Skip to content

Commit bb16e01

Browse files
authored
Merge pull request #2080 from wkentaro/test/prefer-e2e-coverage
test: prefer end-to-end coverage over internal-helper unit tests
2 parents 6d83149 + c9fe50b commit bb16e01

17 files changed

Lines changed: 1208 additions & 564 deletions

tests/e2e/annotation_test.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from __future__ import annotations
22

33
from pathlib import Path
4-
from typing import Literal
54

65
import osam.types._blob
76
import pytest
@@ -10,6 +9,8 @@
109
from PyQt5.QtCore import QTimer
1110
from pytestqt.qtbot import QtBot
1211

12+
from labelme._automation._types import AiOutputFormat
13+
1314
from ..conftest import assert_labelfile_sanity
1415
from ..conftest import close_or_pause
1516
from .conftest import MainWinFactory
@@ -103,6 +104,33 @@
103104
"mask",
104105
id="ai_points-mask",
105106
),
107+
pytest.param(
108+
"ai_points_to_shape",
109+
[],
110+
(0.5, 0.5),
111+
Qt.ControlModifier,
112+
2,
113+
"rectangle",
114+
id="ai_points-rectangle",
115+
),
116+
pytest.param(
117+
"ai_points_to_shape",
118+
[],
119+
(0.5, 0.5),
120+
Qt.ControlModifier,
121+
2,
122+
"circle",
123+
id="ai_points-circle",
124+
),
125+
pytest.param(
126+
"ai_points_to_shape",
127+
[],
128+
(0.5, 0.5),
129+
Qt.ControlModifier,
130+
4,
131+
"oriented_rectangle",
132+
id="ai_points-oriented_rectangle",
133+
),
106134
pytest.param(
107135
"ai_box_to_shape",
108136
[(0.3, 0.3)],
@@ -121,6 +149,24 @@
121149
"mask",
122150
id="ai_box-mask",
123151
),
152+
pytest.param(
153+
"ai_box_to_shape",
154+
[(0.3, 0.3)],
155+
(0.7, 0.7),
156+
Qt.NoModifier,
157+
2,
158+
"rectangle",
159+
id="ai_box-rectangle",
160+
),
161+
pytest.param(
162+
"ai_box_to_shape",
163+
[(0.3, 0.3)],
164+
(0.7, 0.7),
165+
Qt.NoModifier,
166+
2,
167+
"circle",
168+
id="ai_box-circle",
169+
),
124170
pytest.param(
125171
"ai_box_to_shape",
126172
[(0.3, 0.3)],
@@ -143,7 +189,7 @@ def test_annotate_shape_types(
143189
finalize_click: tuple[float, float],
144190
finalize_modifier: Qt.KeyboardModifier,
145191
expected_num_points: int | None,
146-
ai_output_format: Literal["polygon", "mask", "oriented_rectangle"] | None,
192+
ai_output_format: AiOutputFormat | None,
147193
) -> None:
148194
expected_shape_type = ai_output_format if ai_output_format else create_mode
149195

tests/e2e/canvas_interaction_test.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
from __future__ import annotations
22

3+
import base64
4+
import json
35
from pathlib import Path
46
from typing import Final
57

8+
import numpy as np
9+
import PIL.Image
610
import pytest
711
from PyQt5.QtCore import QPointF
812
from PyQt5.QtCore import Qt
913
from pytestqt.qtbot import QtBot
1014

1115
from labelme import _shape
16+
from labelme import utils
1217
from labelme._shape import Shape
1318
from labelme.app import MainWindow
1419
from labelme.widgets.canvas import Canvas
@@ -17,11 +22,14 @@
1722

1823
from ..conftest import assert_labelfile_sanity
1924
from ..conftest import close_or_pause
25+
from .conftest import MainWinFactory
2026
from .conftest import click_canvas_fraction
2127
from .conftest import drag_canvas
28+
from .conftest import hover_widget_pos
2229
from .conftest import image_to_widget_pos
2330
from .conftest import schedule_on_dialog
2431
from .conftest import select_shape
32+
from .conftest import show_window_and_wait_for_imagedata
2533
from .conftest import submit_label_dialog
2634

2735
_TEST_FILE_NAME: Final[str] = "annotated/2011_000003.json"
@@ -602,3 +610,134 @@ def test_remove_point_blocked_at_minimum(
602610

603611
_save_and_check(win=raw_win, tmp_path=tmp_path)
604612
close_or_pause(qtbot=qtbot, widget=raw_win, pause=pause)
613+
614+
615+
def _click_to_select(qtbot: QtBot, canvas: Canvas, image_pos: QPointF) -> None:
616+
pos = image_to_widget_pos(canvas=canvas, image_pos=image_pos)
617+
hover_widget_pos(qtbot=qtbot, canvas=canvas, pos=pos)
618+
qtbot.mouseClick(canvas, Qt.LeftButton, pos=pos)
619+
qtbot.wait(50)
620+
621+
622+
@pytest.mark.gui
623+
def test_select_point_shape_by_click(
624+
qtbot: QtBot,
625+
raw_win: MainWindow,
626+
pause: bool,
627+
) -> None:
628+
canvas = raw_win._canvas_widgets.canvas
629+
raw_win._switch_canvas_mode(edit=False, create_mode="point")
630+
qtbot.wait(50)
631+
632+
submit_label_dialog(qtbot=qtbot, label_dialog=raw_win._label_dialog, label="pt")
633+
click_canvas_fraction(qtbot=qtbot, canvas=canvas, xy=(0.5, 0.5))
634+
635+
shape = _wait_for_shape(qtbot=qtbot, canvas=canvas, label="pt")
636+
assert shape.shape_type == "point"
637+
638+
raw_win._switch_canvas_mode(edit=True)
639+
qtbot.wait(50)
640+
641+
_click_to_select(qtbot=qtbot, canvas=canvas, image_pos=QPointF(shape.points[0]))
642+
643+
assert shape in canvas.selected_shapes
644+
645+
close_or_pause(qtbot=qtbot, widget=raw_win, pause=pause)
646+
647+
648+
@pytest.mark.gui
649+
def test_right_click_on_shape_opens_context_menu(
650+
qtbot: QtBot,
651+
annotated_win: MainWindow,
652+
monkeypatch: pytest.MonkeyPatch,
653+
pause: bool,
654+
) -> None:
655+
canvas = annotated_win._canvas_widgets.canvas
656+
# No prior right-drag has populated `_selected_shapes_copy`, so the bare
657+
# right-click should open menus[0] (the no-clipboard variant). Stubbing
658+
# both exec_ methods catches a regression that would route to menus[1].
659+
menu_opened: list[int] = []
660+
monkeypatch.setattr(
661+
canvas.menus[0],
662+
"exec_",
663+
lambda *args, **kwargs: menu_opened.append(0) or None,
664+
)
665+
monkeypatch.setattr(
666+
canvas.menus[1],
667+
"exec_",
668+
lambda *args, **kwargs: menu_opened.append(1) or None,
669+
)
670+
671+
bounds_center = _shape.bounds(shape=canvas.shapes[_SHAPE_INDEX]).center()
672+
pos = image_to_widget_pos(canvas=canvas, image_pos=bounds_center)
673+
qtbot.mouseMove(canvas, pos=pos)
674+
qtbot.wait(50)
675+
qtbot.mouseClick(canvas, Qt.RightButton, pos=pos)
676+
qtbot.wait(50)
677+
678+
assert menu_opened == [0]
679+
680+
close_or_pause(qtbot=qtbot, widget=annotated_win, pause=pause)
681+
682+
683+
@pytest.mark.gui
684+
def test_select_mask_shape_by_click(
685+
main_win: MainWinFactory,
686+
qtbot: QtBot,
687+
data_path: Path,
688+
tmp_path: Path,
689+
pause: bool,
690+
) -> None:
691+
# Mask cells are indexed pixel-for-pixel from points[0]; clicking inside
692+
# the True region must land on a True cell to exercise the mask branch
693+
# of `_shape.contains_point`.
694+
mask_arr = np.zeros((40, 40), dtype=np.uint8)
695+
mask_arr[10:30, 10:30] = 1
696+
mask_b64 = utils.img_arr_to_b64(mask_arr)
697+
698+
raw_image_path = data_path / "raw/2011_000003.jpg"
699+
img_b64 = base64.b64encode(raw_image_path.read_bytes()).decode("utf-8")
700+
image_width, image_height = PIL.Image.open(raw_image_path).size
701+
702+
fixture_json = tmp_path / "mask_fixture.json"
703+
fixture_json.write_text(
704+
json.dumps(
705+
{
706+
"version": "6.0.0",
707+
"flags": {},
708+
"shapes": [
709+
{
710+
"label": "mask_shape",
711+
"points": [[100.0, 100.0], [139.0, 139.0]],
712+
"group_id": None,
713+
"description": "",
714+
"shape_type": "mask",
715+
"flags": {},
716+
"mask": mask_b64,
717+
}
718+
],
719+
"imagePath": raw_image_path.name,
720+
"imageData": img_b64,
721+
"imageHeight": image_height,
722+
"imageWidth": image_width,
723+
}
724+
)
725+
)
726+
727+
win = main_win(
728+
file_or_dir=str(fixture_json),
729+
config_overrides={"with_image_data": True},
730+
)
731+
show_window_and_wait_for_imagedata(qtbot=qtbot, win=win)
732+
canvas = win._canvas_widgets.canvas
733+
734+
qtbot.waitUntil(lambda: any(s.label == "mask_shape" for s in canvas.shapes))
735+
shape = next(s for s in canvas.shapes if s.label == "mask_shape")
736+
737+
# True region in image coords: rows/cols 110..129 (mask[10:30,10:30] +
738+
# origin (100,100)). Click well inside that block.
739+
_click_to_select(qtbot=qtbot, canvas=canvas, image_pos=QPointF(120.0, 120.0))
740+
741+
assert shape in canvas.selected_shapes
742+
743+
close_or_pause(qtbot=qtbot, widget=win, pause=pause)

tests/e2e/conftest.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,17 @@ def create(
136136
pass
137137

138138

139+
def hover_widget_pos(qtbot: QtBot, canvas: Canvas, pos: QPoint) -> None:
140+
# The offscreen Qt platform dedupes mouseMove events that match the
141+
# current cursor position, which suppresses hover-state refresh after a
142+
# click that landed on the same pixel. Nudging to (0, 0) first guarantees
143+
# the second move is treated as fresh.
144+
qtbot.mouseMove(canvas, pos=QPoint(0, 0))
145+
qtbot.wait(50)
146+
qtbot.mouseMove(canvas, pos=pos)
147+
qtbot.wait(50)
148+
149+
139150
def click_canvas_fraction(
140151
qtbot: QtBot,
141152
canvas: Canvas,

0 commit comments

Comments
 (0)