Repository: aeye-lab/pymovements
Pull request #799 (2024-09-12 07:34:15)

add stimulus to plotting

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/aeye-lab/pymovements/pull/799?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) Report
Attention: Patch coverage is `66.66667%` with `3 lines` in your changes missing coverage. Please review.
> Project coverage is 99.83%. Comparing base [(`5868d11`)](https://app.codecov.io/gh/aeye-lab/pymovements/commit/5868d11f136930cb04212075daee3b942309f6fa?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) to head [(`7ffd449`)](https://app.codecov.io/gh/aeye-lab/pymovements/commit/7ffd4492cb0c6840cb110c0ed80342235bfc4525?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).

| [Files with missing lines](https://app.codecov.io/gh/aeye-lab/pymovements/pull/799?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) | Patch % | Lines |
|---|---|---|
| [src/pymovements/plotting/heatmap.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/799?src=pr&el=tree&filepath=src%2Fpymovements%2Fplotting%2Fheatmap.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL3Bsb3R0aW5nL2hlYXRtYXAucHk=) | 57.14% | [2 Missing and 1 partial :warning: ](https://app.codecov.io/gh/aeye-lab/pymovements/pull/799?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) |

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##              main     #799      +/-   ##
===========================================
- Coverage   100.00%   99.83%   -0.17%     
===========================================
  Files           66       66              
  Lines         3055     3061       +6     
  Branches       871      809      -62     
===========================================
+ Hits          3055     3056       +1     
- Misses           0        3       +3     
- Partials         0        2       +2     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/aeye-lab/pymovements/pull/799?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).

SiQube: scanpath plotting does not work yet, 

you can use the following dummy script to run it:

```python
import pymovements as pm


dataset = pm.Dataset('ToyDataset', path='data/ToyDataset')
dataset.download()
dataset.load()
dataset.pix2deg()
dataset.pos2vel()
dataset.detect('ivt')
print(dataset.gaze[5].frame)
dataset.compute_properties(("location", {'position_column':"pixel"}))

print(dataset.events[5].frame)

pm.plotting.scanpathplot(
    events=dataset.events[5],
    position_column='location',
    title='Scanpathplot', 
    add_stimulus=True,
    path_to_image_stimulus='tests/files/pexels-zoorg-1000498.jpg',
    stimulus_origin='upper',
    alpha=.5,
)
```

to test scanpath plotting it might be better to use our own image instead of pexels

to run the entire pipeline, checkout this branch `add-stimulus-to-plotting-as-background` and then run the script below:


```console
pip install -e .
python plot_t.py
```

Patch:
diff --git a/src/pymovements/plotting/heatmap.py b/src/pymovements/plotting/heatmap.py
index d1038e3..2dca956 100644
--- a/src/pymovements/plotting/heatmap.py
+++ b/src/pymovements/plotting/heatmap.py
@@ -20,11 +20,14 @@
 """Heatmap module."""
 from __future__ import annotations
 
+from pathlib import Path
+
 import matplotlib.pyplot as plt
 import numpy as np
 from matplotlib import colors
 
 from pymovements.gaze import GazeDataFrame
+from pymovements.utils.plotting import draw_image_stimulus
 
 
 def heatmap(
@@ -42,6 +45,10 @@ def heatmap(
         ylabel: str | None = None,
         show: bool = True,
         savepath: str | None = None,
+        add_stimulus: bool = False,
+        path_to_image_stimulus: str | Path | None = None,
+        stimulus_origin: str = 'upper',
+        alpha: float = 1.,
 ) -> plt.Figure:
     """Plot a heatmap of gaze data.
 
@@ -81,6 +88,14 @@ def heatmap(
         Whether to show the plot. (default: True)
     savepath: str | None
         If provided, the figure will be saved to this path. (default: None)
+    add_stimulus: bool
+        Define whether stimulus should be included. (default: False)
+    path_to_image_stimulus: str | Path | None
+        Path to image stimulus. (default: None)
+    stimulus_origin: str
+        Origin of stimulus. (default: 'upper')
+    alpha: float
+        Alpha value of heatmap. (default: 1.)
 
     Raises
     ------
@@ -138,8 +153,19 @@ def heatmap(
     # Convert heatmap values from sample count to seconds
     heatmap_value /= gaze.experiment.sampling_rate
 
+    extent = [x_edges[0], x_edges[-1], y_edges[0], y_edges[-1]]
+
     # Create the plot
-    fig, ax = plt.subplots(figsize=figsize)
+    if add_stimulus:
+        assert path_to_image_stimulus
+        fig, ax = draw_image_stimulus(
+            path_to_image_stimulus,
+            origin=stimulus_origin,
+            figsize=figsize,
+            extent=extent,
+        )
+    else:
+        fig, ax = plt.subplots(figsize=figsize)
 
     # Plot the heatmap
     heatmap_plot = ax.imshow(
@@ -147,7 +173,8 @@ def heatmap(
         cmap=cmap,
         origin=origin,
         interpolation=interpolation,
-        extent=[x_edges[0], x_edges[-1], y_edges[0], y_edges[-1]],
+        extent=extent,
+        alpha=alpha,
     )
 
     # Set the plot title and axis labels
diff --git a/src/pymovements/plotting/scanpathplot.py b/src/pymovements/plotting/scanpathplot.py
index 56ddf97..cbbaec3 100644
--- a/src/pymovements/plotting/scanpathplot.py
+++ b/src/pymovements/plotting/scanpathplot.py
@@ -27,11 +27,11 @@ import matplotlib.colors
 import matplotlib.pyplot as plt
 import matplotlib.scale
 import numpy as np
-from matplotlib.collections import LineCollection
 from matplotlib.patches import Circle
 
 from pymovements.events import EventDataFrame
 from pymovements.gaze import GazeDataFrame
+from pymovements.utils.plotting import draw_line_data
 from pymovements.utils.plotting import LinearSegmentedColormapType
 from pymovements.utils.plotting import setup_matplotlib
 
@@ -63,6 +63,9 @@ def scanpathplot(
         alpha: float = 0.5,
         add_traceplot: bool = False,
         gaze_position_column: str = 'pixel',
+        add_stimulus: bool = False,
+        path_to_image_stimulus: str | None = None,
+        stimulus_origin: str = 'upper',
 ) -> None:
     """Plot scanpath from positional data.
 
@@ -107,6 +110,12 @@ def scanpathplot(
         Boolean value indicating whether to add traceplot to the scanpath plot. (default: False)
     gaze_position_column: str
         Position column in the gaze dataframe. (default: 'pixel')
+    add_stimulus: bool
+        Boolean value indicationg whether to plot the scanpath on the stimuls. (default: False)
+    path_to_image_stimulus: str | None
+        Path of the stimulus to be shown. (default: None)
+    stimulus_origin: str
+        Origin of stimuls to plot on the stimulus. (default: 'upper')
 
     Raises
     ------
@@ -114,17 +123,24 @@ def scanpathplot(
         If length of x and y coordinates do not match or if ``cmap_norm`` is unknown.
 
     """
+    # pylint: disable=duplicate-code
     x_signal = events.frame[position_column].list.get(0)
     y_signal = events.frame[position_column].list.get(1)
 
     fig, ax, cmap, cmap_norm, cval, show_cbar = setup_matplotlib(
         x_signal,
+        y_signal,
         figsize,
         cmap,
         cmap_norm,
         cmap_segmentdata,
         cval,
         show_cbar,
+        add_stimulus,
+        path_to_image_stimulus,
+        stimulus_origin,
+        padding,
+        pad_factor,
     )
 
     for row in events.frame.iter_rows(named=True):
@@ -142,30 +158,20 @@ def scanpathplot(
         assert gaze
         gaze_x_signal = gaze.frame[gaze_position_column].list.get(0)
         gaze_y_signal = gaze.frame[gaze_position_column].list.get(1)
-        points = np.array([gaze_x_signal, gaze_y_signal]).T.reshape((-1, 1, 2))
-        segments = np.concatenate([points[:-1], points[1:]], axis=1)
-
-        # Create a continuous norm to map from data points to colors
-        line_collection = LineCollection(segments, cmap=cmap, norm=cmap_norm)
-        # Set the values used for colormapping
-        line_collection.set_array(cval)
-        line_collection.set_linewidth(2)
-        line = ax.add_collection(line_collection)
+        line = draw_line_data(
+            gaze_x_signal,
+            gaze_y_signal,
+            ax,
+            cmap,
+            cmap_norm,
+            cval,
+        )
+
         if show_cbar:
             # sm = matplotlib.cm.ScalarMappable(cmap=cmap, norm=cmap_norm)
             # sm.set_array(cval)
             fig.colorbar(line, label=cbar_label, ax=ax)
 
-    if padding is None:
-        x_pad = (np.nanmax(x_signal) - np.nanmin(x_signal)) * pad_factor
-        y_pad = (np.nanmax(y_signal) - np.nanmin(y_signal)) * pad_factor
-    else:
-        x_pad = padding
-        y_pad = padding
-
-    ax.set_xlim(np.nanmin(x_signal) - x_pad, np.nanmax(x_signal) + x_pad)
-    ax.set_ylim(np.nanmin(y_signal) - y_pad, np.nanmax(y_signal) + y_pad)
-
     if title:
         ax.set_title(title)
 
diff --git a/src/pymovements/plotting/traceplot.py b/src/pymovements/plotting/traceplot.py
index 967cba6..49af4a1 100644
--- a/src/pymovements/plotting/traceplot.py
+++ b/src/pymovements/plotting/traceplot.py
@@ -26,9 +26,9 @@ import matplotlib.colors
 import matplotlib.pyplot as plt
 import matplotlib.scale
 import numpy as np
-from matplotlib.collections import LineCollection
 
 from pymovements.gaze.gaze_dataframe import GazeDataFrame
+from pymovements.utils.plotting import draw_line_data
 from pymovements.utils.plotting import LinearSegmentedColormapType
 from pymovements.utils.plotting import setup_matplotlib
 
@@ -55,6 +55,9 @@ def traceplot(
         title: str | None = None,
         savepath: str | None = None,
         show: bool = True,
+        add_stimulus: bool = False,
+        path_to_image_stimulus: str | None = None,
+        stimulus_origin: str = 'upper',
 ) -> None:
     """Plot eye gaze trace from positional data.
 
@@ -89,6 +92,12 @@ def traceplot(
         If given, figure will be saved to this path. (default: None)
     show: bool
         If True, figure will be shown. (default: True)
+    add_stimulus: bool
+        Define whether stimulus should be included. (default: False)
+    path_to_image_stimulus: str | None
+        Path to image stimulus. (default: None)
+    stimulus_origin: str
+        Origin of stimulus. (default: 'upper')
 
     Raises
     ------
@@ -96,42 +105,34 @@ def traceplot(
         If length of x and y coordinates do not match or if ``cmap_norm`` is unknown.
 
     """
+    # pylint: disable=duplicate-code
     x_signal = gaze.frame[position_column].list.get(0)
     y_signal = gaze.frame[position_column].list.get(1)
 
     fig, ax, cmap, cmap_norm, cval, show_cbar = setup_matplotlib(
         x_signal,
+        y_signal,
         figsize,
         cmap,
         cmap_norm,
         cmap_segmentdata,
         cval,
         show_cbar,
+        add_stimulus,
+        path_to_image_stimulus,
+        stimulus_origin,
+        padding,
+        pad_factor,
     )
 
-    # Create a set of line segments so that we can color them individually
-    # This creates the points as a N x 1 x 2 array so that we can stack points
-    # together easily to get the segments. The segments array for line collection
-    # needs to be (numlines) x (points per line) x 2 (for x and y)
-    points = np.array([x_signal, y_signal]).T.reshape((-1, 1, 2))
-    segments = np.concatenate([points[:-1], points[1:]], axis=1)
-
-    # Create a continuous norm to map from data points to colors
-    line_collection = LineCollection(segments, cmap=cmap, norm=cmap_norm)
-    # Set the values used for colormapping
-    line_collection.set_array(cval)
-    line_collection.set_linewidth(2)
-    line = ax.add_collection(line_collection)
-
-    if padding is None:
-        x_pad = (np.nanmax(x_signal) - np.nanmin(x_signal)) * pad_factor
-        y_pad = (np.nanmax(y_signal) - np.nanmin(y_signal)) * pad_factor
-    else:
-        x_pad = padding
-        y_pad = padding
-
-    ax.set_xlim(np.nanmin(x_signal) - x_pad, np.nanmax(x_signal) + x_pad)
-    ax.set_ylim(np.nanmin(y_signal) - y_pad, np.nanmax(y_signal) + y_pad)
+    line = draw_line_data(
+        x_signal,
+        y_signal,
+        ax,
+        cmap,
+        cmap_norm,
+        cval,
+    )
 
     if show_cbar:
         # sm = matplotlib.cm.ScalarMappable(cmap=cmap, norm=cmap_norm)
diff --git a/src/pymovements/utils/plotting.py b/src/pymovements/utils/plotting.py
index c1e4abd..2025b73 100644
--- a/src/pymovements/utils/plotting.py
+++ b/src/pymovements/utils/plotting.py
@@ -28,6 +28,7 @@ from typing import Union
 import matplotlib.pyplot
 import numpy as np
 import PIL.Image
+from matplotlib.collections import LineCollection
 from typing_extensions import TypeAlias
 
 LinearSegmentedColormapType: TypeAlias = dict[
@@ -93,19 +94,27 @@ MatplotlibSetupType: TypeAlias = tuple[
 
 
 def setup_matplotlib(
-        signal: np.ndarray,
+        x_signal: np.ndarray,
+        y_signal: np.ndarray,
         figsize: tuple[int, int],
         cmap: matplotlib.colors.Colormap | None = None,
         cmap_norm: matplotlib.colors.Normalize | str | None = None,
         cmap_segmentdata: LinearSegmentedColormapType | None = None,
         cval: np.ndarray | None = None,
         show_cbar: bool = False,
+        add_stimulus: bool = False,
+        path_to_image_stimulus: str | None = None,
+        stimulus_origin: str = 'upper',
+        padding: float | None = None,
+        pad_factor: float | None = 0.05,
 ) -> MatplotlibSetupType:
     """Configure cmap.
 
     Parameters
     ----------
-    signal: np.ndarray
+    x_signal: np.ndarray
+        Time-step array.
+    y_signal: np.ndarray
         Time-step array.
     figsize: tuple[int, int]
         Figure size.
@@ -119,17 +128,42 @@ def setup_matplotlib(
         Line color values. (default: None)
     show_cbar: bool
         Shows color bar if True. (default: False)
+    add_stimulus: bool
+        Boolean value indicationg whether to plot the scanpath on the stimuls. (default: False)
+    path_to_image_stimulus: str | None
+        Path of the stimulus to be shown. (default: None)
+    stimulus_origin: str
+        Origin of stimuls to plot on the stimulus. (default: 'upper')
+    padding: float | None
+        Absolute padding value. If None it is inferred from pad_factor and limits. (default: None)
+    pad_factor: float | None
+        Relative padding factor to construct padding from value. (default: 0.05)
 
     Returns
     -------
     MatplotlibSetupType
         Configures fig, ax, cmap, cmap_norm, cmap_segmentdata, cval, and show_cbar.
     """
-    n = len(signal)
+    n = len(x_signal)
 
     fig = matplotlib.pyplot.figure(figsize=figsize)
     ax = fig.gca()
 
+    if add_stimulus:
+        img = PIL.Image.open(path_to_image_stimulus)
+        ax.imshow(img, origin=stimulus_origin, extent=None)
+    else:
+        if padding is None:
+            x_pad = (np.nanmax(x_signal) - np.nanmin(x_signal)) * pad_factor
+            y_pad = (np.nanmax(y_signal) - np.nanmin(y_signal)) * pad_factor
+        else:
+            x_pad = padding
+            y_pad = padding
+
+        ax.set_xlim(np.nanmin(x_signal) - x_pad, np.nanmax(x_signal) + x_pad)
+        ax.set_ylim(np.nanmin(y_signal) - y_pad, np.nanmax(y_signal) + y_pad)
+        ax.invert_yaxis()
+
     if cval is None:
         cval = np.zeros(n)
         show_cbar = False
@@ -186,6 +220,10 @@ def draw_image_stimulus(
         image_stimulus: str | Path,
         origin: str = 'upper',
         show: bool = False,
+        figsize: tuple[float, float] = (15, 10),
+        extent: list[float] | None = None,
+        fig: matplotlib.pyplot.figure | None = None,
+        ax: matplotlib.pyplot.Axes | None = None,
 ) -> tuple[matplotlib.pyplot.figure, matplotlib.pyplot.Axes]:
     """Draw stimulus.
 
@@ -196,7 +234,15 @@ def draw_image_stimulus(
     origin: str
         Origin how to draw the image.
     show: bool
-        Boolean whether to show the image.
+        Boolean whether to show the image. (default: False)
+    figsize: tuple[float, float]
+        Size of the figure. (default: (15, 10))
+    extent: list[float] | None
+        Extent of image. (default: None)
+    fig: matplotlib.pyplot.figure | None
+        Matplotlib canvas. (default: None)
+    ax: matplotlib.pyplot.Axes | None
+        Matplotlib axes. (default: None)
 
     Returns
     -------
@@ -204,8 +250,56 @@ def draw_image_stimulus(
     ax: matplotlib.pyplot.Axes
     """
     img = PIL.Image.open(image_stimulus)
-    fig, ax = matplotlib.pyplot.subplots()
-    ax.imshow(img, origin=origin)
+    if not fig:
+        fig, ax = matplotlib.pyplot.subplots(figsize=figsize)
+    assert ax
+    ax.imshow(img, origin=origin, extent=extent)
     if show:
         matplotlib.pyplot.show()
     return fig, ax
+
+
+def draw_line_data(
+        x_signal: np.ndarray,
+        y_signal: np.ndarray,
+        ax: matplotlib.pyplot.Axes,
+        cmap: matplotlib.colors.Colormap | None = None,
+        cmap_norm: matplotlib.colors.Normalize | str | None = None,
+        cval: np.ndarray | None = None,
+) -> matplotlib.pyplot.Axes:
+    """Draw line data.
+
+    Parameters
+    ----------
+    x_signal: np.ndarray
+        Data to be plotted.
+    y_signal: np.ndarray
+        Data to be plotted.
+    ax: matplotlib.pyplot.Axes
+        Matplotlib axes.
+    cmap: matplotlib.colors.Colormap | None
+        Color map for line color values. (default: None)
+    cmap_norm: matplotlib.colors.Normalize | str | None
+        Normalization for color values. (default: None)
+    cval: np.ndarray | None
+        Line color values. (default: None)
+
+    Returns
+    -------
+    matplotlib.pyplot.Axes
+        Axes with added line data.
+
+    """
+    # Create a set of line segments so that we can color them individually
+    # This creates the points as a N x 1 x 2 array so that we can stack points
+    # together easily to get the segments. The segments array for line collection
+    # needs to be (numlines) x (points per line) x 2 (for x and y)
+    points = np.array([x_signal, y_signal]).T.reshape((-1, 1, 2))
+    segments = np.concatenate([points[:-1], points[1:]], axis=1)
+    # Create a continuous norm to map from data points to colors
+    line_collection = LineCollection(segments, cmap=cmap, norm=cmap_norm)
+    # Set the values used for colormapping
+    line_collection.set_array(cval)
+    line_collection.set_linewidth(2)
+    line = ax.add_collection(line_collection)
+    return line


Test patch:
diff --git a/tests/unit/plotting/heatmap_test.py b/tests/unit/plotting/heatmap_test.py
index 6f0e2cc..5e44040 100644
--- a/tests/unit/plotting/heatmap_test.py
+++ b/tests/unit/plotting/heatmap_test.py
@@ -118,6 +118,16 @@ def args_fixture(experiment_fixture, request):
         pytest.param(
             {'show_cbar': False}, id='show_cbar_false',
         ),
+        pytest.param(
+            {
+                'add_stimulus': True,
+                'path_to_image_stimulus': './tests/files/pexels-zoorg-1000498.jpg',
+                'stimulus_origin': 'lower',
+            }, id='add_stimulus_true',
+        ),
+        pytest.param(
+            {'add_stimulus': False}, id='add_stimulus_false',
+        ),
     ],
 )
 def test_heatmap_show(args, kwargs, monkeypatch):
diff --git a/tests/unit/plotting/scanpathplot_test.py b/tests/unit/plotting/scanpathplot_test.py
index f01b087..40ce6f7 100644
--- a/tests/unit/plotting/scanpathplot_test.py
+++ b/tests/unit/plotting/scanpathplot_test.py
@@ -128,6 +128,13 @@ def gaze_fixture():
             {'cval': np.arange(0, 200), 'title': 'foo'},
             id='set_title',
         ),
+        pytest.param(
+            {
+                'add_stimulus': True,
+                'path_to_image_stimulus': './tests/files/pexels-zoorg-1000498.jpg',
+            },
+            id='set_stimulus',
+        ),
     ],
 )
 def test_scanpathplot_show(events, gaze, kwargs, monkeypatch):
diff --git a/tests/unit/plotting/traceplot_test.py b/tests/unit/plotting/traceplot_test.py
index 67242b5..d9ace45 100644
--- a/tests/unit/plotting/traceplot_test.py
+++ b/tests/unit/plotting/traceplot_test.py
@@ -113,6 +113,13 @@ def gaze_fixture():
             {'cval': np.arange(0, 200), 'title': 'foo'},
             id='set_title',
         ),
+        pytest.param(
+            {
+                'add_stimulus': True,
+                'path_to_image_stimulus': './tests/files/pexels-zoorg-1000498.jpg',
+            },
+            id='set_stimulus',
+        ),
     ],
 )
 def test_traceplot_show(gaze, kwargs, monkeypatch):
diff --git a/tests/unit/utils/plotting_test.py b/tests/unit/utils/plotting_test.py
index 7e1dd51..26cd147 100644
--- a/tests/unit/utils/plotting_test.py
+++ b/tests/unit/utils/plotting_test.py
@@ -93,3 +93,44 @@ def test_no_show_image_stimulus(image_stimulus, origin, monkeypatch):
     pm.utils.plotting.draw_image_stimulus(image_stimulus, origin=origin, show=False)
     pyplot.close()
     mock.assert_not_called()
+
+
+@pytest.mark.parametrize(
+    ('image_stimulus'),
+    (
+        pytest.param(
+            './tests/files/pexels-zoorg-1000498.jpg',
+            id='image_stimulus_str',
+        ),
+        pytest.param(
+            Path('./tests/files/pexels-zoorg-1000498.jpg'),
+            id='image_stimulus_Path',
+        ),
+    ),
+)
+@pytest.mark.parametrize(
+    ('origin'),
+    (
+        pytest.param(
+            'upper',
+            id='origin_upper',
+        ),
+        pytest.param(
+            'lower',
+            id='origin_lower',
+        ),
+    ),
+)
+def test_image_stimulus_fig_not_None(image_stimulus, origin, monkeypatch):
+    mock = Mock()
+    monkeypatch.setattr(pyplot, 'show', mock)
+    fig, ax = pyplot.subplots(figsize=(15, 10))
+    pm.utils.plotting.draw_image_stimulus(
+        image_stimulus,
+        origin=origin,
+        fig=fig,
+        ax=ax,
+        show=True,
+    )
+    pyplot.close()
+    mock.assert_called_once()