diff --git a/Mainframe3270/keywords/commands.py b/Mainframe3270/keywords/commands.py index 61ef525..3fcdd7c 100644 --- a/Mainframe3270/keywords/commands.py +++ b/Mainframe3270/keywords/commands.py @@ -5,7 +5,7 @@ from robot.api.deco import keyword from Mainframe3270.librarycomponent import LibraryComponent -from Mainframe3270.utils import coordinate_tuple_to_dict +from Mainframe3270.utils import coordinates_to_dict class CommandKeywords(LibraryComponent): @@ -85,8 +85,8 @@ def send_pf(self, pf: str) -> None: self.mf.exec_command("PF({0})".format(pf).encode("utf-8")) time.sleep(self.wait_time) - @keyword("Get Cursor Position") - def get_cursor_position(self, mode: str = "As Tuple") -> Union[tuple, dict]: + @keyword("Get Current Position") + def get_current_position(self, mode: str = "As Tuple") -> Union[tuple, dict]: """Returns the current cursor position. The coordinates are 1 based. By default, this keyword returns a tuple of integers. However, if you specify the `mode` with the value @@ -97,11 +97,21 @@ def get_cursor_position(self, mode: str = "As Tuple") -> Union[tuple, dict]: | Get Cursor Position | As Dict | # Returns a position like {"xpos": 1, "ypos": 1} | """ - result = self.mf.get_cursor_position() + ypos, xpos = self.mf.get_current_position() if mode.lower() == "as dict": - return coordinate_tuple_to_dict(result) + return coordinates_to_dict(ypos, xpos) elif mode.lower() == "as tuple": - return result + return ypos, xpos else: logger.warn('"mode" should be either "as dict" or "as tuple". Returning the result as tuple') - return result + return ypos, xpos + + @keyword("Move Cursor To") + def move_cursor_to(self, ypos: int, xpos: int): + """Moves the cursor to the specified ypos/xpos position. The coordinates are 1 based. + This keyword raises an error if the specified values exceed the Mainframe screen dimensions. + + Example: + | Move Cursor To | 1 | 5 | + """ + self.mf.move_to(ypos, xpos) diff --git a/Mainframe3270/keywords/read_write.py b/Mainframe3270/keywords/read_write.py index d6e842c..13e72ec 100644 --- a/Mainframe3270/keywords/read_write.py +++ b/Mainframe3270/keywords/read_write.py @@ -5,7 +5,7 @@ from robot.api.deco import keyword from Mainframe3270.librarycomponent import LibraryComponent -from Mainframe3270.utils import coordinate_tuple_to_dict +from Mainframe3270.utils import coordinates_to_dict class ReadWriteKeywords(LibraryComponent): @@ -20,6 +20,13 @@ def read(self, ypos: int, xpos: int, length: int) -> str: """ return self.mf.string_get(ypos, xpos, length) + @keyword("Read From Current Position") + def read_from_current_position(self, length: int): + """Similar to `Read`, however this keyword only takes `length` as an argument + to get a string of length from the current cursor position.""" + ypos, xpos = self.library.get_current_position() + return self.mf.string_get(ypos, xpos, length) + @keyword("Read All Screen") def read_all_screen(self) -> str: """Read the current screen and returns all content in one string. @@ -48,11 +55,12 @@ def get_string_positions(self, string: str, mode: str = "As Tuple", ignore_case: If `ignore_case` is set to `True`, then the search is done case-insensitively. Example: - | ${indices} | Find String | Abc | # Returns something like [(1, 8)] + | ${positions} | Get String Positions | Abc | # Returns something like [(1, 8)] + | ${positions} | Get String Positions | Abc | As Dict | # Returns something like [{"ypos": 1, "xpos": 8}] """ results = self.mf.get_string_positions(string, ignore_case) if mode.lower() == "as dict": - return [coordinate_tuple_to_dict(r) for r in results] + return [coordinates_to_dict(ypos, xpos) for ypos, xpos in results] elif mode.lower() == "as tuple": return results else: diff --git a/Mainframe3270/py3270.py b/Mainframe3270/py3270.py index f71b488..c3a29e8 100644 --- a/Mainframe3270/py3270.py +++ b/Mainframe3270/py3270.py @@ -431,6 +431,7 @@ def move_to(self, ypos, xpos): move the cursor to the given coordinates. Coordinates are 1 based, as listed in the status area of the terminal. """ + self._check_limits(ypos, xpos) # the screen's coordinates are 1 based, but the command is 0 based xpos -= 1 ypos -= 1 @@ -445,7 +446,6 @@ def send_string(self, tosend, ypos=None, xpos=None): terminal. """ if xpos and ypos: - self._check_limits(ypos, xpos) self.move_to(ypos, xpos) # escape double quotes in the data to send tosend = tosend.decode("utf-8").replace('"', '"') @@ -488,9 +488,8 @@ def search_string(self, string, ignore_case=False): def get_string_positions(self, string, ignore_case=False): """Returns a list of tuples of ypos and xpos for the position where the `string` was found, or an empty list if it was not found.""" - screen_content = self.read_all_screen().lower() if ignore_case else self.read_all_screen() - string = string.lower() if ignore_case else string - indices_object = re.finditer(re.escape(string), screen_content) + screen_content = self.read_all_screen() + indices_object = re.finditer(re.escape(string), screen_content, flags=0 if not ignore_case else re.IGNORECASE) indices = [index.start() for index in indices_object] # ypos and xpos should be returned 1-based return [self._get_ypos_and_xpos_from_index(index + 1) for index in indices] @@ -535,9 +534,9 @@ def fill_field(self, ypos, xpos, tosend, length): def save_screen(self, file_path): self.exec_command("PrintText(html,file,{0})".format(file_path).encode("utf-8")) - def get_cursor_position(self): + def get_current_position(self): """Returns the current cursor position as a tuple of 1 indexed integers.""" - command = self.exec_command("Query(Cursor)") + command = self.exec_command(b"Query(Cursor)") if len(command.data) != 1: raise Exception(f'Cursor position returned an unexpected value: "{command.data}"') list_of_strings = command.data[0].decode("utf-8").split(" ") diff --git a/Mainframe3270/utils.py b/Mainframe3270/utils.py index 5879a6d..6325188 100644 --- a/Mainframe3270/utils.py +++ b/Mainframe3270/utils.py @@ -9,9 +9,5 @@ def convert_timeout(time): return timestr_to_secs(time, round_to=None) -def coordinate_tuple_to_dict(coordinate_tuple): - if not isinstance(coordinate_tuple, tuple): - raise TypeError(f"Input must be instance of {tuple}") - if len(coordinate_tuple) != 2: - raise ValueError(f"Length of input must be 2, but was {len(coordinate_tuple)}") - return {"ypos": coordinate_tuple[0], "xpos": coordinate_tuple[1]} +def coordinates_to_dict(ypos: int, xpos: int): + return {"ypos": ypos, "xpos": xpos} diff --git a/atest/mainframe.robot b/atest/mainframe.robot index 64faca7..7c1fa9d 100644 --- a/atest/mainframe.robot +++ b/atest/mainframe.robot @@ -24,6 +24,10 @@ Exception Test Read Run Keyword And Expect Error ${X_AXIS_EXCEEDED_EXPECTED_ERROR} Read 4 81 1 Run Keyword And Expect Error ${Y_AXIS_EXCEEDED_EXPECTED_ERROR} Read 25 48 34 +Exception Test Read From Current Position + Run Keyword And Expect Error ${X_AXIS_EXCEEDED_EXPECTED_ERROR} Read From Current Position + ... 81 + Exception Test Write In Position Run Keyword And Expect Error ${X_AXIS_EXCEEDED_EXPECTED_ERROR} Write In Position ${WRITE_TEXT} 10 81 Run Keyword And Expect Error ${Y_AXIS_EXCEEDED_EXPECTED_ERROR} Write In Position ${WRITE_TEXT} 25 10 @@ -112,6 +116,12 @@ Exception Test Wait Until String Exception Test Wait Until String With Timeout Verify Wait Until String With Timeout Wait Until String ${STRING_NON_EXISTENT} timeout=2 +Exception Test Move Cursor To + Run Keyword And Expect Error ${Y_AXIS_EXCEEDED_EXPECTED_ERROR} + ... Move Cursor To 25 1 + Run Keyword And Expect Error ${X_AXIS_EXCEEDED_EXPECTED_ERROR} + ... Move Cursor To 1 81 + Test Wait Until String Wait Until String ${WELCOME_TITLE} timeout=4 @@ -159,10 +169,20 @@ Test Page Should Not Contain String Page Should Not Contain String ${WELCOME_WRONG_CASE} Page Should Not Contain String ${STRING_NON_EXISTENT} ignore_case=${True} +Test Move Cursor To + Move Cursor To 5 5 + ${position} Get Current Position + Should Be Equal ${{ (5, 5) }} ${position} + Test Read ${read_text} Read 1 10 48 Should Be Equal As Strings ${WELCOME_TITLE} ${read_text} +Test Read From Current Position + Move Cursor To 4 48 + ${read_text} Read From Current Position 12 + Should Be Equal As Strings Display name ${read_text} + Test Read All Screen ${screen_content} Read All Screen Should Contain ${screen_content} i @@ -170,6 +190,7 @@ Test Read All Screen Should Not Contain ${screen_content} xyz Test Write Bare + Move Cursor To 5 25 Write Bare ${WRITE_TEXT} ${read_text} Read 5 25 4 Take Screenshot @@ -225,16 +246,16 @@ Test Send PF Send PF 1 Page Should Contain String Function key not allowed. -Test Get Cursor Position +Test Get Current Position Move Next Field - ${position} Get Cursor Position + ${position} Get Current Position Should Be Equal ${{ (6, 25) }} ${position} - ${position_as_dict} Get Cursor Position As Dict + ${position_as_dict} Get Current Position As Dict Should Be Equal ${{ {"xpos": 25, "ypos": 6} }} ${position_as_dict} Write Bare AB - ${position} Get Cursor Position + ${position} Get Current Position Should Be Equal ${{ (6, 27) }} ${position} - ${position_as_dict} Get Cursor Position As Dict + ${position_as_dict} Get Current Position As Dict Should Be Equal ${{ {"xpos": 27, "ypos": 6} }} ${position_as_dict} Test Get String Positions diff --git a/utest/Mainframe3270/keywords/test_commands.py b/utest/Mainframe3270/keywords/test_commands.py index 09b974e..510e14d 100644 --- a/utest/Mainframe3270/keywords/test_commands.py +++ b/utest/Mainframe3270/keywords/test_commands.py @@ -98,22 +98,48 @@ def test_send_pf(mocker: MockerFixture, under_test: CommandKeywords): Emulator.exec_command.assert_called_with("PF(5)".encode("utf-8")) -def test_get_current_cursor_position(mocker: MockerFixture, under_test: CommandKeywords): - mocker.patch("Mainframe3270.py3270.Emulator.get_cursor_position", return_value=(6, 6)) +def test_get_current_position(mocker: MockerFixture, under_test: CommandKeywords): + mocker.patch("Mainframe3270.py3270.Emulator.get_current_position", return_value=(6, 6)) - assert under_test.get_cursor_position() == (6, 6) + assert under_test.get_current_position() == (6, 6) -def test_get_current_cursor_position_as_dict(mocker: MockerFixture, under_test: CommandKeywords): - mocker.patch("Mainframe3270.py3270.Emulator.get_cursor_position", return_value=(6, 6)) +def test_get_current_position_as_dict(mocker: MockerFixture, under_test: CommandKeywords): + mocker.patch("Mainframe3270.py3270.Emulator.get_current_position", return_value=(6, 6)) - assert under_test.get_cursor_position("as DiCt") == {"xpos": 6, "ypos": 6} + assert under_test.get_current_position("as DiCt") == {"xpos": 6, "ypos": 6} -def test_get_current_cursor_position_invalid_mode(mocker: MockerFixture, under_test: CommandKeywords): - mocker.patch("Mainframe3270.py3270.Emulator.get_cursor_position", return_value=(6, 6)) +def test_get_current_position_invalid_mode(mocker: MockerFixture, under_test: CommandKeywords): + mocker.patch("Mainframe3270.py3270.Emulator.get_current_position", return_value=(6, 6)) mocker.patch("robot.api.logger.warn") - assert under_test.get_cursor_position("this is wrong") == (6, 6) + assert under_test.get_current_position("this is wrong") == (6, 6) logger.warn.assert_called_with('"mode" should be either "as dict" or "as tuple". Returning the result as tuple') + + +def test_move_cursor_to(mocker: MockerFixture, under_test: CommandKeywords): + mocker.patch("Mainframe3270.py3270.Emulator.move_to") + + under_test.move_cursor_to(5, 5) + + Emulator.move_to.assert_called_with(5, 5) + + +@pytest.mark.parametrize( + ("ypos", "xpos", "expected_error"), + [ + (25, 1, "You have exceeded the y-axis limit of the mainframe screen"), + (1, 81, "You have exceeded the x-axis limit of the mainframe screen"), + ], +) +def test_move_cursor_to_exceeds_ypos( + mocker: MockerFixture, under_test: CommandKeywords, ypos: int, xpos: int, expected_error: str +): + mocker.patch("Mainframe3270.py3270.Emulator.exec_command") + + with pytest.raises(Exception, match=expected_error): + under_test.move_cursor_to(ypos, xpos) + + Emulator.exec_command.assert_not_called() diff --git a/utest/Mainframe3270/test_utils.py b/utest/Mainframe3270/test_utils.py index 4d34363..6338985 100644 --- a/utest/Mainframe3270/test_utils.py +++ b/utest/Mainframe3270/test_utils.py @@ -1,8 +1,6 @@ from datetime import timedelta -import pytest - -from Mainframe3270.utils import convert_timeout, coordinate_tuple_to_dict +from Mainframe3270.utils import convert_timeout, coordinates_to_dict def test_convert_timeout_with_timedelta(): @@ -16,15 +14,5 @@ def test_convert_timeout_with_timestring(): assert timeout == 60.0 -def test_coordinate_tuple_to_dict(): - assert coordinate_tuple_to_dict((1, 5)) == {"ypos": 1, "xpos": 5} - - -def test_coordinate_tuple_to_dict_with_wrong_input(): - with pytest.raises(TypeError, match="Input must be instance of "): - coordinate_tuple_to_dict(1) - - -def test_coordinate_tuple_to_dict_with_invalid_length(): - with pytest.raises(ValueError, match="Length of input must be 2, but was 3"): - coordinate_tuple_to_dict((2, 4, 6)) +def test_coordinates_to_dict(): + assert coordinates_to_dict(1, 5) == {"ypos": 1, "xpos": 5} diff --git a/utest/py3270/test_emulator.py b/utest/py3270/test_emulator.py index 33e2db6..a8bb067 100644 --- a/utest/py3270/test_emulator.py +++ b/utest/py3270/test_emulator.py @@ -341,7 +341,7 @@ def test_get_current_cursor_position(mocker: MockerFixture): under_test = Emulator() # result is 1 based, that is why we expect (6, 6) - assert under_test.get_cursor_position() == (6, 6) + assert under_test.get_current_position() == (6, 6) def test_get_current_cursor_position_returns_unexpected_value(mocker: MockerFixture): @@ -352,7 +352,7 @@ def test_get_current_cursor_position_returns_unexpected_value(mocker: MockerFixt under_test = Emulator() with pytest.raises(Exception, match="Cursor position returned an unexpected value"): - under_test.get_cursor_position() + under_test.get_current_position() @pytest.mark.usefixtures("mock_windows") @@ -366,7 +366,7 @@ def test_get_current_cursor_position_returns_unexpected_value(mocker: MockerFixt ("5", 131, [(1, 132)]), ], ) -def test_find_string(mocker: MockerFixture, model, index, expected): +def test_get_string_positions(mocker: MockerFixture, model, index, expected): under_test = Emulator(model=model) mocker.patch( "Mainframe3270.py3270.Emulator.read_all_screen", return_value=_mock_return_all_screen(under_test, "abc", index) @@ -375,7 +375,7 @@ def test_find_string(mocker: MockerFixture, model, index, expected): assert under_test.get_string_positions("abc") == expected -def test_find_string_ignore_case(mocker: MockerFixture): +def test_get_string_positions_ignore_case(mocker: MockerFixture): under_test = Emulator() mocker.patch( "Mainframe3270.py3270.Emulator.read_all_screen", return_value=_mock_return_all_screen(under_test, "ABC", 5) @@ -384,7 +384,7 @@ def test_find_string_ignore_case(mocker: MockerFixture): assert under_test.get_string_positions("aBc", True) == [(1, 6)] -def test_find_string_without_result(mocker: MockerFixture): +def test_get_string_positions_without_result(mocker: MockerFixture): under_test = Emulator() mocker.patch( "Mainframe3270.py3270.Emulator.read_all_screen", return_value=_mock_return_all_screen(under_test, "abc", 1)