diff --git a/docs/CHANGELOG.rst b/docs/CHANGELOG.rst index bc557fc..f96c53d 100644 --- a/docs/CHANGELOG.rst +++ b/docs/CHANGELOG.rst @@ -15,12 +15,13 @@ Runtime Changes: prior to the start of the first block, and likewise ensures that demographics collection is not performed more than once. * Demographics collection has been changed so that queries in - `user_queries.json` are skipped if they do not correspond to a column in the + ``user_queries.json`` are skipped if they do not correspond to a column in the `participants` table of the database. Additionally, the query for the participant's unique identifer is now always collected first. * Demographics collection now fails immediately with an informative message if a required column in the `participants` table does not have a corresponding query. +* Custom EVAL statements in ``user_queries.json`` files are no longer supported. * EyeLink EDF files collected while in development mode are now saved in the subfolder ``EDF/devmode/`` to separate them from actual participant data. * ``klibs export`` no longer exports data collected in development mode unless @@ -56,6 +57,11 @@ Runtime Changes: API Changes: +* Added a new module :mod:`~klibs.KLQueries` which provides a set of classes + for prompting participants for input and collecting/validating responses. +* The :func:`~klibs.KLCommunication.query` function has been deprecated in + favour of the :mod:`~klibs.KLQueries` module and should be not be used for + new projects. * Added a new attribute :attr:`~klibs.Experiment.participant_info` to the Experiment class that provides a dictionary for easy access to participant information collected during demographics (e.g. handedness). diff --git a/docs/source/api.rst b/docs/source/api.rst index 0336485..e0c1da0 100755 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -32,6 +32,7 @@ Useful Modules api/eyetracking.rst api/eventinterface.rst api/structure.rst + api/queries.rst Advanced Modules diff --git a/docs/source/api/queries.rst b/docs/source/api/queries.rst new file mode 100644 index 0000000..1ceec2c --- /dev/null +++ b/docs/source/api/queries.rst @@ -0,0 +1,5 @@ +`klibs.KLQueries` - Simple text input prompts +============================================= + +.. automodule:: klibs.KLQueries + :members: diff --git a/klibs/KLCommunication.py b/klibs/KLCommunication.py index b23a042..7af7933 100755 --- a/klibs/KLCommunication.py +++ b/klibs/KLCommunication.py @@ -18,11 +18,26 @@ from klibs.KLRuntimeInfo import runtime_info_init, TaskArchiver from klibs.KLGraphics import blit, clear, fill, flip from klibs.KLUserInterface import ui_request, any_key -from klibs.KLText import _get_text_style, add_text_style, message - - -user_queries = None -default_strings = None +from klibs.KLText import _get_text_style, message + +user_queries = None # [Compat]: Only needed for TraceLab and BaseballTOJ + + +def _parse_eval(statement): + # Parse legacy eval statements without actually using eval + if not isinstance(statement, str): + return statement + elif statement[:5] != "EVAL:": + return statement + elif statement == "EVAL: now()": + return now() + elif statement == "EVAL: int(now())": + return int(now()) + elif statement[:9] == "EVAL: 'm'": + return ("m" if now() % 2 > 1 else "f") + else: + e = "Custom EVAL statements are no longer supported." + raise RuntimeError(e) def _get_demographics_queries(db, queries): @@ -155,11 +170,10 @@ def collect_demographics(anonymous=False): def init_messaging(): - # This function should be removed somehow: user queries should be a) redone, and - # b) loaded in with a regular function - # Need to do in a way that won't break TraceLab + # This function should be removed as soon as use of 'experimental' queries + # is removed from BaseballTOJ and TraceLab and replaced with an internal + # _load_demographics function. global user_queries - global default_strings # Load the user_queries file in and store the queries in an object try: @@ -171,45 +185,14 @@ def init_messaging(): err = "Error: user_queries.json file missing required section '{0}'." cso(err.format(req)) raise ValueError() - default_strings = user_queries.default_strings # for easy accessiblity except ValueError: raise ValueError("User queries file has at least one formatting error; cannot continue.") def query(query_ob, anonymous=False): - '''Asks the user a question and collects the response via the keyboard. Intended for use with - the queries contained within a project's user_queries.json file. This function is used - internally for collecting demographics at the start of each run, but can also be used during - experiment runtime to collect info from participants based on the queries contained in the - "experimental" section of the user_queries.json file. - - Args: - query_ob (:class:`~klibs.KLJSON_Object.AttributeDict`): The object containing the query - to present. See :obj:`~klibs.KLCommunication.user_queries` for more information. - anonymous (bool, optional): If True, will immediately return the query object's anonymous - value without prompting the user (used interally for P.development_mode). Defaults to - False. - - Returns: - The response to the query, coerced to the type specified by query_ob.format.type (can be - str, int, float, bool, or None). - - Raises: - ValueError: If the query object's type is not one of "str", "int", "float", "bool", or None, - or if a query_ob.format.range value is given and the type is not "int" or "float". - TypeError: If query_ob.accepted is specified and it is not a list of values, or if a - query_ob.format.range is specified and it is not a two-item list. - - ''' + # [Compat]: Deprecated, only needed by TraceLab and BaseballTOJ if anonymous: - try: - # Check if anon value is an EVAL statement, and if so evaluate it - eval_statement = re.match(re.compile(u"^EVAL:[ ]*(.*)$"), query_ob.anonymous_value) - if eval_statement: - query_ob.anonymous_value = eval(eval_statement.group(1)) - except TypeError: - pass - return query_ob.anonymous_value + return _parse_eval(query_ob.anonymous_value) f = query_ob.format if f.type not in ("int", "float", "str", "bool", None): @@ -246,6 +229,7 @@ def query(query_ob, anonymous=False): # Create an informative error message for invalid responses accepted_responses = query_ob.accepted # for code readability + default_strings = user_queries['default_strings'] try: if accepted_responses: try: diff --git a/klibs/KLGraphics/KLNumpySurface.py b/klibs/KLGraphics/KLNumpySurface.py index 17c99b4..2f5fae7 100755 --- a/klibs/KLGraphics/KLNumpySurface.py +++ b/klibs/KLGraphics/KLNumpySurface.py @@ -168,6 +168,9 @@ def blit(self, source, registration=7, location=(0,0), clip=True, blend=True): Valid source content types include :obj:`NumpySurface` objects, :obj:`Drawbject` shapes, and :obj:`numpy.ndarray` or :obj:`Pillow.Image` objects in RGBA format. + See the :func:`~klibs.KLGraphics.blit` documentation for more information on the + `registration` and `location` parameters. + Args: source: The content to draw to the surface. registration (int, optional): An integer from 1 to 9 indicating which point on the diff --git a/klibs/KLGraphics/core.py b/klibs/KLGraphics/core.py index cc5e68a..53a2c92 100644 --- a/klibs/KLGraphics/core.py +++ b/klibs/KLGraphics/core.py @@ -164,19 +164,42 @@ def fill(color=None, context=None): def blit(source, registration=7, location=(0,0), flip_x=False): - """ - Draws passed content to the display buffer. All content that is not in already - rendered to Numpy Array format will be rendered when it is passed to this - function, thus it is recommended to render beforehand whenever possible to - avoid performance issues resulting from the extra overhead. + """Draws a source image or texture to the display buffer. + + Valid source content types include :obj:`~NumpySurface` objects, :obj:`~Drawbject` + shapes, and Numpy arrays (:obj:`numpy.ndarray`) in RGBA format. + + When drawing to the display buffer, you can specify both a location and a + registration for the texture. The `registration` parameter is a digit from 1 to 9 + that defines an anchor point on the texture to align with the given location, with + the different digits corresponding to different anchor points on the texture: + + .. code-block:: text + + 7 -- 8 -- 9 1 = Bottom Left 6 = Middle Right + | | | 2 = Bottom Center 7 = Top Left + 4 -- 5 -- 6 3 = Bottom Right 8 = Top Center + | | | 4 = Middle Left 9 = Top Right + 1 -- 2 -- 3 5 = Center + + For example, the default registration of 7 indicates that the *top-left corner* of + the texture should be drawn to the specified location. If you wanted to draw + something with its `midpoint` aligned to the given location, you would use a + registration of 5:: + + # Draw fixation to middle of screen + fill() + blit(fixation, 5, P.screen_c) + flip() - Valid source content types include :obj:`NumpySurface` objects, :obj:`Drawbject` shapes, - and :obj:`numpy.ndarray` or :obj:`Pillow.Image` objects in RGBA format. + Note that drawing an image to the display buffer does not cause that image to + appear immediately on screen: anything drawn to the buffer will not be shown to + the user until :func:`~flip` is called. Args: source: Image data to draw to the display buffer. - registration (int): An integer from 1 to 9 indicating which location on the - surface will be aligned to the location value (see manual for more info). + registration (int): An integer from 1 to 9 indicating the anchor point on the + source to align with the given location. Defaults to the top-left corner. location(tuple(int,int)): A tuple of x,y pixel coordinates indicating where to draw the object to. flip_x (bool): If True, flips the content along its x-axis before drawing to diff --git a/klibs/KLQueries.py b/klibs/KLQueries.py new file mode 100644 index 0000000..05b2e66 --- /dev/null +++ b/klibs/KLQueries.py @@ -0,0 +1,510 @@ +""" + +The KLQueries module provides a simple set of classes for presenting queries and +collecting/validating their responses. + +There are many situations where you might want to prompt a numeric, categorical, or +free-form text response from a participant. For example, in an implicit learning task +you might want to ask the participant at the end of the task whether they thought they +had noticed a repeating sequence during the experiment. If the participant responds +that they did, you might also want to prompt them to report that sequence to the best +of their memory:: + + learned_q = ChoiceQuery( + "Do you feel like you noticed a repeating sequence? (y/n)", + choices = ['y', 'n'] + ) + seq_learned = learned_q.prompt() + + seq = None + if seq_learned == "y": + seq_q = TextQuery( + "Please enter the sequence to the best of your memory:", + max_len=10, case='lower' + ) + seq = seq_q.prompt() + +When a Query response is collected, the query text is presented on screen and the +user's response is rendered underneath it as it is entered on the keyboard. Once +the user presses the Return/Enter key, the response is checked to ensure it meets +the criteria defined by the query type and options (e.g. that the response matches +one of the possible response options for a ChoiceQuery). If the response is valid, +it will be returned immediately. If not, the response will be cleared and an +informative error message is presented (e.g. "Please enter one of the following +options: [ y or n ]") prompting the user to try again. + +""" + +import sdl2 +from klibs import P +from klibs.KLInternal import iterable +from klibs.KLUtilities import make_hash, pretty_list +from klibs.KLEventQueue import flush, pump +from klibs.KLUserInterface import ui_request +from klibs.KLText import TextStyle, message, _get_text_style +from klibs.KLGraphics import fill, blit, flip + + +class QueryStyle(): + """Defines a custom style to use for rendering a Query. + + By default, queries will use the default text style and other aesthetics from the + klibs runtime. This class allows for customizing the appearance of queries by + overriding one or more of these defaults:: + + # Configures the query to appear centered on the screen + q_style = QueryStyle(location=P.screen_c, registration=5) + rating_q = NumberQuery( + "On a scale from 1 to 10, how well do you think you did?", + min=1, max=10, style=q_style + ) + + If `location` and `registration` are not provided, queries default to being + center-aligned and presented at the top of the screen (see the + :func:`~klibs.KLGraphics.blit` documentation for a detailed explanation of + these parameters). + + Additional style parameters that can be overridden include the text style, the + text alignment, the vertical offset of the input relative to the query, the + background fill color, and the colour to use for error text. + + The input offset is relative to the line spacing of its text style. For example, + if a text style has a line spacing equivalent to 40 px between lines, a query with + an input offset of 1.5 will have a `60 px` offset between the last line of query + text and the rendered input. + + Args: + textstyle (str or :obj:`~KLText.TextStyle`): The text style (font, size, color, + etc.) to use for rendering the Query. Defaults to the default text style. + align (str, optional): The text alignment to use ('left', 'right', or 'center') + for the query and input text. Defaults to center alignment. + registration (int, optional): An integer from 1 to 9 indicating the anchor point + on the query text to align with the given location. Defaults to the midpoint + of the bottom edge. + location (tuple, optional): A custom location in (x, y) pixel coordinates + specifying where to draw the query text. Defaults to slightly above the + middle of the screen. + input_offset (float, optional): The vertical offset between the last line of + query text and the rendered input in units of line spacing. + bg_color (tuple, optional): A custom background fill color to use for the query. + err_color (tuple, optional): A custom color to use for error text if an invalid + response is entered. + + """ + def __init__( + self, + textstyle='default', + align='center', + registration=2, + location=None, + input_offset=2.0, + bg_color=None, + err_color=None + ): + self.reg = registration + self.loc = location if location else (P.screen_c[0], int(P.screen_y * 0.48)) + self.align = align + self.input_offset = input_offset + self.bg_color = bg_color + self._init_text_styles(textstyle, err_color) + + def _validate_options(self): + if self.input_offset < 0: + e = "Query input offset must be a positive value." + raise ValueError(e) + + def _init_text_styles(self, style, err_color): + # Get the error style and input offset based on the params & text style + if not isinstance(style, TextStyle): + style = _get_text_style(style) + self.style = style + err_color = P.default_alert_color if err_color == None else err_color + self.err = TextStyle(style.fontname, style._size, err_color) + line_offset = int(round(style.size_px * style.line_space)) + end_pad = line_offset - style._lineskip_px + self.input_offset_px = end_pad + ((self.input_offset - 1) * line_offset) + + +class Query(): + """An abstract base class for queries. + + Query objects present simple prompts to users and collect/validate their + responses. + + Args: + q (str or list): The text of the query to present to the participant. Can + be a string or a list of strings. + style (:obj:`~QueryStyle`, optional): Optional aesthetic overrides defining the + appearance and location of the query. + + """ + def __init__(self, q, style=None): + self.q = "\n".join(q) if iterable(q) else q + self.style = style if style else QueryStyle() + self._q_msg = message(self.q, self.style.style, self.style.align) + self._input_loc, self._input_reg = self._get_input_position(self.style) + self.password = False + + def _format_input(self, resp): + # Takes the current query input and formats/sanitizes it + return resp + + def _validate_response(self, resp): + # Checks whether the response is valid for the current query, + # returning an error string to be displayed if not + return None + + def _format_response(self, resp): + # Applies any final formatting to the response before returning it + return resp + + def _get_input_position(self, style): + # Given query location/registration/size, get bottom-left corner + y_offset, x_offset = (0, 0) + + if style.reg >= 7: + y_offset = self._q_msg.height + elif style.reg >= 4: + y_offset = self._q_msg.height / 2 + + if style.reg % 3 == 0: + x_offset = -self._q_msg.width / 2 + elif style.reg % 3 == 1: + x_offset = self._q_msg.width / 2 + + q_bcx = style.loc[0] + x_offset + q_bcy = style.loc[1] + y_offset + if style.align == "center": + input_x = q_bcx + input_reg = 8 + elif style.align == "left": + input_x = int(q_bcx - self._q_msg.width / 2) + input_reg = 7 + elif style.align == "right": + input_x = int(q_bcx + self._q_msg.width / 2) + input_reg = 9 + + input_y = int(q_bcy + style.input_offset_px) + return (input_x, input_y), input_reg + + def _render(self, userinput, err=None): + # Renders the query and any current input/error to the screen + if err: + resp_msg = message(err, self.style.err) + else: + if self.password: + userinput = len(userinput) * '*' + resp_msg = message(userinput, self.style.style) + + fill(self.style.bg_color) + blit(self._q_msg, self.style.reg, self.style.loc) + blit(resp_msg, self._input_reg, self._input_loc) + flip() + + def prompt(self): + """Presents the query and collects a response. + + This method draws the query to the screen and waits for the user to + enter a response using the keyboard. The user's text input will be shown + under the query as it is entered. + + Responses are submitted by pressing the Enter (or Return) key. The response + will be validated once submitted. If the response is invalid, error text + explaining how to respond correctly will be presented and will remain on + screen until the user begins entering another response. + + Returns: + The collected response. + + """ + err = None + resp = "" + self._render(resp) + + flush() + sdl2.SDL_StartTextInput() + while True: + for event in pump(): + + if event.type == sdl2.SDL_TEXTINPUT: + resp += event.text.text.decode('utf-8') + resp = self._format_input(resp) + self._render(resp, err) + + elif event.type == sdl2.SDL_KEYDOWN: + ui_request(event.key.keysym) + key = event.key.keysym.sym + + # If escape pressed, clear all existing input + if key == sdl2.SDLK_ESCAPE: + resp = "" + + # If backspace pressed, remove last character + elif key == sdl2.SDLK_BACKSPACE: + resp = resp[:-1] + + # If Enter/Return pressed, check the response and either return + # if valid or display an error if not + elif key in (sdl2.SDLK_KP_ENTER, sdl2.SDLK_RETURN): + err = self._validate_response(resp) + if not err: + sdl2.SDL_StopTextInput() + return self._format_response(resp) + + # Whenever a key is pressed, redraw the screen + self._render(resp, err) + if err: + resp = "" + err = None + + +class NumberQuery(Query): + """Collects a numeric response from the user. + + NumberQueries ensure that the response is a valid number (specifically an + integer unless ``decimal`` is True) and falls within the allowed range. If + the user submits a non-numeric or out-of-range response, an error message + reiterating the response requirements will be shown, prompting the user to + try again:: + + rating_q = NumberQuery( + "Please enter a number between 1 and 7" + min=1, max=7 + ) + rating = rating_q.prompt() + + Args: + q (str or list): The text of the query to present to the participant. Can + be a string or a list of strings. + min (float, optional): The lowest response allowed. Defaults to 0. + max (float, optional): The highest response allowed. Defaults to 1000. + decimal (bool, optional): Whether decimal numbers (e.g. 7.5) should be allowed + as responses. Defaults to False (integers only). + allow_blank (bool, optional): Whether blank responses should be allowed. + Defaults to False. + style (:obj:`~QueryStyle`, optional): Optional aesthetic overrides defining the + appearance and location of the query. + + """ + def __init__(self, q, min=0, max=1000, decimal=False, allow_blank=False, style=None): + super(NumberQuery, self).__init__(q, style) + self.min = min + self.max = max + self.float = decimal + self.allow_blank = allow_blank + self._validate_options() + + def prompt(self): + """Presents the query and collects a numeric response. + + Returns as soon as a valid response has been submitted. + + Returns: + The number (int or float) entered by the user. + + """ + return super(NumberQuery, self).prompt() + + def _validate_options(self): + if self.max < self.min: + raise ValueError("Maximum value must be larger than minimum value.") + + def _format_input(self, s): + s = s.strip() + return s + + def _validate_response(self, resp): + err = None + # If allow null and empty response, accept automatically + if self.allow_blank and resp == "": + return None + # Make sure response is a valid number + try: + resp = float(resp) if self.float else int(resp) + except ValueError: + err = "Please respond with a whole number." + if self.float: + err = err.replace("whole ", "") + return err + # Make sure response is within the specified range + if resp < self.min or resp > self.max: + err = "Please respond with a number between {} and {}, inclusive." + err = err.format(self.min, self.max) + return err + + def _format_response(self, resp): + if resp == "": + return None + elif self.float: + return float(resp) + return int(resp) + + +class ChoiceQuery(Query): + """Collects a multiple-choice response from the user. + + ChoiceQueries ensure that the response matches one of the possible response + options, prompting the user to try again they enter an invalid response:: + + cond_q = ChoiceQuery( + "Please enter the condition to use for this participant:", + choices=["A", "B", "C"], case="upper" + ) + condition = cond_q.prompt() + + ChoiceQueries are not case sensitive, meaning that a response of 'a' will + still match with (and return) 'A' if it exists as a response option. For + aesthetic purposes you can enforce that user input is rendered in upper case, + lower case, or title case, but this will not affect the returned string. + + Args: + q (str or list): The text of the query to present to the user. Can + be a string or a list of strings. + choices (list): The list of possible response options. + case (str, optional): The case to use when rendering user input, can be + 'upper', 'lower', 'title', or None. Defaults to None. + style (:obj:`~QueryStyle`, optional): Optional aesthetic overrides defining the + appearance and location of the query. + + """ + def __init__(self, q, choices, case=None, style=None): + super(ChoiceQuery, self).__init__(q, style) + self.choices = choices + self.case = case + self._validate_options() + + def prompt(self): + """Presents the query and collects a multiple-choice response. + + Returns as soon as a valid response has been submitted. + + Returns: + str: The choice entered by the user. + + """ + return super(ChoiceQuery, self).prompt() + + def _validate_options(self): + # Make sure at least two response options and convert to strings if necessary + if not iterable(self.choices) or len(self.choices) < 2: + e = "Choice queries must have at least two response options." + raise RuntimeError(e) + # Ensure case is a valid value + if self.case and self.case not in ['lower', 'upper', 'title']: + e = "Query case must be either 'lower', 'upper', 'title', or None." + raise RuntimeError(e) + self.choices = [str(choice) for choice in self.choices] + + def _format_input(self, s): + s = s.lstrip().strip("\n") + if self.case == 'upper': + s = s.upper() + elif self.case == 'lower': + s = s.lower() + elif self.case == 'title': + s = s.title() + return s + + def _validate_response(self, resp): + err = None + # Check if response matches a valid option + if resp.lower() not in [c.lower() for c in self.choices]: + opts = pretty_list(self.choices) + err = "Please respond with one of the following options: " + opts + return err + + def _format_response(self, resp): + # Always return chosen option exactly, ignoring case of response + for choice in self.choices: + if choice.lower() == resp.lower(): + resp = choice + return resp + + +class TextQuery(Query): + """Collects a free-form text response from the user. + + TextQueries provide minimal restrictions on user responses, ensuring only + that the response is between the minimum and maximum character count. If the + minimum character count is 0, then empty responses will be allowed:: + + seq_q = TextQuery( + "If you feel like you learned a sequence, please enter it now:", + min_length=0, max_length=10, case='upper' + ) + sequence = seq_q.prompt() + + + For aesthetic or input normalization purposes, you can specify the case of the + response (lower case, upper case, or title case). If specified, all input will + be rendered and returned in that case. + + Args: + q (str or list): The text of the query to present to the user. Can + be a string or a list of strings. + min_length (int, optional): The minimum character count. Defaults to 1. + max_length (int, optional): The maximum character count. Defaults to 50. + case (str, optional): The case to use when formatting the user input. Can be + 'upper', 'lower', 'title', or None. + password (bool, optional): If True, user input will be rendered as asterisks + for the purpose of privacy. Defaults to False. + style (:obj:`~QueryStyle`, optional): Optional aesthetic overrides defining the + appearance and location of the query. + + """ + def __init__( + self, q, min_length=1, max_length=50, case=None, password=False, style=None + ): + super(TextQuery, self).__init__(q, style) + self.min_l = min_length + self.max_l = max_length + self.case = case + self.password = password + self._validate_options() + + def prompt(self): + """Presents the query and collects a freeform text response. + + Returns as soon as a valid response has been submitted. + + Returns: + str: The response entered by the user. + + """ + return super(TextQuery, self).prompt() + + def _validate_options(self): + if self.min_l > self.max_l: + raise ValueError("Maximum response length must be larger than the minimum.") + if self.case and self.case not in ['lower', 'upper', 'title']: + e = "Query case must be either 'lower', 'upper', 'title', or None." + raise RuntimeError(e) + + def _format_input(self, s): + s = s.lstrip().strip("\n") + if self.case == 'upper': + s = s.upper() + elif self.case == 'lower': + s = s.lower() + elif self.case == 'title': + s = s.title() + return s + + def _validate_response(self, resp): + err = None + # Check if response is too long or short + if self.min_l == self.max_l and len(resp) != self.min_l: + err = "Your response must be {} characters long.".format(self.min_l) + elif len(resp) < self.min_l or len(resp) > self.max_l: + err = "Your response must be between {} and {} characters long." + err = err.format(self.min_l, self.max_l) + return err + + def _format_response(self, resp): + if self.case == 'upper': + resp = resp.upper() + elif self.case == 'lower': + resp = resp.lower() + elif self.case == 'title': + resp = resp.title() + return resp diff --git a/klibs/KLText.py b/klibs/KLText.py index 413e89a..6c3d0c8 100755 --- a/klibs/KLText.py +++ b/klibs/KLText.py @@ -106,9 +106,8 @@ def _render_line(text, font, color): def _render_multiline(lines, style, width, align): """Renders multiple lines of text with a given justification to a surface.""" # Determine required surface height and create surface - line_pad = int(round(style.size_px * (style.line_space - 1.0))) - net_line_height = style.size_px + line_pad - height = (len(lines) - 1) * net_line_height + style._lineskip_px + line_offset = int(round(style.size_px * style.line_space)) + height = (len(lines) - 1) * line_offset + style._lineskip_px surf_fill = list(style.color[:3][::-1]) + [0] # Needed for correct alpha blending surf = _create_surface((width, height), fill=surf_fill) # Render all lines with proper alignment to surface @@ -123,7 +122,7 @@ def _render_multiline(lines, style, width, align): line_x = int((width - lw) / 2) elif align == "right": line_x = width - lw - line_rect = SDL_Rect(line_x, i * net_line_height, lw, lh) + line_rect = SDL_Rect(line_x, i * line_offset, lw, lh) SDL_BlitSurface(line, None, surf, line_rect) SDL_FreeSurface(line) diff --git a/klibs/tests/test_KLQueries.py b/klibs/tests/test_KLQueries.py new file mode 100644 index 0000000..3b9af34 --- /dev/null +++ b/klibs/tests/test_KLQueries.py @@ -0,0 +1,101 @@ +import pytest + +from klibs.KLText import add_text_style +from klibs.KLQueries import QueryStyle, NumberQuery, ChoiceQuery, TextQuery + + +def test_query_style(with_text_init): + add_text_style("querystyle-test", size = '30px', line_space = 2.0) + # Test init for QueryStyles + s1 = QueryStyle(align="left", registration=7, location=(100, 100)) + s2 = QueryStyle("querystyle-test", bg_color=(0, 0, 0), err_color=(0, 0, 255)) + s3 = QueryStyle("querystyle-test", input_offset=1.5) + with pytest.raises(RuntimeError): + QueryStyle("nonexistent-text-style") + # Test that input offset calculated correctly + end_pad = 60 - s3.style._lineskip_px + assert s3.input_offset_px == (30 + end_pad) + + +def test_number_query(with_text_init): + # Test init for NumberQueries + q1 = NumberQuery("Which number is best?") + q2 = NumberQuery("Enter a value:", min=-100, max=100, decimal=True) + q3 = NumberQuery(["Multiple", "Lines"], allow_blank=True) + with pytest.raises(ValueError): + NumberQuery("bad min/max", min=100, max=10) + + # Test response validation + assert q1._validate_response("3") == None + assert q2._validate_response("3.6") == None + assert q3._validate_response("") == None + assert q1._validate_response("3.6") != None + assert q1._validate_response("") != None + assert q1._validate_response("99999") != None + assert q1._validate_response("-1") != None + assert q1._validate_response("text") != None + assert q2._validate_response("text") != None + + # Test internal methods + assert q1._format_input(" 3 ") == "3" + assert q1._format_response("4") == 4 + assert q2._format_response("5.24") == 5.24 + assert q3._format_response("") == None + + +def test_choice_query(with_text_init): + # Test init for ChoiceQueries + q1 = ChoiceQuery("Which one?", ["a", "b", "c"]) + q2 = ChoiceQuery("Enter a condition:", ["PP", "MI", "CC"], case="upper") + q3 = ChoiceQuery(["Multiple", "Lines"], [1, 2, 3, 4, 5], case="lower") + with pytest.raises(RuntimeError): + ChoiceQuery("no choice", ["a"]) + with pytest.raises(RuntimeError): + ChoiceQuery("bad case", ["a", "b"], case="cool") + + # Test response validation + assert q1._validate_response("a") == None + assert q1._validate_response("b") == None + assert q1._validate_response("C") == None + assert q1._validate_response("d") != None + assert q2._validate_response("pp") == None + assert q2._validate_response("PP") == None + assert q3._validate_response("1") == None + + # Test internal methods + assert q1._format_input(" a ") == "a " + assert q2._format_input("test") == "TEST" + assert q3._format_input("TEST") == "test" + assert q1._format_response("A") == "a" + assert q2._format_response("cc") == "CC" + assert q3._format_response("5") == "5" + + +def test_text_query(with_text_init): + # Test init for TextQueries + q1 = TextQuery("Write something:", max_length=3) + q2 = TextQuery("YELL SOMETHING:", min_length=5, max_length=5, case="upper") + q3 = TextQuery(["Multiple", "Lines"], min_length=0, case="title") + with pytest.raises(ValueError): + TextQuery("bad min/max", min_length=10, max_length=5) + with pytest.raises(RuntimeError): + TextQuery("bad case", case="cool") + + # Test response validation + assert q1._validate_response("a") == None + assert q1._validate_response("abc") == None + assert q1._validate_response("") != None + assert q1._validate_response("abcd") != None + assert q2._validate_response("12345") == None + assert q2._validate_response("123456") != None + assert q3._validate_response("123456") == None + assert q3._validate_response("") == None + + # Test internal methods + assert q1._format_input(" a ") == "a " + assert q2._format_input("test") == "TEST" + assert q3._format_input("TEST") == "Test" + assert q1._format_response("A") == "A" + assert q2._format_response("cc") == "CC" + assert q3._format_response("") == "" + assert q3._format_response("hello") == "Hello"