0

While trying to test a urwid-based question with input validation and autocomplete suggestions, I am experiencing some difficulties. The code that I am trying to test using pytest is given in the two files below:

InputValidationQuestion.py

from typing import List

import urwid
from typeguard import typechecked

from tui_labeller.file_read_write_helper import write_to_file


@typechecked
def get_filtered_suggestions(
    *, input_text: str, available_suggestions: List[str]
) -> List[str]:
    """
    Filter suggestions based on input text, matching from start with wildcard support.
    Special case: '*' alone shows all available suggestions.

    Args:
        input_text (str): The text entered by user, can include '*' as wildcard
        available_suggestions (list): List of possible suggestion strings

    Returns:
        list: Filtered suggestions based on input criteria
    """
    input_text = input_text.strip()

    # Special case: if input is '*', return all suggestions
    if input_text == "*":
        return available_suggestions

    # If no input, return all suggestions
    if not input_text:
        return available_suggestions

    # Handle wildcard case
    if "*" in input_text:
        # Split input by wildcard
        parts = input_text.lower().split("*")
        prefix = parts[0]  # What comes before the wildcard

        # Filter suggestions
        filtered = [
            suggestion
            for suggestion in available_suggestions
            if suggestion.lower().startswith(prefix)
            and all(part in suggestion.lower() for part in parts[1:] if part)
        ]
    else:
        # Original filtering for non-wildcard case
        filtered = [
            suggestion
            for suggestion in available_suggestions
            if suggestion.lower().startswith(input_text.lower())
        ]

    # If no matches found, return ['-']
    return filtered if filtered else ["-"]


class InputValidationQuestion(urwid.Edit):
    def __init__(
        self, caption, suggestions=None, autocomplete_box=None, pile=None
    ):
        super().__init__(caption=caption)
        self.suggestions = suggestions or []
        self.autocomplete_box = autocomplete_box
        self.pile = pile
        self._in_autocomplete: bool = False

    def handle_autocomplete(self, key, size):
        """Handle autocomplete logic based on input key and suggestions.

        Args:
            key: The pressed key
            size: The size parameter for keypress
        Returns:
            The result of keypress or None if handled
        Raises:
            ValueError: When autocomplete conditions aren't met
        """
        if not self.suggestions:
            write_to_file(
                filename="eg.txt",
                content=f"self.suggestions={self.suggestions}",
                append=True,
            )
            return super().keypress(size, key)

        # Handle automatic substitution when '*' yields single match
        if "*" in self.edit_text:
            matches = [s for s in self.suggestions if self._match_pattern(s)]
            if len(matches) == 1:
                self.set_edit_text(matches[0])
                self.owner.set_attr_map({None: "normal"})
                write_to_file(
                    filename="eg.txt",
                    content=f"self.edit_text={self.edit_text}",
                    append=True,
                )
                return None
            elif len(matches) == 0:
                raise ValueError("No matches found for pattern")
            # TODO: do stuff here.
            # If multiple matches, continue to tab handling

        # Handle tab key press
        if key == "tab":
            matches = [s for s in self.suggestions if self._match_pattern(s)]

            if len(matches) == 1:
                self.set_edit_text(matches[0])
                self.owner.set_attr_map({None: "normal"})
                return None
            elif len(matches) == 0:
                raise ValueError("No matching suggestion found")
            else:
                raise ValueError("Multiple ambiguous suggestions available")

        return super().keypress(size, key)

    def valid_char(self, ch):
        return len(ch) == 1 and (ch.isalpha() or ch in [":", "*"])

    def keypress(self, size, key):
        write_to_file(
            filename="eg.txt",
            content=f"key={key}, self.edit_text={self.edit_text}",
            append=True,
        )
        if key in ["tab", "*"]:
            return self.handle_autocomplete(key, size)
        elif key == "enter":
            return "enter"
        elif key in ("up", "down"):
            if self.pile:
                current_pos = self.pile.focus_position
                new_pos = current_pos - 1 if key == "up" else current_pos + 1
                if 0 <= new_pos < len(self.pile.contents) - 2:
                    self.pile.focus_position = new_pos
                    focused_widget = self.pile.focus
                    if isinstance(focused_widget, urwid.AttrMap):
                        focused_widget.base_widget.update_autocomplete()
                    return None
            return key
        elif key in ("delete", "backspace", "left", "right"):
            write_to_file(
                filename="eg.txt",
                content=f"self.edit_text={self.edit_text}",
                append=True,
            )
            result = super().keypress(size, key)
            self.update_autocomplete()
            return result
        elif self.valid_char(key):
            result = super().keypress(size, key)
            self.update_autocomplete()
            return result
        return None

    def _match_pattern(self, suggestion):
        pattern = self.edit_text.lower().replace("*", ".*")
        return bool(re.match(f"^{pattern}$", suggestion.lower()))

    def update_autocomplete(self):
        if self._in_autocomplete:  # Prevent recursion
            return

        if not self.autocomplete_box:
            return

        self._in_autocomplete = True  # Set flag
        try:
            remaining_suggestions = get_filtered_suggestions(
                input_text=self.edit_text,
                available_suggestions=self.suggestions,
            )

            suggestions_text = ", ".join(remaining_suggestions)
            write_to_file(
                filename="eg.txt",
                content=f"suggestions_text={suggestions_text}",
                append=True,
            )
            self.autocomplete_box.base_widget.set_text(suggestions_text)
            self.autocomplete_box.base_widget._invalidate()

            if "*" in self.edit_text:
                if len(remaining_suggestions) == 1:
                    # Use set_edit_text instead of direct assignment to avoid triggering signals
                    self.set_edit_text(remaining_suggestions[0])
            else:
                self.owner.set_attr_map({None: "normal"})
        finally:
            self._in_autocomplete = False  # Reset flag


class QuestionApp:
    def __init__(self):
        self.questions = [
            ("Question 1: ", ["apple", "apricot", "avocado"]),
            ("Question 2: ", ["banana", "blueberry", "blackberry"]),
            ("Question 3: ", ["cat", "caterpillar", "cactus"]),
        ]

        self.palette = [
            ("normal", "white", "black"),
            ("highlight", "white", "dark red"),
            ("autocomplete", "yellow", "dark blue"),
        ]

        self.autocomplete_box = urwid.AttrMap(
            urwid.Text("", align="left"), "autocomplete"
        )

        self.pile = urwid.Pile([])
        self.inputs = []
        for question, suggestions in self.questions:
            edit = InputValidationQuestion(
                question, suggestions, self.autocomplete_box, self.pile
            )
            attr_edit = urwid.AttrMap(edit, "normal")
            edit.owner = attr_edit
            self.inputs.append(attr_edit)

        self.pile.contents = [
            (self.inputs[0], ("pack", None)),
            (self.inputs[1], ("pack", None)),
            (self.inputs[2], ("pack", None)),
            (urwid.Divider(), ("pack", None)),
            (
                urwid.Columns(
                    [(30, urwid.Text("Autocomplete: ")), self.autocomplete_box]
                ),
                ("pack", None),
            ),
        ]

        self.fill = urwid.Filler(self.pile, valign="top")
        self.loop = urwid.MainLoop(
            self.fill, self.palette, unhandled_input=self.handle_input
        )

    def handle_input(self, key):
        print(f"Unhandled input: {key}")
        write_to_file(
            filename="eg.txt", content=f"Unhandled input: {key}", append=False
        )
        # TODO: if cursor is at the first question and up is pressed, go to last question.

        # TODO: if cursor is at the last question and down is pressed, go to first question.
        raise ValueError(f"STOPPED at:{key}")

    def run(self):
        def update_autocomplete(widget, new_text):
            widget.update_autocomplete()

        for input_widget in self.inputs:
            urwid.connect_signal(
                input_widget.base_widget, "change", update_autocomplete
            )

        if self.inputs:
            self.pile.focus_position = 0
            self.inputs[0].base_widget.update_autocomplete()

        self.loop.run()

InputValidationQuestions.py:

import urwid

from tui_labeller.file_read_write_helper import write_to_file
from tui_labeller.tuis.urwid.InputValidationQuestion import (
    InputValidationQuestion,
)


class InputValidationQuestions:
    def __init__(self):
        self.questions = [
            ("Question 1: ", ["apple", "apricot", "avocado"]),
            ("Question 2: ", ["banana", "blueberry", "blackberry"]),
            ("Question 3: ", ["cat", "caterpillar", "cactus"]),
        ]

        self.palette = [
            ("normal", "white", "black"),
            ("highlight", "white", "dark red"),
            ("autocomplete", "yellow", "dark blue"),
        ]

        self.autocomplete_box = urwid.AttrMap(
            urwid.Text("", align="left"), "autocomplete"
        )

        self.pile = urwid.Pile([])
        self.inputs = []
        for question, suggestions in self.questions:
            edit = InputValidationQuestion(
                question, suggestions, self.autocomplete_box, self.pile
            )
            attr_edit = urwid.AttrMap(edit, "normal")
            edit.owner = attr_edit
            self.inputs.append(attr_edit)

        self.pile.contents = [
            (self.inputs[0], ("pack", None)),
            (self.inputs[1], ("pack", None)),
            (self.inputs[2], ("pack", None)),
            (urwid.Divider(), ("pack", None)),
            (
                urwid.Columns(
                    [(30, urwid.Text("Autocomplete: ")), self.autocomplete_box]
                ),
                ("pack", None),
            ),
        ]

        self.fill = urwid.Filler(self.pile, valign="top")
        self.loop = urwid.MainLoop(
            self.fill, self.palette, unhandled_input=self.handle_input
        )

    def handle_input(self, key):
        print(f"Unhandled input: {key}")
        write_to_file(
            filename="eg.txt", content=f"Unhandled input: {key}", append=False
        )
        # TODO: if cursor is at the first question and up is pressed, go to last question.

        # TODO: if cursor is at the last question and down is pressed, go to first question.
        raise ValueError(f"STOPPED at:{key}")

    def run(self):
        def update_autocomplete(widget, new_text):
            widget.update_autocomplete()

        for input_widget in self.inputs:
            urwid.connect_signal(
                input_widget.base_widget, "change", update_autocomplete
            )

        if self.inputs:
            self.pile.focus_position = 0
            self.inputs[0].base_widget.update_autocomplete()

        self.loop.run()

These are ran with:

from typeguard import typechecked

from src.tui_labeller.tuis.urwid.InputValidationQuestions import (
    InputValidationQuestions,
)


app = InputValidationQuestions()
app.run()

Expected behaviour

The class handles incremental input (e.g., typing "a,v," selects "avocado") in a TUI app launched with python -m src.tui_labeller -i <image> -o <dir> -t urwid. In the real run, "a,v," works, and pressing Enter raises a ValueError: STOPPED at:enter, which is expected/fine.

Test behaviour

However, my test fails because the handle_input method raises ValueError: STOPPED at:a on the first keypress ("a"), stopping further processing.

Test Code

Below is the code that attempts to test the code above:

import urwid
import pytest
from tui_labeller.tuis.urwid.SUBSTITUECLASSHERE import SUBSTITUECLASSHERE
@pytest.fixture
def app():
    app = SUBSTITUECLASSHERE()
    app.loop.screen = urwid.raw_display.Screen()
    return app
def test_avocado_selection(app):
    input_sequence = ["a", "v", "*"]
    for key in input_sequence:
        app.loop.process_input([key])
        if hasattr(app.loop, "unhandled_input") and app.loop.unhandled_input:
            app.loop.unhandled_input(key)
    # How to check "avocado" is selected? Edit widget, ListBox, or custom attribute?
def test_enter_error_handling(app):
    input_sequence = ["a", "v", "*", "enter"]
    with pytest.raises(ValueError, match="STOPPED at:enter"):
        for key in input_sequence:
            app.loop.process_input([key])
            if hasattr(app.loop, "unhandled_input") and app.loop.unhandled_input:
                app.loop.unhandled_input(key)

The test fails with:

ValueError: STOPPED at:a
AssertionError: Regex pattern 'STOPPED at:enter' did not match 'STOPPED at:a'

Question

How do I modify the test to:

  1. Process "a,v,*" to select "avocado" without raising errors early,
  2. Verify the selection (what widget/attribute holds it?)

1 Answer 1

0

A solution was found with:

from pprint import pprint
from typing import List
import urwid
import pytest
from tui_labeller.tuis.urwid.InputValidationQuestions import InputValidationQuestions

@pytest.fixture
def app():
    app = InputValidationQuestions()
    app.loop.screen = urwid.raw_display.Screen()
    return app

def assert_autocomplete_options(the_question, expected_options: List[str], step: str):
    """Helper function to compare autocomplete options with expected list."""
    # Extract the text from the autocomplete_box widget
    actual_widget = the_question._original_widget.autocomplete_box.original_widget
    actual_text = actual_widget.text  # Get the Text widget’s content
    # Split the comma-separated string into a list, stripping whitespace
    actual_options = [opt.strip() for opt in actual_text.split(",")]
    # Sort both lists to ignore order differences
    actual_options.sort()
    expected_options_sorted = sorted(expected_options)
    assert actual_options == expected_options_sorted, (
        f"After '{step}', expected {expected_options_sorted}, got '{actual_options}'"
    )
    # Debug output
    pprint(f"Autocomplete text after '{step}': {actual_text}")

def test_avocado_selection(app):
    input_sequence = ["a", "*", "t"]
    the_question = app.inputs[0]  # Assuming inputs[0] is the widget we’re testing

    # Step 1: Press "a"
    the_question.keypress(1, "a")
    expected_after_a = ["avocado", "apple", "apricot"]
    assert_autocomplete_options(the_question, expected_after_a, "a")

    # Step 2: Press "*"
    the_question.keypress(1, "*")
    expected_after_star = ["avocado", "apple", "apricot"]
    assert_autocomplete_options(the_question, expected_after_star, "*")

    # Step 3: Press "t"
    the_question.keypress(1, "t")
    expected_after_t = ["apricot"]
    assert_autocomplete_options(the_question, expected_after_t, "t")

I currently do not see an overarching logic to the test develop strategy beyond: finding a way to send keys to the code without it throwing an error, and printing all the objects and child objects until you see change what you expect to see change, and then combining those two solutions into a workable test.

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.