Metadata-Version: 2.3
Name: pmt-tools
Version: 0.2.3
Summary: Helpers for Pyodide-MkDocs-Theme selenium testing
License: GPL-3.0-or-later
Author: Frédéric Zinelli
Author-email: frederic.zinelli@gmail.com
Requires-Python: >=3.9, <4.0
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Dist: selenium (>=4.36)
Requires-Dist: selenium-query (>=0.2)
Description-Content-Type: text/markdown

# PMT Tools

`pmt_tools` provides test-oriented helpers for interacting with the interactive components generated by PMT/MkDocs pages.

The package is built around a simple idea:

```
page
 ├── runners (automatically searched/defined)
 |    ├── IDEs
 |    ├── terminals
 |    ├── Python buttons
 |    └── auto-run blocks
 ├── QCMs (to define with `Qcm.from_(page)`)
 └── Tabbed contents (to define with `TabbedContent.from_(page)`)
```

The package turns these DOM components into Python objects that can be queried, interacted with, and validated through `selenium-query`.

The main objects exposed by the package are:

* `BasePmtPage`: the page-level entry point;
* `Ide`: interaction with PMT IDEs;
* `Terminal`: interaction with terminals;
* `PyBtn`: interaction with Python buttons;
* `Runner`: the common abstraction for executable page components;
* `AutoRun`: interaction with `run` macros elements;
* `Qcm`: interaction with QCM components;
* `TabbedContent`: interaction with MkDocs-Material tabbed contents.

---

## `BasePmtPage`

`BasePmtPage` extends `selenium_query.BaseSeleniumPage` with PMT-specific page loading and runner discovery.

A page class should inherit from it and define its URL, following the same requirements as `selenium_query.BaseSeleniumPage`. See the [package documentation](https://gitlab.com/frederic-zinelli/selenium-query) if needed.

```python
from pmt_tools import BasePmtPage

class MyPage(BasePmtPage):
    URL = "my-page"
```

The page readiness condition is PMT-specific. A page is considered ready when the relevant subscription flags have completed:

- All subscriptions have been done
- MCQs have been updated (if needed)
- Mathjax has been executed (if needed)

Once the page data is gathered, `BasePmtPage` discovers the runners present on the page and store them in two class level objects:

```python
MyPage.runners: List[Runner]
MyPage.runners_by_class: Dict[str, List[Runner]]
```

_IMPORTANT: Runners objects should never be instantiated manually by the user. Systematically use the `BasePmt.base_gather_data` logistic to make everything automatic._


The page-level abstraction is therefore:

```
BasePmtPage
    ↓
load PMT page
    ↓
wait for PMT initialization
    ↓
discover page runners
    ↓
wrap each runner in its specialized Python object
```

---

## `IdeConfig`, `CorrRemProfile` and `CheckBtnColor`

These are configuration/value helpers used by `Ide`.

### `IdeConfig`

`IdeConfig` describes the configuration of an IDE:

```python
@dataclass
class IdeConfig:
    count: Union[float,int,None]
    has_check_btn: bool
    has_corr_and_reveal_btns: bool
    revealed: bool = False

    @property
    def has_counter(self):
        return self.count is not None
```

| Attribute | Description |
|---|---|
| `count` | Number of remaining attempts, or `inf` or `None` if no counter is present. |
| `has_check_btn` | Whether the IDE has a check button. |
| `has_corr_and_reveal_btns` | Whether correction and reveal buttons are available. |
| `is_revealed` | Whether the solution has been revealed. |
| `has_counter` | Whether the IDE has a counter. |





---





## `Ide`

`Ide` is the complete IDE abstraction.

It combines the behavior defined by the IDE-related classes in `ide.py`:

```
Ide
├── common IDE behavior
├── code editor behavior
├── correction / remarks / counter behavior
├── split-screen / full-screen behavior
├── history behavior
├── `Terminal` behaviors
├── `Runner` behaviors
└── button
      ├── comment
      ├── two_cols
      ├── full_screen
      ├── play
      ├── check
      ├── download
      ├── upload
      ├── restart
      ├── save
      ├── zip
      ├── corr
      ├── reveal
      └── Terminal buttons...
```

A typical IDE is obtained automatically from a `BasePmtPage`:

```python
ides = cls.runners_by_class["py_mk_ide"]
```

The object then provides a high-level API for interacting with the IDE. See the `Terminal` class for interactions with the terminal of an IDE.


### Reading and writing the editor content

| Signature                                    | Description                                     |
| -------------------------------------------- | ----------------------------------------------- |
| `ide.get_editor_code()`                      | Gets the current code in the editor.            |
| `ide.set_editor_code(code)`                  | Replaces the editor's code.                     |
| `ide.clear_editor()`                         | Clears the editor.                              |
| `ide.check_code_editor(code)`                | Checks the editor's code exactly.               |
| `ide.check_code_editor(code, contains=True)` | Checks that the editor contains the given code. |
| `ide.clean_then_type_in_editor(code)`        | Clears the editor, then types the given code.   |
| `ide.editor_shortcut(shortcut)`              | Sends a keyboard shortcut to the editor.        |


### Validations

| Signature                                   | Description                                                                       |
| ------------------------------------------- | --------------------------------------------------------------------------------- |
| `ide.validate_and_check_btn_becomes(color:CheckBtnColor)` | Clicks the validation button and checks that it becomes the expected color. |
| `ide.check_validation_btn_color(color:CheckBtnColor)`     | Only checks the current color of the validation button.             |
| `ide.has_orange_box(expected=True)`         | Checks whether the IDE validation button displays the orange “modified code” box. |
| `ide.is_dirty(expected=True)`               | Checks whether the IDE is in a dirty state (internally).                          |
| `ide.check_count(count)`                    | Checks the number of remaining attempts. `count` can be an integer, `inf` for unlimited attempts, or `None` for no counter. |
| `ide.open_corr_rem()`                      | Opens or closes the correction/remarks section and returns its `<details>` as a Getter. |
| `ide.check_corr_rem_config(profile:CorrRemProfile, finally_close=False)` | Checks the correction/remarks configuration and optionally closes the section afterward. |

#### `CorrRemProfile`

`CorrRemProfile` describes what the solution area should contain:

| Value                     | Meaning                        |
| ------------------------- | ------------------------------ |
| `CorrRemProfile.hidden`   | Hidden                         |
| `CorrRemProfile.none`     | No solution/correction content |
| `CorrRemProfile.corr`     | Solution                       |
| `CorrRemProfile.rem`      | Remarks                        |
| `CorrRemProfile.corr_rem` | Solution + remarks             |

They are typically used with the `check_corr_rem_config(...)` method.

#### `CheckBtnColor`

`CheckBtnColor` contains the expected visual state of the validation button:

| Value                   | Color   |
| ----------------------- | ------- |
| `CheckBtnColor.success` | `green` |
| `CheckBtnColor.failure` | `red`   |
| `CheckBtnColor.default` | `none`  |
| `CheckBtnColor.teacher` | `blue`  |


### Validations history

| Signature                              | Description                                                             |
| -------------------------------------- | ----------------------------------------------------------------------- |
| `ide.get_history() -> Getter`          | Opens the validation history and returns its items.                     |
| `ide.check_history_items_count(count)` | Checks the number of items in the validation history.                   |
| `ide.check_history_item(item:Getter=None, teacher=False, color=None)` | Checks a history item entry.             |
| `ide.remove_history()`                 | Removes the history box from the UI                                     |
| `ide.cleanup_history(keep)`            | Cleans the history while keeping the specified number of first entries. |

`check_history_item` checks the text of the item is a date in the expected format (date + time / WARNING: this is valid for Capytale integration, not with PMT)

* If `item` is None, all items of the history are tested.

* If `color:CheckBtnColor` is given, the tested item(s) should have this color.

* `teacher=True` is a shortcut to test appropriately for teacher's entries (Capytale, review mode).

### Full-screen and split-screen modes

| Signature                                     | Description                                                         |
| --------------------------------------------- | ------------------------------------------------------------------- |
| `ide.is_full_screen(expected)`                | Checks whether the IDE is in full-screen mode.                      |
| `ide.check_btn_full_screen(enter=True)`       | Checks the full-screen button behavior.                             |
| `ide.check_shortcut_full_screen(enter=True)`  | Checks the full-screen keyboard shortcut behavior.                  |
| `ide.is_split_screen(expected)`               | Checks whether the IDE is in split-screen mode.                     |
| `ide.check_btn_split_screen(enter=True)`      | Checks the split-screen button behavior.                            |
| `ide.check_shortcut_split_screen(enter=True)` | Checks the split-screen keyboard shortcut behavior.                 |
| `ide.check_placeholder_is_in_place()`         | Checks the placeholder position after entering split-screen mode.   |
| `ide.check_is_back_in_place()`                | Checks that the IDE is back in its original position.               |
| `ide.check_is_split_full_height()`            | Checks that the split-screen layout uses the full available height. |


### Various helpers

| Signature                                 | Description                                                                 |
| ----------------------------------------- | --------------------------------------------------------------------------- |
| `ide.check_has_src_hash_msg(has_it=True)` | Checks whether the IDE displays a source-hash message in the terminal.      |
| `ide.zip_upload()`                        | Triggers the IDE's ZIP upload mechanism.                                    |
| `ide.has_priority()`                      | Checks whether the IDE has priority when several IDEs are grouped together. |
| `ide.reset()`                             | Resets the underlying IDE through its JavaScript API (same as the restart button, without the confirmation). |
| `ide.set_python_global_N(value)`          | Sets the Python global `N` through Pyodide (see PMT `pylibs` module).       |





---





## `Terminal`

`Terminal` is the complete terminal abstraction.

It combines:

```
Terminal
├── common terminal behavior
├── text selection behavior
├── `Runner` behaviors
└── button
      ├── cut_term
      └── stdout_wraps
```

A terminal is discovered automatically from a `BasePmtPage`. They can be extracted from `BasePmtPage.runners_by_class` using the html class names `term_solo` for isolated terminals or `py_mk_terminal` for terminals embedded within IDE elements.


| Signature                                               | Description                                              |
| ------------------------------------------------------- | -------------------------------------------------------- |
| `term.get_term_content()`                               | Gets the current terminal content, including the prompt. |
| `term.check_term_content(content, contains=False)`      | Checks the terminal content exactly or, with `contains=True`, checks that it contains the given text. |
| `term.exec_term_cmd(command)`                           | Executes a command directly through the terminal's JavaScript runner (hence, without typing it in the terminal). |
| `term.clean_then_type_in_terminal(command)`             | Clears the terminal, types the command, and executes it. |
| `term.run_command(command)`                             | Types a command into the terminal and presses `Enter`. |
| `term.check_current_cmd(command)`                       | Checks the command currently entered in the terminal without executing it. |
| `term.check_command(command, expected, contains=False)` | Executes a command and checks its resulting terminal content; if `command=None`, checks the existing content without running a command. |
| `term.send_keys_terminal(*keys, execute=False)`         | Sends keys to the terminal, optionally executing them. `Enter` or `Return` also triggers execution. |


---

### Selecting terminal text

`Terminal` also provides helpers for testing text selection behaviors.

The prompt can be double-clicked:

```python
terminal.double_click_prompt()
```

---

The cursor can be placed at one of the supported positions:

```python
terminal.set_cursor(0)      # beginning of the line
terminal.set_cursor(1)      # middle of the line
terminal.set_cursor(-1)     # end of the line
```

---

A selection can then be made by providing the characters after which the cursor should be put, both for the beginning and ending points:

```python
terminal.select(
    "output.0.abc",    # Start: first line of output, after "abc"
    "output.3.def",    # End: fourth line of output, after "def"
)
```

An exception is raised if the specified "points" are invalid.

The selection API uses a small dot-separated notation:

```python
"section[.line].characters"
```

| Element      | Description                                            |
| ------------ | ------------------------------------------------------ |
| `section`    | either `output`, `prompt` or `cmd`                     |
| `line`       | The index of the line in the section (can be negative, optional if one line only) |
| `characters` | The string of characters after which (from left)       |

_Examples:_

```
prompt.>>
cmd.-1.abcde
output.5.some text
cmd.abcde
output.-2."Use quotes for dots..."
```

Once the text is selected, it can then be retrieved or its content checked:

```python
selected = terminal.get_term_selected_text()

terminal.check_term_selected_text("expected text")
```





---





## `Runner`

`Runner` is the common abstraction for executable PMT components. It wraps a `selenium_query.Getter`, with relay method to the main/usual `Getter` logistic. They also provide facilitators to handle running JS methods of the corresponding PTM objects, with various context managers.

### Buttons

A Runner (and also any child class) always provide an interface to interact easily with all the buttons it holds in its interface.

These are accessible through `runner.button.xxx` where `xxx` is the button identifier (see the related classes), and provide methods to simplify their use and testing.

| Signature                                        | Description                                                                   |
| ------------------------------------------------ | ----------------------------------------------------------------------------- |
| `button.click(extra_delay=None)`                 | Clicks the button, with automatic waiting logistic and partial Alert support. |
| `button.right_click() -> Button`                 | Applies right click operation on the button.                                  |
| `button.check_tip_text(exp:str, contains=False, floating=False)` | Check tooltips messages.                                      |
| `button.check_capytale_chip(txt_or_int, msg="")` | Checks the Capytale counter displayed on the button, or verifies that it is absent when `txt_or_int` is falsy. |


* `click(...) -> Button|Alert`:

    - Returns the Button itself, or an Alert object if one is expected on click. The user will have to handle the Alert.
    - Can apply `extra_delay` in addition to the default waiting behaviors.

* `check_tip_text(...)`:

    - If `contains=True`, `exp` must be contained in the tooltip text, instead of matching it exactly.
    - Use `floating=True` to check "PMT bare tips". In this case, the Button automatically scroll and move the pointer where appropriate to display the tooltip.

- `check_capytale_chip`:

    To use only when testing CodEx integration in Capytale. `msg` is an optional extra assertion message.


### `runner.run_js(...)`

A `Runner` object is aware of its html element's id and, if the website is built with `pyodide_macros._dev_mode: true`, the `Runner` can automatically access the corresponding JS instance by using `{js_runner}` in the code passed as argument to `run_js`. So, something like the following is possible:

```python
runner.run_js("return {js_runner}.isDirty")
```

See `selenium-query` documentation for more details about how to use the `run_js` method.

### Synchronisation helper

Each runner also provides a basic synchronization context manager, which might help implement various waiting mechanisms. The Runner automatically handles the internal logistic, making sure the JS runner has ended executions before releasing the context.

```python
with runner.wait_executions_done():
    ...
```

IMPORTANT: considering PMT executions, they are considered done just before any `post` or `post_term` section is executed (see [PMT's documentation](https://frederic-zinelli.gitlab.io/pyodide-mkdocs-theme/redactors/IDE-details/#ide-sections)).

### Miscellaneous

* `runner.rename("my-runner")` can ease debugging: a Runner `repr` can use the value passed to `rename(...)` instead of the css path used to find the object in the DOM (see `selenium_query.Getter`).

* `runner.pause(seconds:float)` can be used directly (no need for a terminal `.go`).

* In normal page usage, `Runner` objects are generally created automatically with: `Runner.from_page(MyPage)`, which is called from `BasePmtPage.base_gather_data`. This will discover:

    * IDEs;
    * isolated terminals;
    * Python buttons;
    * auto-run components.





---





## `PyBtn`

They are generally gathered in the page under the `py_mk_py_btn` html class name.

```
PyBtn
├── `Runner` behaviors
└── button
      └── play
```

Aside of the base `Runner` interface, they also provide this specialized helper to check that clicking the button produces the expected alert (for the buttons that do so):

```python
py_btn.check_alert_contains_on_click("Expected alert message")
```





---





## `AutoRun`

They are generally gathered in the page under the `py_mk_auto_run` html class name.

It is a `Runner` specialization without additional public behavior of its own, and is mainly useful because `Runner.from_page(...)` can recognize an auto-run block and wrap it in the appropriate Python object.





---






## `Qcm`

`Qcm` represents a PMT QCM component.
| Signature                                                                | Description                                        |
| ------------------------------------------------------------------------ | -------------------------------------------------  |
| `Qcm.from_page(page) -> List[Qcm]`                                       | Gets all QCMs on a page.                           |
| `qcm.get_questions() -> Getter`                                          | Gets the QCM questions.                            |
| `qcm.get_items(question: Getter) -> Getter`                              | Gets the items of a question.                      |
| `qcm.get_q_i_2D_array(target: str = "") -> List[List[Getter]]`           | Gets questions and items as a 2D array.            |
| `qcm.get_rems() -> Getter`                                               | Gets the correction/remarks elements.              |
| `qcm.select(q_and_i: str)`                                               | Selects items using `question.item` notation.      |
| `qcm.check_selected(selected: str, svg_class: str|Dict[str,str] = None)` | Checks the selected items.                         |
| `qcm.validate()`                                                         | Validates the QCM (as in, "evaluate it").          |
| `qcm.validate_if_not_yet()`                                              | Validates the QCM if necessary.                    |
| `qcm.reset()`                                                            | Resets the QCM.                                    |
| `qcm.check_counter(expected: int)`                                       | Checks the QCM counter/result.                     |
| `qcm.snapshot() -> str`                                                  | Creates a snapshot of the QCM's question/item IDs. |


* `get_q_i_2D_array`: `target` allow to target children elements of the QCM items (as a css selector).

* `get_rems`: The QCM must already be evaluated, otherwise the REMs cannot be found.

* `select`: the string is giving POSITIONS, not indices. Example: "1.1 1.2 2.4" selects items 1 and 2 of question 1 and item 4 of question 2.

* `check_selected` behaves the same way as `select` for the first argument.

    `svg_class` allows to check the behaviors of the svg elements and can be provided in various ways (string, dict of `q.i` strings as keys). See implementation details.

    Examples:

    ```python
    qcm.check_selected("1.2 2.1")

    qcm.check_selected("1.2 2.1", svg_class="my-class")

    qcm.check_selected(
        "1.2 2.1", svg_class={"1.2": "correct","2.1": "missed"}
    )
    ```

* `snapshot`: a QCM snapshot records the IDs of its questions and items. This is useful to test randomness.





## `QcmStructure`

`QcmStructure` is a helper that can be used to ease testing `Qcm` object.

For each question, it records:

```python
from pmt_tools import QcmStructure


structure = QcmStructure(
    items_per_question=[4, 3],
    are_squares=[False, True],
    correct=[2, (1, 3)],
    shuffle_questions=True,
    shuffled_items=[False, True],
    mask=False,
)
```

The values mean:
- `items_per_question`:
    * question 1 has 4 choices
    * question 2 has 3 choices
- `are_squares`:
    * question 1 is single-choice
    * question 2 is multiple-choice
- `correct`:
    * the correct answer for question 1 is item number 2
    * the correct answers for question 2 are items 1 and 3
* `shuffle_questions`: questions may be shuffled;
* `shuffled_items`: items of question 2 may be shuffled.
* `mask`: tells if the mask icon should be present on the QCM or not.

The `correct` values use **question/item numbers**, not zero-based indexes.

The lists describing the questions must all have the same length:

```python
len(items_per_question)
== len(are_squares)
== len(shuffled_items)
```





---





## `TabbedContent`

`TabbedContent` represents a MkDocs tabbed block (MkDocs-Material/PyMdown-Extension `=== "..."` syntaxes).

| Signature                                              | Description                                           |
| ------------------------------------------------------ | ----------------------------------------------------- |
| `TabbedContent.from_page(page) -> List[TabbedContent]` | Gets all tabbed blocks on a page.                     |
| `tabbed.labels -> Getter`                              | Gets the tab labels.                                  |
| `tabbed.contents -> Getter`                            | Gets the tab contents.                                |
| `tabbed.click(index) -> None`                          | Selects a tab and checks that its content is visible. |
| `tabbed.label -> Getter`                               | Gets the currently selected tab's label.              |
| `tabbed.content -> Getter`                             | Gets the currently selected tab's content.            |
| `tabbed.check_visible(index) -> None`                  | Checks that a tab's content is visible.               |

