diff --git a/OTAnalytics/application/resources/resource_manager.py b/OTAnalytics/application/resources/resource_manager.py
index 04d6783d2..c18b6c2e0 100644
--- a/OTAnalytics/application/resources/resource_manager.py
+++ b/OTAnalytics/application/resources/resource_manager.py
@@ -23,6 +23,7 @@ class CanvasKeys(ResourceKey):
LABEL_ADD_SECTION = "label-add-section"
LABEL_EDIT_SECTION = "label-edit-section"
IMAGE_DEFAULT = "image-default"
+ MESSAGE_NOT_ENOUGH_POINTS = "message-not-enough-points"
class ConfigurationBarKeys(ResourceKey):
@@ -144,6 +145,11 @@ class RemarkKeys(ResourceKey):
class HotKeys(ResourceKey):
SAVE_SECTION_HOTKEY = "save-section-hotkey"
CANCEL_SECTION_GEOMETRY_HOTKEY = "cancel-section-geometry-hotkey"
+ ADD_KNOB_HOTKEY = "add-knob-hotkey"
+ DELETE_KNOB_HOTKEY = "delete-knob-hotkey"
+ DELETE_KNOB_ALTERNATIVE_HOTKEY = "delete-knob-alternative-hotkey"
+ SHIFT_KNOB_BACKWARD_HOTKEY = "shift-knob-backward-hotkey"
+ SHIFT_KNOB_FORWARD_HOTKEY = "shift-knob-forward-hotkey"
class VisualizationFiltersKeys(ResourceKey):
@@ -212,6 +218,9 @@ class WorkspaceKeys(ResourceKey):
AnalysisKeys.BUTTON_TEXT_EXPORT_TRACK_STATISTICS: "Export track statistics",
CanvasKeys.LABEL_ADD_SECTION: "Add section",
CanvasKeys.LABEL_EDIT_SECTION: "Edit section",
+ CanvasKeys.MESSAGE_NOT_ENOUGH_POINTS: (
+ "Please add at least {count} points to finish the section."
+ ),
ConfigurationBarKeys.LABEL_CONFIGURATION_BAR_FORM_HEADER: "Configuration Bar",
FlowAndSectionKeys.TAB_SECTION: "Section",
FlowAndSectionKeys.TAB_FLOW: "Flow",
@@ -324,10 +333,20 @@ class WorkspaceKeys(ResourceKey):
# Hotkey values as constants for reuse
SAVE_SECTION_HOTKEY_VALUE = "Enter"
CANCEL_SECTION_GEOMETRY_HOTKEY_VALUE = "Escape"
+ADD_KNOB_HOTKEY_VALUE = "+"
+DELETE_KNOB_HOTKEY_VALUE = "Delete"
+DELETE_KNOB_ALTERNATIVE_HOTKEY_VALUE = "Backspace"
+SHIFT_KNOB_BACKWARD_HOTKEY_VALUE = "ArrowLeft"
+SHIFT_KNOB_FORWARD_HOTKEY_VALUE = "ArrowRight"
DEFAULT_HOTKEYS: dict[HotKeys, str] = {
HotKeys.SAVE_SECTION_HOTKEY: SAVE_SECTION_HOTKEY_VALUE,
HotKeys.CANCEL_SECTION_GEOMETRY_HOTKEY: CANCEL_SECTION_GEOMETRY_HOTKEY_VALUE,
+ HotKeys.ADD_KNOB_HOTKEY: ADD_KNOB_HOTKEY_VALUE,
+ HotKeys.DELETE_KNOB_HOTKEY: DELETE_KNOB_HOTKEY_VALUE,
+ HotKeys.DELETE_KNOB_ALTERNATIVE_HOTKEY: DELETE_KNOB_ALTERNATIVE_HOTKEY_VALUE,
+ HotKeys.SHIFT_KNOB_BACKWARD_HOTKEY: SHIFT_KNOB_BACKWARD_HOTKEY_VALUE,
+ HotKeys.SHIFT_KNOB_FORWARD_HOTKEY: SHIFT_KNOB_FORWARD_HOTKEY_VALUE,
}
diff --git a/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/arrow.py b/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/arrow.py
new file mode 100644
index 000000000..df9e3d3fb
--- /dev/null
+++ b/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/arrow.py
@@ -0,0 +1,54 @@
+import math
+from dataclasses import dataclass
+
+DEFAULT_ARROW_WIDTH = 2
+DEFAULT_HEAD_LENGTH = 20
+DEFAULT_HEAD_HALF_WIDTH = 8
+
+
+@dataclass
+class Arrow:
+ """Line from start to end with an arrow head at the end, e.g. a flow."""
+
+ id: str
+ x1: int
+ y1: int
+ x2: int
+ y2: int
+ color: str
+ width: int = DEFAULT_ARROW_WIDTH
+ dash: tuple[int, ...] | None = None
+ head_length: int = DEFAULT_HEAD_LENGTH
+ head_half_width: int = DEFAULT_HEAD_HALF_WIDTH
+
+ def to_svg(self) -> str:
+ dash = (
+ f' stroke-dasharray="{" ".join(str(d) for d in self.dash)}"'
+ if self.dash
+ else ""
+ )
+ svg = f"""
+ """
+ if head := self.head_points():
+ points = " ".join(f"{x},{y}" for x, y in head)
+ svg += f"""
+ """
+ return svg + ""
+
+ def head_points(self) -> list[tuple[float, float]]:
+ """Corners of the arrow head: tip and the two trailing points."""
+ dx = self.x2 - self.x1
+ dy = self.y2 - self.y1
+ length = math.hypot(dx, dy)
+ if length == 0:
+ return []
+ ux, uy = dx / length, dy / length
+ base_x = self.x2 - ux * self.head_length
+ base_y = self.y2 - uy * self.head_length
+ nx, ny = -uy * self.head_half_width, ux * self.head_half_width
+ return [
+ (float(self.x2), float(self.y2)),
+ (base_x + nx, base_y + ny),
+ (base_x - nx, base_y - ny),
+ ]
diff --git a/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/line_resources.py b/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/line_resources.py
index 6bf4d6276..e019bfeb0 100644
--- a/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/line_resources.py
+++ b/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/line_resources.py
@@ -1,16 +1,17 @@
from dataclasses import dataclass
+from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.arrow import Arrow
from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.line import Line
@dataclass
class LineResources:
- lines: dict[str, Line]
+ lines: dict[str, Line | Arrow]
def to_svg(self) -> str:
return "\n".join([line.to_svg() for line in self.lines.values()])
- def add(self, line: Line) -> None:
+ def add(self, line: Line | Arrow) -> None:
self.lines[line.id] = line
def clear(self) -> None:
diff --git a/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/polygon.py b/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/polygon.py
new file mode 100644
index 000000000..5ae6be8e8
--- /dev/null
+++ b/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/polygon.py
@@ -0,0 +1,23 @@
+from dataclasses import dataclass
+
+AREA_FILL_OPACITY = 0.3
+
+
+@dataclass
+class Polygon:
+ """Closed shape that is filled semi-transparently, e.g. an area section."""
+
+ id: str
+ points: list[tuple[int, int]]
+ color: str
+ fill_opacity: float = AREA_FILL_OPACITY
+
+ def to_svg(self) -> str:
+ return f""""""
+
+ def __serialized_points(self) -> str:
+ return " ".join(f"{x},{y}" for x, y in self.points)
diff --git a/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/section_resources.py b/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/section_resources.py
index de5be9aa0..9d34d4303 100644
--- a/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/section_resources.py
+++ b/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/section_resources.py
@@ -1,17 +1,26 @@
-from dataclasses import dataclass
+from dataclasses import dataclass, field
+from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.polygon import Polygon
from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.polyline import Polyline
+from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.text import Text
@dataclass
class SectionResource:
- lines: dict[str, Polyline]
+ lines: dict[str, Polyline | Polygon]
+ texts: dict[str, Text] = field(default_factory=dict)
def to_svg(self) -> str:
- return "\n".join(line.to_svg() for line in self.lines.values())
+ shapes = [line.to_svg() for line in self.lines.values()]
+ texts = [text.to_svg() for text in self.texts.values()]
+ return "\n".join(shapes + texts)
def clear(self) -> None:
self.lines.clear()
+ self.texts.clear()
- def add(self, section_id: str, line: Polyline) -> None:
+ def add(self, section_id: str, line: Polyline | Polygon) -> None:
self.lines[section_id] = line
+
+ def add_text(self, section_id: str, text: Text) -> None:
+ self.texts[section_id] = text
diff --git a/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/text.py b/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/text.py
new file mode 100644
index 000000000..1bd022f8a
--- /dev/null
+++ b/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/text.py
@@ -0,0 +1,22 @@
+from dataclasses import dataclass
+from html import escape
+
+DEFAULT_FONT_SIZE = 14
+
+
+@dataclass
+class Text:
+ id: str
+ x: int
+ y: int
+ text: str
+ color: str
+ font_size: int = DEFAULT_FONT_SIZE
+
+ def to_svg(self) -> str:
+ return f"""{escape(self.text)}"""
diff --git a/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/canvas_form.py b/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/canvas_form.py
index d030aa2e7..9385332ac 100644
--- a/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/canvas_form.py
+++ b/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/canvas_form.py
@@ -1,4 +1,4 @@
-from typing import Iterable, Optional, Self
+from typing import Any, Final, Iterable, Optional, Self
from nicegui import events, ui
from nicegui.elements.interactive_image import InteractiveImage
@@ -17,27 +17,50 @@
)
from OTAnalytics.domain.section import Section
from OTAnalytics.domain.track import TrackImage
+from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.arrow import Arrow
from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.circle import Circle
from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.circle_resources import (
CircleResources,
)
-from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.line import Line
from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.line_resources import LineResources
+from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.polygon import Polygon
from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.polyline import Polyline
from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.section_resources import (
SectionResource,
)
+from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.text import Text
+from OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.section_geometry import ( # noqa
+ Point,
+ close_area,
+ closest_knob_index,
+ delete_knob,
+ has_enough_knobs,
+ insert_knob,
+ minimum_knob_count,
+ open_area,
+ shift_knob_backward,
+ shift_knob_forward,
+)
from OTAnalytics.plugin_ui.nicegui_gui.test_constants import TEST_ID
NORMAL_COLOR = "green"
SELECTED_COLOR = "red"
EDIT_COLOR = "orange"
MOVING_COLOR = "blue"
+HOVERED_KNOB_COLOR = "yellow"
MOVING_STROKE_WIDTH = 400
MOVING_STROKE_OPACITY = 0.0
MARKER_INTERACTIVE_IMAGE = "marker-interactive-image"
CLICK = "click"
+MOUSE_MOVE = "mousemove"
+MOUSE_UP = "mouseup"
+CONTEXT_MENU = "contextmenu.prevent"
+PASSIVE_MOUSE_EVENTS = [CLICK]
+EDITOR_MOUSE_EVENTS = [CLICK, MOUSE_MOVE, MOUSE_UP]
+EVENTS_PROP = "events"
+RELEASE_FOCUS = "document.activeElement?.blur()"
+NOTIFICATION_TYPE_WARNING: Final = "warning"
POINTER_EVENT_ALL = "all"
POINTER_EVENT_NONE = ""
IMAGE_X = "image_x"
@@ -45,34 +68,83 @@
ELEMENT_ID = "element_id"
CURSOR = "pointer"
+CURSOR_MOVE = "move"
NEW_SECTION_ID = "new-section-id"
+EDIT_KNOB_ID_PREFIX = "edit-knob-"
+KNOB_RADIUS = 6
+HIGHLIGHTED_KNOB_RADIUS = 8
+HIGHLIGHTED_KNOB_STROKE_WIDTH = 3
+KNOB_CATCH_RADIUS = 15
+SECTION_TEXT_OFFSET_Y = -12
+
+# Keys of the arrow style, named like the options of the customtkinter canvas
+ARROW_STYLE_COLOR = "fill"
+ARROW_STYLE_WIDTH = "width"
+ARROW_STYLE_DASH = "dash"
+DEFAULT_ARROW_STYLE: dict = {ARROW_STYLE_COLOR: NORMAL_COLOR, ARROW_STYLE_WIDTH: 2}
def circle_to_coordinates(circles: Iterable[Circle]) -> list[tuple[int, int]]:
return [circle.to_tuple() for circle in circles]
-def create_circle(e: dict, fill: str = NORMAL_COLOR) -> Circle:
- return Circle(
- x=round(e[IMAGE_X]),
- y=round(e[IMAGE_Y]),
- pointer_event=POINTER_EVENT_ALL,
- id=e[ELEMENT_ID],
- fill=fill,
- )
+def create_section_shape(
+ id: str, coordinates: list[Point], color: str, is_area_section: bool
+) -> Polyline | Polygon:
+ """Area sections are drawn closed and filled, line sections as polyline."""
+ if is_area_section:
+ return Polygon(id=id, points=open_area(coordinates, True), color=color)
+ return Polyline(id=id, points=coordinates, color=color)
+
+
+def to_point(x: float, y: float) -> Point:
+ return round(x), round(y)
-def create_moving_circle(e: dict, fill: str = NORMAL_COLOR) -> Circle:
- return Circle(
- x=round(e[IMAGE_X]),
- y=round(e[IMAGE_Y]),
+def svg_event_to_point(e: dict) -> Point:
+ return to_point(e[IMAGE_X], e[IMAGE_Y])
+
+
+def knob_index_from_element_id(element_id: str | None) -> int | None:
+ if not element_id or not element_id.startswith(EDIT_KNOB_ID_PREFIX):
+ return None
+ try:
+ return int(element_id.removeprefix(EDIT_KNOB_ID_PREFIX))
+ except ValueError:
+ return None
+
+
+def create_knob(
+ index: int, point: Point, is_hovered: bool = False, is_selected: bool = False
+) -> Circle:
+ """Create the circle of a knob of the section geometry editor.
+
+ A selected knob gets a large transparent stroke. Thereby, the svg keeps
+ receiving pointer events while the knob is moved.
+ """
+ knob = Circle(
+ id=f"{EDIT_KNOB_ID_PREFIX}{index}",
+ x=point[0],
+ y=point[1],
+ fill=EDIT_COLOR,
pointer_event=POINTER_EVENT_ALL,
- id=e[ELEMENT_ID],
- fill=fill,
- stroke=MOVING_COLOR,
- stroke_width=MOVING_STROKE_WIDTH,
- stroke_opacity=MOVING_STROKE_OPACITY,
+ radius=KNOB_RADIUS,
+ stroke=EDIT_COLOR,
+ stroke_width=0,
+ cursor=CURSOR_MOVE,
)
+ if is_selected:
+ knob.fill = MOVING_COLOR
+ knob.radius = HIGHLIGHTED_KNOB_RADIUS
+ knob.stroke = MOVING_COLOR
+ knob.stroke_width = MOVING_STROKE_WIDTH
+ knob.stroke_opacity = MOVING_STROKE_OPACITY
+ elif is_hovered:
+ knob.radius = HIGHLIGHTED_KNOB_RADIUS
+ knob.stroke = HOVERED_KNOB_COLOR
+ knob.stroke_width = HIGHLIGHTED_KNOB_STROKE_WIDTH
+ knob.stroke_opacity = 1.0
+ return knob
class CanvasForm(AbstractCanvas, AbstractFrameCanvas, AbstractTreeviewInterface):
@@ -90,10 +162,19 @@ def __init__(
self._new_sections = self._current_sections
self._introduce_to_viewmodel()
self._current_section: Section | None = None
- self._current_point: Circle | None = None
- self._new_point: Circle | None = None
+ self._edit_is_area_section = False
+ self._edit_coordinates: list[Point] = []
+ self._edit_temporary: list[Point] = []
+ self._hovered_knob_index: int | None = None
+ self._selected_knob_index: int | None = None
+ self._dragging = False
+ self._drop_on_release = False
+ self._knob_moved = False
+ self._mouse_position: Point | None = None
+ self._enabled = True
self.add_preview_image()
self._new_section_points: list[Circle] = []
+ self._preview_point: Point | None = None
self._new_section = False
self._new_area_section = False
self._sections: SectionResource = SectionResource({})
@@ -110,7 +191,7 @@ def add_preview_image(self) -> None:
def build(self) -> Self:
self._background_image = ui.interactive_image(
- "", on_mouse=self._on_pointer_down, events=[CLICK]
+ "", on_mouse=self.on_mouse, events=list(PASSIVE_MOUSE_EVENTS)
)
self._background_image.props(f"{TEST_ID}={MARKER_INTERACTIVE_IMAGE}")
self._background_image.on(
@@ -122,39 +203,146 @@ def build(self) -> Self:
self._background_image.on(
"svg:pointerup", lambda e: self.on_svg_pointer_up(e.args)
)
+ self._background_image.on(CONTEXT_MENU, self.on_right_click)
self._change_image()
ui.keyboard(on_key=self.handle_key)
self.load_sections()
return self
+ def _is_editor_active(self) -> bool:
+ return self._new_section or self._current_section is not None
+
+ def _accepts_input(self) -> bool:
+ return self._enabled and self._is_editor_active()
+
+ def _is_hotkey(self, e: KeyEventArguments, hotkey: HotKeys) -> bool:
+ return e.key == self._resource_manager.get_hotkey(hotkey)
+
async def handle_key(self, e: KeyEventArguments) -> None:
- if e.key == self._resource_manager.get_hotkey(HotKeys.SAVE_SECTION_HOTKEY):
- if self._new_section:
- await self._save_new_section()
- elif self._current_section:
- self._save_edit_section()
- if e.key == self._resource_manager.get_hotkey(
- HotKeys.CANCEL_SECTION_GEOMETRY_HOTKEY
- ):
+ """Handle key presses. Keys are only evaluated while an editor is active.
+
+ This prevents key presses in other parts of the page from being handled by
+ the canvas.
+ """
+ if not self._accepts_input() or not e.action.keydown:
+ return
+ if self._is_hotkey(e, HotKeys.SAVE_SECTION_HOTKEY):
+ await self._finish_editor()
+ elif self._is_hotkey(e, HotKeys.CANCEL_SECTION_GEOMETRY_HOTKEY):
+ self._on_cancel_key()
+ elif self._current_section is not None:
+ self._handle_geometry_editor_key(e)
+
+ def _on_cancel_key(self) -> None:
+ if self._current_section is not None and self._selected_knob_index is not None:
+ self._deselect_knob(revert=True)
+ self.draw_all()
+ else:
self._cancel_action()
+ def _handle_geometry_editor_key(self, e: KeyEventArguments) -> None:
+ if self._is_hotkey(e, HotKeys.ADD_KNOB_HOTKEY):
+ self._add_knob()
+ elif self._is_hotkey(e, HotKeys.DELETE_KNOB_HOTKEY) or self._is_hotkey(
+ e, HotKeys.DELETE_KNOB_ALTERNATIVE_HOTKEY
+ ):
+ self._delete_selected_knob()
+ elif self._is_hotkey(e, HotKeys.SHIFT_KNOB_BACKWARD_HOTKEY):
+ self._shift_selected_knob(forward=False)
+ elif self._is_hotkey(e, HotKeys.SHIFT_KNOB_FORWARD_HOTKEY):
+ self._shift_selected_knob(forward=True)
+
+ def _add_knob(self) -> None:
+ """Insert a knob at the mouse position between the closest knobs.
+
+ The new knob is selected and follows the mouse until it is placed by a click.
+ """
+ if self._selected_knob_index is not None or self._mouse_position is None:
+ return
+ self._edit_temporary, self._selected_knob_index = insert_knob(
+ self._edit_temporary, self._mouse_position, self._edit_is_area_section
+ )
+ self._hovered_knob_index = None
+ self._dragging = False
+ self.draw_all()
+
+ def _delete_selected_knob(self) -> None:
+ if self._selected_knob_index is None:
+ return
+ self._edit_temporary = delete_knob(
+ self._edit_temporary,
+ self._selected_knob_index,
+ self._edit_is_area_section,
+ )
+ self._commit_edit()
+ self._deselect_knob(revert=False)
+ self.draw_all()
+
+ def _shift_selected_knob(self, forward: bool) -> None:
+ if self._selected_knob_index is None:
+ return
+ shift = shift_knob_forward if forward else shift_knob_backward
+ self._edit_temporary, self._selected_knob_index = shift(
+ self._edit_temporary,
+ self._selected_knob_index,
+ self._edit_is_area_section,
+ )
+ self.draw_all()
+
+ def _commit_edit(self) -> None:
+ self._edit_coordinates = list(self._edit_temporary)
+
+ def _deselect_knob(self, revert: bool) -> None:
+ if revert:
+ self._edit_temporary = list(self._edit_coordinates)
+ self._selected_knob_index = None
+ self._dragging = False
+ self._drop_on_release = False
+ self._knob_moved = False
+
+ async def on_right_click(self, _: Any = None) -> None:
+ """Finish the active editor like the save hotkey does."""
+ if self._accepts_input():
+ await self._finish_editor()
+
+ async def _finish_editor(self) -> None:
+ if self._new_section:
+ await self._save_new_section()
+ elif self._current_section:
+ self._save_edit_section()
+
async def _save_new_section(self) -> None:
- """Save a new section with the current points."""
+ """Save a new section with the current points.
+
+ The section is only saved if enough points have been added. Otherwise, the
+ user is notified and can continue adding points.
+ """
coordinates = [circle.to_tuple() for circle in self._new_section_points]
+ is_area_section = self._new_area_section
+ if not has_enough_knobs(coordinates, is_area_section):
+ self._notify_not_enough_points(is_area_section)
+ return
self.__reset_editor()
await self._viewmodel.add_new_section(
- coordinates=coordinates,
- is_area_section=self._new_area_section,
+ coordinates=close_area(coordinates, is_area_section),
+ is_area_section=is_area_section,
get_metadata=self.__get_metadata,
)
+ def _notify_not_enough_points(self, is_area_section: bool) -> None:
+ message = self._resource_manager.get(CanvasKeys.MESSAGE_NOT_ENOUGH_POINTS)
+ ui.notify(
+ message.format(count=minimum_knob_count(is_area_section)),
+ type=NOTIFICATION_TYPE_WARNING,
+ )
+
def _save_edit_section(self) -> None:
"""Save changes to an existing section."""
if self._current_section is None:
return
metadata = self._current_section.to_dict()
- coordinates = self._current_section_geometry()
+ coordinates = close_area(self._edit_temporary, self._edit_is_area_section)
self.__reset_editor()
self._viewmodel.update_section_coordinates(metadata, coordinates)
@@ -164,17 +352,42 @@ def _cancel_action(self) -> None:
self._viewmodel.cancel_action()
self._viewmodel.refresh_items_on_canvas()
- def _current_section_geometry(self) -> list[tuple[int, int]]:
- if self._current_section is None:
- return []
- circles = self._circles.by_section.get(self._current_section.id.id, {})
- return circle_to_coordinates(circles.values())
-
def __reset_editor(self) -> None:
self._new_section = False
self._current_section = None
- self._current_point = None
self._new_section_points = []
+ self._preview_point = None
+ self._reset_geometry_editor()
+ self._update_mouse_events()
+
+ def _reset_geometry_editor(self) -> None:
+ self._edit_coordinates = []
+ self._edit_temporary = []
+ self._hovered_knob_index = None
+ self._mouse_position = None
+ self._deselect_knob(revert=False)
+
+ def _update_mouse_events(self) -> None:
+ """Subscribe to mouse movements only while an editor is active.
+
+ This avoids sending every mouse movement to the server if nobody needs it.
+ """
+ if self._background_image is None:
+ return
+ events = (
+ EDITOR_MOUSE_EVENTS if self._is_editor_active() else PASSIVE_MOUSE_EVENTS
+ )
+ self._background_image._props[EVENTS_PROP] = list(events)
+ self._background_image.update()
+
+ def _release_focus(self) -> None:
+ """Release the focus of the button that started the editor.
+
+ Key presses are ignored while a button has the focus. Releasing it enables
+ the hotkeys of the editor right away.
+ """
+ if self._background_image is not None:
+ self._background_image.client.run_javascript(RELEASE_FOCUS)
async def __get_metadata(self) -> dict:
title = self._resource_manager.get(
@@ -188,35 +401,107 @@ async def __get_metadata(self) -> dict:
def draw_all(self) -> None:
if self._background_image:
- self._background_image.content = self._edit_geometry()
- self._background_image.content += self._sections.to_svg()
+ self._background_image.content = self._sections.to_svg()
self._background_image.content += self._flows.to_svg()
self._background_image.content += self._circles.to_svg()
- if self._current_point:
- self._background_image.content += self._current_point.to_svg()
- if self._new_point:
- self._background_image.content += self._new_point.to_svg()
+ self._background_image.content += self._edit_geometry()
self.draw_new_section()
def draw_new_section(self) -> None:
+ if not self._background_image:
+ return
+ coordinates = circle_to_coordinates(self._new_section_points)
+ if coordinates and self._preview_point:
+ coordinates.append(self._preview_point)
+ if len(coordinates) >= 2:
+ shape = create_section_shape(
+ NEW_SECTION_ID, coordinates, EDIT_COLOR, self._new_area_section
+ )
+ self._background_image.content += shape.to_svg()
for point in self._new_section_points:
- if self._background_image:
- self._background_image.content += point.to_svg()
- if len(self._new_section_points) >= 2:
- coordinates = circle_to_coordinates(self._new_section_points)
- if self._new_area_section:
- coordinates.append(coordinates[0])
- line = Polyline(id=NEW_SECTION_ID, points=coordinates, color=EDIT_COLOR)
- if self._background_image:
- self._background_image.content += line.to_svg()
+ self._background_image.content += point.to_svg()
def _edit_geometry(self) -> str:
if self._current_section is None:
return ""
section_id = f"{self._current_section.id.id}-edit"
- return Polyline(
- id=section_id, points=self._current_section_geometry(), color=EDIT_COLOR
+ svg = create_section_shape(
+ section_id, self._edit_temporary, EDIT_COLOR, self._edit_is_area_section
).to_svg()
+ for index, point in enumerate(self._edit_temporary):
+ knob = create_knob(
+ index,
+ point,
+ is_hovered=index == self._hovered_knob_index,
+ is_selected=index == self._selected_knob_index,
+ )
+ svg += knob.to_svg()
+ return svg
+
+ def on_mouse(self, e: events.MouseEventArguments) -> None:
+ """Dispatch mouse events of the interactive image."""
+ if not self._accepts_input():
+ return
+ point = to_point(e.image_x, e.image_y)
+ if e.type == MOUSE_MOVE:
+ self._on_move(point)
+ elif e.type == MOUSE_UP:
+ self._on_release(point)
+ elif e.type == CLICK:
+ if self._current_section is not None:
+ self._on_canvas_click(point)
+ else:
+ self._on_pointer_down(e)
+
+ def _on_move(self, point: Point) -> None:
+ if self._new_section:
+ self._preview_point = point
+ self.draw_all()
+ elif self._current_section is not None:
+ self._mouse_position = point
+ if self._selected_knob_index is not None:
+ self._move_selected_knob(point)
+ else:
+ self._hover_knob(point)
+
+ def _move_selected_knob(self, point: Point) -> None:
+ if self._selected_knob_index is None:
+ return
+ self._edit_temporary[self._selected_knob_index] = point
+ if self._dragging:
+ self._knob_moved = True
+ self.draw_all()
+
+ def _hover_knob(self, point: Point) -> None:
+ hovered = closest_knob_index(self._edit_temporary, point, KNOB_CATCH_RADIUS)
+ if hovered != self._hovered_knob_index:
+ self._hovered_knob_index = hovered
+ self.draw_all()
+
+ def _on_release(self, point: Point) -> None:
+ """Place the selected knob if it was dragged or picked up before."""
+ if self._current_section is None or not self._dragging:
+ return
+ self._dragging = False
+ if self._selected_knob_index is None:
+ return
+ if self._knob_moved or self._drop_on_release:
+ self._place_selected_knob(point)
+ self.draw_all()
+
+ def _on_canvas_click(self, point: Point) -> None:
+ if self._selected_knob_index is not None and not self._dragging:
+ self._place_selected_knob(point)
+ self.draw_all()
+
+ def _place_selected_knob(self, point: Point) -> None:
+ index = self._selected_knob_index
+ if index is None:
+ return
+ self._edit_temporary[index] = point
+ self._commit_edit()
+ self._deselect_knob(revert=False)
+ self._hovered_knob_index = index
def _on_pointer_down(self, e: events.MouseEventArguments) -> None:
if self._new_section:
@@ -224,7 +509,7 @@ def _on_pointer_down(self, e: events.MouseEventArguments) -> None:
Circle(
x=round(e.image_x),
y=round(e.image_y),
- pointer_event=POINTER_EVENT_ALL,
+ pointer_event=POINTER_EVENT_NONE,
cursor=CURSOR,
fill=EDIT_COLOR,
id=f"new_point-{len(self._new_section_points)}",
@@ -233,27 +518,34 @@ def _on_pointer_down(self, e: events.MouseEventArguments) -> None:
self.draw_all()
def on_svg_pointer_down(self, e: dict) -> None:
- if self._new_section:
- pass
- elif self._current_section:
- self._current_point = create_moving_circle(e, fill=EDIT_COLOR)
- self.draw_all()
+ """Select a knob of the edited section or pick up the selected knob."""
+ if self._current_section is None or not self._enabled:
+ return
+ point = svg_event_to_point(e)
+ self._mouse_position = point
+ if self._selected_knob_index is None:
+ index = knob_index_from_element_id(e.get(ELEMENT_ID))
+ if index is None:
+ index = closest_knob_index(
+ self._edit_temporary, point, KNOB_CATCH_RADIUS
+ )
+ if index is None:
+ return
+ self._selected_knob_index = index
+ self._drop_on_release = False
+ else:
+ self._drop_on_release = True
+ self._dragging = True
+ self._knob_moved = False
+ self.draw_all()
def on_svg_pointer_move(self, e: dict) -> None:
- if self._current_section and self._current_point:
- self._current_point = create_moving_circle(e, fill=EDIT_COLOR)
- self._circles.add(self._current_section.id.id, self._current_point)
- self.draw_all()
+ if self._accepts_input():
+ self._on_move(svg_event_to_point(e))
def on_svg_pointer_up(self, e: dict) -> None:
- if self._new_section:
- pass
- elif self._current_section and self._current_point:
- self._circles.add(
- self._current_section.id.id, create_circle(e, fill=EDIT_COLOR)
- )
- self._current_point = None
- self.draw_all()
+ if self._accepts_input():
+ self._on_release(svg_event_to_point(e))
def update_background(self, image: TrackImage) -> None:
self._current_image = image.as_image()
@@ -272,11 +564,6 @@ def clear_image(self) -> None:
def load_sections(self) -> None:
self._viewmodel.refresh_items_on_canvas()
- def draw_current_point(self) -> None:
- if self._background_image:
- if self._current_point:
- self._background_image.content += self._current_point.to_svg()
-
def draw_section(
self,
id: str,
@@ -294,7 +581,8 @@ def draw_section(
if is_selected_section:
color = SELECTED_COLOR
pointer_event = POINTER_EVENT_ALL
- for index, coordinate in enumerate(coordinates):
+ knobs = open_area(coordinates, is_area_section)
+ for index, coordinate in enumerate(knobs):
x = coordinate[0]
y = coordinate[1]
list_of_circle.append(
@@ -308,10 +596,21 @@ def draw_section(
)
for circle in list_of_circle:
self._circles.add(id, circle)
- polyline = Polyline(id, coordinates, color=color)
- self._sections.add(id, polyline)
- if self._current_point:
- self.draw_current_point()
+ self._sections.add(
+ id, create_section_shape(id, coordinates, color, is_area_section)
+ )
+ if text is not None and coordinates:
+ x, y = coordinates[0]
+ self._sections.add_text(
+ id,
+ Text(
+ id=f"{id}-text",
+ x=x,
+ y=y + SECTION_TEXT_OFFSET_Y,
+ text=text,
+ color=color,
+ ),
+ )
self.draw_all()
def start_section_geometry_editor(
@@ -320,7 +619,21 @@ def start_section_geometry_editor(
hovered_knob_style: dict | None = None,
is_area_section: bool = False,
) -> None:
+ self._new_section = False
+ self._new_section_points = []
+ self._preview_point = None
+ self._reset_geometry_editor()
+ self._enabled = True
self._current_section = section
+ self._edit_is_area_section = is_area_section
+ coordinates = [
+ to_point(coordinate.x, coordinate.y)
+ for coordinate in section.get_coordinates()
+ ]
+ self._edit_coordinates = open_area(coordinates, is_area_section)
+ self._edit_temporary = list(self._edit_coordinates)
+ self._update_mouse_events()
+ self._release_focus()
self.draw_all()
def draw_arrow(
@@ -334,15 +647,19 @@ def draw_arrow(
) -> None:
start_x, start_y = start_refpt_calculator.get_reference_point(start_section)
end_x, end_y = end_refpt_calculator.get_reference_point(end_section)
- new_line = Line(
+ style = {**DEFAULT_ARROW_STYLE, **(arrow_style or {})}
+ dash = style.get(ARROW_STYLE_DASH)
+ arrow = Arrow(
id=f"arrow-{start_section.id}-{end_section.id}",
x1=int(start_x),
x2=int(end_x),
y1=int(start_y),
y2=int(end_y),
- stroke=NORMAL_COLOR,
+ color=style[ARROW_STYLE_COLOR],
+ width=style[ARROW_STYLE_WIDTH],
+ dash=tuple(dash) if dash else None,
)
- self._flows.add(new_line)
+ self._flows.add(arrow)
self.draw_all()
def delete_element(self, tag_or_id: str) -> None:
@@ -357,8 +674,16 @@ def start_section_builder(
is_area_section: bool = False,
section: Optional[Section] = None,
) -> None:
+ self._enabled = True
+ self._current_section = None
+ self._reset_geometry_editor()
self._new_section = True
self._new_area_section = is_area_section
+ self._new_section_points = []
+ self._preview_point = None
+ self._update_mouse_events()
+ self._release_focus()
+ self.draw_all()
def get_position(self, offset: tuple[float, float] = (0.5, 0.5)) -> tuple[int, int]:
return 0, 0
@@ -367,10 +692,15 @@ def _notify_viewmodel_about_selected_item_ids(self, ids: list[str]) -> None:
pass
def disable(self) -> None:
- pass
+ """Ignore canvas input while an action is running.
+
+ An editor started by the running action enables the canvas again, because
+ the editor is part of this action.
+ """
+ self._enabled = False
def enable(self) -> None:
- pass
+ self._enabled = True
def update_items(self) -> None:
pass
diff --git a/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/section_geometry.py b/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/section_geometry.py
new file mode 100644
index 000000000..08ed00aaf
--- /dev/null
+++ b/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/section_geometry.py
@@ -0,0 +1,184 @@
+"""UI independent geometry helpers for the section editor on the WebUI canvas."""
+
+Point = tuple[int, int]
+
+MINIMUM_LINE_SECTION_KNOBS = 2
+MINIMUM_AREA_SECTION_KNOBS = 3
+
+
+def minimum_knob_count(is_area_section: bool) -> int:
+ """Minimal number of knobs a section must have.
+
+ A line section needs a start and an end. An area needs at least three distinct
+ knobs to enclose a surface.
+ """
+ if is_area_section:
+ return MINIMUM_AREA_SECTION_KNOBS
+ return MINIMUM_LINE_SECTION_KNOBS
+
+
+def has_enough_knobs(coordinates: list[Point], is_area_section: bool) -> bool:
+ return len(coordinates) >= minimum_knob_count(is_area_section)
+
+
+def close_area(coordinates: list[Point], is_area_section: bool) -> list[Point]:
+ """Append the first coordinate to an area so that it forms a closed polygon."""
+ if not is_area_section or not coordinates:
+ return list(coordinates)
+ if len(coordinates) > 1 and coordinates[0] == coordinates[-1]:
+ return list(coordinates)
+ return list(coordinates) + [coordinates[0]]
+
+
+def open_area(coordinates: list[Point], is_area_section: bool) -> list[Point]:
+ """Remove the closing coordinate of an area so that every knob is unique.
+
+ The first and the last coordinate of a stored area are equal. To edit the area,
+ the closing coordinate has to be removed.
+ """
+ if is_area_section and len(coordinates) > 1 and coordinates[0] == coordinates[-1]:
+ return list(coordinates[:-1])
+ return list(coordinates)
+
+
+def _squared_distance(first: Point, second: Point) -> float:
+ return (first[0] - second[0]) ** 2 + (first[1] - second[1]) ** 2
+
+
+def closest_knob_index(
+ coordinates: list[Point], point: Point, radius: float
+) -> int | None:
+ """Index of the knob closest to the given point within the given radius."""
+ closest: int | None = None
+ closest_distance = radius**2
+ for index, knob in enumerate(coordinates):
+ distance = _squared_distance(knob, point)
+ if distance <= closest_distance:
+ closest = index
+ closest_distance = distance
+ return closest
+
+
+def _projection(start: Point, end: Point, point: Point) -> float:
+ """Relative position of the point projected onto the segment from start to end.
+
+ 0 means the projection is at start, 1 at end. Values outside [0, 1] lie
+ outside the segment.
+ """
+ dx = end[0] - start[0]
+ dy = end[1] - start[1]
+ squared_length = dx**2 + dy**2
+ if squared_length == 0:
+ return 0.0
+ algebraic_dot = (point[0] - start[0]) * dx + (point[1] - start[1]) * dy
+ return algebraic_dot / squared_length
+
+
+def _squared_distance_to_segment(start: Point, end: Point, point: Point) -> float:
+ position = min(max(_projection(start, end, point), 0.0), 1.0)
+ closest = (
+ start[0] + position * (end[0] - start[0]),
+ start[1] + position * (end[1] - start[1]),
+ )
+ return (closest[0] - point[0]) ** 2 + (closest[1] - point[1]) ** 2
+
+
+def _segments(coordinates: list[Point], is_area_section: bool) -> list[int]:
+ """Indices of the start knobs of all segments of a section."""
+ number_of_segments = len(coordinates) - 1
+ if is_area_section and len(coordinates) > 2:
+ number_of_segments = len(coordinates)
+ return list(range(max(number_of_segments, 0)))
+
+
+def insert_index(coordinates: list[Point], point: Point, is_area_section: bool) -> int:
+ """Index at which a new knob at the given point should be inserted.
+
+ The knob is inserted into the segment closest to the point. For line sections,
+ points beyond the first or the last knob extend the line at that end.
+ """
+ segments = _segments(coordinates, is_area_section)
+ if not segments:
+ return len(coordinates)
+ closest_segment = min(
+ segments,
+ key=lambda index: _squared_distance_to_segment(
+ coordinates[index], coordinates[(index + 1) % len(coordinates)], point
+ ),
+ )
+ if not is_area_section:
+ start = coordinates[closest_segment]
+ end = coordinates[closest_segment + 1]
+ projection = _projection(start, end, point)
+ if closest_segment == 0 and projection < 0:
+ return 0
+ if closest_segment == segments[-1] and projection > 1:
+ return len(coordinates)
+ return closest_segment + 1
+
+
+def insert_knob(
+ coordinates: list[Point], point: Point, is_area_section: bool
+) -> tuple[list[Point], int]:
+ """Insert a knob at the given point between the closest knobs."""
+ index = insert_index(coordinates, point, is_area_section)
+ new_coordinates = list(coordinates)
+ new_coordinates.insert(index, point)
+ return new_coordinates, index
+
+
+def _swap(coordinates: list[Point], index: int, other: int) -> list[Point]:
+ new_coordinates = list(coordinates)
+ new_coordinates[index], new_coordinates[other] = (
+ new_coordinates[other],
+ new_coordinates[index],
+ )
+ return new_coordinates
+
+
+def shift_knob_forward(
+ coordinates: list[Point], index: int, is_area_section: bool
+) -> tuple[list[Point], int]:
+ """Move the knob at index one position forward in the order of knobs.
+
+ The last knob of an area wraps around to the start. The last knob of a line
+ section cannot be moved forward.
+ """
+ if not 0 <= index < len(coordinates):
+ return list(coordinates), index
+ if index == len(coordinates) - 1:
+ if not is_area_section:
+ return list(coordinates), index
+ return _swap(coordinates, index, 0), 0
+ return _swap(coordinates, index, index + 1), index + 1
+
+
+def shift_knob_backward(
+ coordinates: list[Point], index: int, is_area_section: bool
+) -> tuple[list[Point], int]:
+ """Move the knob at index one position backward in the order of knobs.
+
+ The first knob of an area wraps around to the end. The first knob of a line
+ section cannot be moved backward.
+ """
+ if not 0 <= index < len(coordinates):
+ return list(coordinates), index
+ if index == 0:
+ if not is_area_section:
+ return list(coordinates), index
+ last = len(coordinates) - 1
+ return _swap(coordinates, index, last), last
+ return _swap(coordinates, index, index - 1), index - 1
+
+
+def delete_knob(
+ coordinates: list[Point], index: int, is_area_section: bool
+) -> list[Point]:
+ """Delete the knob at index unless the section would have too few knobs."""
+ if not 0 <= index < len(coordinates):
+ return list(coordinates)
+ if len(coordinates) <= minimum_knob_count(is_area_section):
+ return list(coordinates)
+ new_coordinates = list(coordinates)
+ del new_coordinates[index]
+ return new_coordinates
diff --git a/tests/acceptance/test_sections_and_flows.py b/tests/acceptance/test_sections_and_flows.py
index 132dd7f61..c355fb6fc 100644
--- a/tests/acceptance/test_sections_and_flows.py
+++ b/tests/acceptance/test_sections_and_flows.py
@@ -1,19 +1,30 @@
+import json
import time
from pathlib import Path
+from typing import Any
import pytest
-from playwright.sync_api import Page # type: ignore # noqa: E402
+from playwright.sync_api import Page, expect # type: ignore # noqa: E402
from OTAnalytics.application.resources.resource_manager import (
FlowAndSectionKeys,
ResourceManager,
+ SectionKeys,
)
from OTAnalytics.plugin_ui.nicegui_gui.dialogs.edit_flow_dialog import (
MARKER_NAME as MARKER_FLOW_NAME,
)
+from OTAnalytics.plugin_ui.nicegui_gui.dialogs.edit_section_dialog import (
+ MARKER_NAME as MARKER_SECTION_NAME,
+)
from OTAnalytics.plugin_ui.nicegui_gui.nicegui.elements.dialog import (
MARKER_APPLY as MARKER_DIALOG_APPLY,
)
+from OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form import (
+ EDIT_COLOR,
+ EDIT_KNOB_ID_PREFIX,
+ MARKER_INTERACTIVE_IMAGE,
+)
from OTAnalytics.plugin_ui.nicegui_gui.pages.sections_and_flow_form.container import (
MARKER_TAB_SECTION,
)
@@ -23,6 +34,9 @@
MARKER_BUTTON_REMOVE,
MARKER_FLOW_TABLE,
)
+from OTAnalytics.plugin_ui.nicegui_gui.pages.sections_and_flow_form.sections_form import ( # noqa
+ MARKER_SECTION_TABLE,
+)
from tests.acceptance.conftest import (
ACCEPTANCE_TEST_FINAL_TIMEOUT_MS,
ACCEPTANCE_TEST_PYTEST_TIMEOUT,
@@ -219,3 +233,178 @@ def test_remove_flow_and_generate_flows_and_save_project(
assert saved_path.exists(), f"Expected saved configuration at {saved_path}"
reference_path = Path(__file__).parents[1] / "data" / "test_name.otconfig"
compare_json_files(saved_path, reference_path)
+
+
+SECTION_EDITOR_SETTLE_MS = 300
+NORTH_SECTION = "North-Section"
+
+
+def _canvas_image(page: Page) -> Any:
+ return (
+ search_for_marker_element(page, MARKER_INTERACTIVE_IMAGE).locator("img").first
+ )
+
+
+def _to_page_position(page: Page, x: float, y: float) -> tuple[float, float]:
+ """Convert image coordinates into page coordinates of the mouse."""
+ image = _canvas_image(page)
+ image.scroll_into_view_if_needed()
+ box = image.bounding_box()
+ assert box is not None, "Canvas image is not visible"
+ scale = image.evaluate("element => element.clientWidth / element.naturalWidth")
+ return box["x"] + x * scale, box["y"] + y * scale
+
+
+def _edit_knobs(page: Page) -> Any:
+ return search_for_marker_element(page, MARKER_INTERACTIVE_IMAGE).locator(
+ f"svg circle[id^='{EDIT_KNOB_ID_PREFIX}']"
+ )
+
+
+def _move_mouse(page: Page, x: float, y: float) -> None:
+ page.mouse.move(*_to_page_position(page, x, y), steps=3)
+ page.wait_for_timeout(SECTION_EDITOR_SETTLE_MS)
+
+
+def _click(page: Page, x: float, y: float, button: str = "left") -> None:
+ page.mouse.click(*_to_page_position(page, x, y), button=button) # type: ignore
+ page.wait_for_timeout(SECTION_EDITOR_SETTLE_MS)
+
+
+def _press(page: Page, key: str) -> None:
+ page.keyboard.press(key)
+ page.wait_for_timeout(SECTION_EDITOR_SETTLE_MS)
+
+
+def _open_sections_of_preconfigured_project(
+ page: Page, external_app: NiceGUITestServer, resource_manager: ResourceManager
+) -> None:
+ load_main_page(page, external_app)
+ otconfig_path = (
+ Path(__file__).parents[1] / "data" / "sections_created_test_file.otconfig"
+ )
+ setup_with_preconfigured_otconfig(page, resource_manager, otconfig_path)
+ search_for_marker_element(page, MARKER_TAB_SECTION).first.click()
+ _dismiss_notifications(page)
+
+
+def _dismiss_notifications(page: Page) -> None:
+ """Close notifications of loading the project, they may cover the canvas."""
+ notifications = page.locator(".q-notification")
+ deadline = time.time() + ACCEPTANCE_TEST_WAIT_TIMEOUT
+ while notifications.count() and time.time() < deadline:
+ close_buttons = notifications.locator("button")
+ if close_buttons.count():
+ # Notifications keep moving while others disappear, so no regular click
+ close_buttons.first.dispatch_event("click")
+ page.wait_for_timeout(PLAYWRIGHT_POLL_INTERVAL_SLOW_MS)
+ expect(notifications).to_have_count(0)
+
+
+def _edit_geometry_of(
+ page: Page, resource_manager: ResourceManager, section_name: str
+) -> None:
+ table = search_for_marker_element(page, MARKER_SECTION_TABLE).first
+ table.locator("tbody tr").filter(has_text=section_name).first.click()
+ page.get_by_text(resource_manager.get(SectionKeys.BUTTON_EDIT), exact=True).click()
+ expect(_edit_knobs(page)).not_to_have_count(0)
+
+
+def _saved_section_coordinates(
+ page: Page, resource_manager: ResourceManager, path: Path, section_name: str
+) -> list[tuple[int, int]]:
+ save_project_as(page, resource_manager, path)
+ deadline = time.time() + ACCEPTANCE_TEST_WAIT_TIMEOUT
+ while not path.exists() and time.time() < deadline:
+ time.sleep(PLAYWRIGHT_POLL_INTERVAL_SLOW_MS / 1000)
+ assert path.exists(), f"Expected saved configuration at {path}"
+ sections = json.loads(path.read_text())["sections"]
+ section = next(s for s in sections if s["name"] == section_name)
+ return [(c["x"], c["y"]) for c in section["coordinates"]]
+
+
+class TestSectionEditorOnCanvas:
+
+ @pytest.mark.timeout(ACCEPTANCE_TEST_PYTEST_TIMEOUT)
+ @pytest.mark.playwright
+ @pytest.mark.usefixtures("external_app")
+ def test_draw_line_section_with_preview_and_right_click(
+ self,
+ page: Page,
+ external_app: NiceGUITestServer,
+ resource_manager: ResourceManager,
+ ) -> None:
+ """Draw a line section: preview follows the mouse, right click finishes."""
+ _open_sections_of_preconfigured_project(page, external_app, resource_manager)
+ page.get_by_text(
+ resource_manager.get(SectionKeys.BUTTON_ADD_LINE), exact=True
+ ).click()
+
+ _click(page, 40, 200)
+ _move_mouse(page, 200, 260)
+
+ preview = search_for_marker_element(page, MARKER_INTERACTIVE_IMAGE).locator(
+ f"svg polyline[stroke='{EDIT_COLOR}']"
+ )
+ expect(preview).to_have_count(1)
+
+ _click(page, 200, 260)
+ _click(page, 200, 260, button="right")
+
+ name_input = search_for_marker_element(page, MARKER_SECTION_NAME).first
+ name_input.wait_for(state="visible")
+ name_input.fill("Drawn-Section")
+ search_for_marker_element(page, MARKER_DIALOG_APPLY).first.click()
+
+ table = search_for_marker_element(page, MARKER_SECTION_TABLE).first
+ expect(table).to_contain_text("Drawn-Section")
+
+ @pytest.mark.timeout(ACCEPTANCE_TEST_PYTEST_TIMEOUT)
+ @pytest.mark.playwright
+ @pytest.mark.usefixtures("external_app")
+ def test_insert_and_delete_knob_of_section(
+ self,
+ page: Page,
+ external_app: NiceGUITestServer,
+ resource_manager: ResourceManager,
+ test_data_tmp_dir: Path,
+ ) -> None:
+ """Insert a knob with '+' and delete it again with 'Delete'."""
+ _open_sections_of_preconfigured_project(page, external_app, resource_manager)
+
+ knobs = _edit_knobs(page)
+
+ _edit_geometry_of(page, resource_manager, NORTH_SECTION)
+ expect(knobs).to_have_count(2)
+ _move_mouse(page, 88, 60)
+ _press(page, "+")
+ expect(knobs).to_have_count(3)
+ _click(page, 88, 60)
+ _press(page, "Enter")
+ expect(knobs).to_have_count(0)
+
+ coordinates = _saved_section_coordinates(
+ page,
+ resource_manager,
+ test_data_tmp_dir / "inserted.otconfig",
+ NORTH_SECTION,
+ )
+ assert len(coordinates) == 3
+ assert coordinates[0] == (22, 22)
+ assert coordinates[2] == (154, 66)
+
+ _edit_geometry_of(page, resource_manager, NORTH_SECTION)
+ expect(knobs).to_have_count(3)
+ _click(page, *coordinates[1])
+ _press(page, "Delete")
+ expect(knobs).to_have_count(2)
+ _press(page, "Enter")
+ expect(knobs).to_have_count(0)
+
+ coordinates = _saved_section_coordinates(
+ page,
+ resource_manager,
+ test_data_tmp_dir / "deleted.otconfig",
+ NORTH_SECTION,
+ )
+ assert coordinates == [(22, 22), (154, 66)]
diff --git a/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/test_arrow.py b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/test_arrow.py
new file mode 100644
index 000000000..a761b2aaa
--- /dev/null
+++ b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/test_arrow.py
@@ -0,0 +1,43 @@
+from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.arrow import Arrow
+
+
+class TestArrow:
+ def test_arrow_to_svg(self) -> None:
+ arrow = Arrow(
+ id="arrow", x1=0, y1=0, x2=100, y2=0, color="red", width=4, dash=(5, 5)
+ )
+
+ svg = arrow.to_svg()
+
+ assert 'id="arrow"' in svg
+ assert ' None:
+ arrow = Arrow(
+ id="arrow",
+ x1=0,
+ y1=0,
+ x2=100,
+ y2=0,
+ color="red",
+ head_length=20,
+ head_half_width=8,
+ )
+
+ assert arrow.head_points() == [(100.0, 0.0), (80.0, 8.0), (80.0, -8.0)]
+
+ def test_arrow_without_dash(self) -> None:
+ arrow = Arrow(id="arrow", x1=0, y1=0, x2=100, y2=0, color="red")
+
+ assert "stroke-dasharray" not in arrow.to_svg()
+
+ def test_arrow_without_length_has_no_head(self) -> None:
+ arrow = Arrow(id="arrow", x1=10, y1=10, x2=10, y2=10, color="red")
+
+ assert arrow.head_points() == []
+ assert " None:
+ polygon = Polygon(
+ id="area", points=[(0, 0), (10, 0), (10, 10)], color="red", fill_opacity=0.3
+ )
+
+ svg = polygon.to_svg()
+
+ assert " None:
+ polygon = Polygon(id="area", points=[(0, 0), (10, 0), (10, 10)], color="red")
+
+ assert 0 < polygon.fill_opacity < 1
diff --git a/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/test_text.py b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/test_text.py
new file mode 100644
index 000000000..cd34819b1
--- /dev/null
+++ b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/nicegui/svg/test_text.py
@@ -0,0 +1,23 @@
+from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.text import Text
+
+
+class TestText:
+ def test_text_to_svg(self) -> None:
+ text = Text(id="section-text", x=10, y=20, text="North", color="green")
+
+ svg = text.to_svg()
+
+ assert "North" in svg
+
+ def test_text_is_escaped(self) -> None:
+ text = Text(id="section-text", x=10, y=20, text="&", color="green")
+
+ svg = text.to_svg()
+
+ assert "<b>&</b>" in svg
+ assert "" not in svg
diff --git a/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_canvas_form.py b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_canvas_form.py
index 86de904af..0d83eb9f3 100644
--- a/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_canvas_form.py
+++ b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_canvas_form.py
@@ -1,4 +1,4 @@
-from typing import Any
+from typing import Any, Iterator
from unittest.mock import MagicMock, Mock, patch
import pytest
@@ -9,28 +9,39 @@
from OTAnalytics.adapter_ui.view_model import ViewModel
from OTAnalytics.application.resources.resource_manager import (
+ ADD_KNOB_HOTKEY_VALUE,
CANCEL_SECTION_GEOMETRY_HOTKEY_VALUE,
+ DEFAULT_HOTKEYS,
+ DELETE_KNOB_ALTERNATIVE_HOTKEY_VALUE,
+ DELETE_KNOB_HOTKEY_VALUE,
SAVE_SECTION_HOTKEY_VALUE,
+ SHIFT_KNOB_BACKWARD_HOTKEY_VALUE,
+ SHIFT_KNOB_FORWARD_HOTKEY_VALUE,
HotKeys,
ResourceManager,
)
+from OTAnalytics.domain.geometry import Coordinate
from OTAnalytics.domain.section import Section
from OTAnalytics.domain.track import TrackImage
from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.circle import Circle
from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.line import Line
+from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.polygon import Polygon
from OTAnalytics.plugin_ui.nicegui_gui.nicegui.svg.polyline import Polyline
from OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form import (
+ CLICK,
EDIT_COLOR,
+ EDIT_KNOB_ID_PREFIX,
ELEMENT_ID,
+ HOVERED_KNOB_COLOR,
IMAGE_X,
IMAGE_Y,
- MOVING_COLOR,
+ MOUSE_MOVE,
+ MOUSE_UP,
NORMAL_COLOR,
+ RELEASE_FOCUS,
SELECTED_COLOR,
CanvasForm,
circle_to_coordinates,
- create_circle,
- create_moving_circle,
)
@@ -73,6 +84,11 @@ def mock_section() -> Mock:
section.id = Mock()
section.id.id = "test-section-id"
section.to_dict.return_value = {"id": "test-section-id", "name": "Test Section"}
+ section.get_coordinates.return_value = [
+ Coordinate(10, 20),
+ Coordinate(110, 20),
+ Coordinate(210, 20),
+ ]
return section
@@ -109,7 +125,7 @@ def setup_canvas_form_with_mocks(
canvas_form.build()
if current_section is not None:
- canvas_form._current_section = current_section
+ canvas_form.start_section_geometry_editor(current_section)
return canvas_form
@@ -117,45 +133,6 @@ def setup_canvas_form_with_mocks(
class TestCanvasFormHelperFunctions:
"""Test helper functions used by CanvasForm."""
- def test_create_circle(self) -> None:
- # Arrange
- event_data = {IMAGE_X: 100.7, IMAGE_Y: 200.3, ELEMENT_ID: "test-element"}
-
- # Act
- circle = create_circle(event_data, fill="red")
-
- # Assert
- assert circle.x == 101 # rounded
- assert circle.y == 200 # rounded
- assert circle.id == "test-element"
- assert circle.fill == "red"
- assert circle.pointer_event == "all"
-
- def test_create_circle_with_default_fill(self) -> None:
- # Arrange
- event_data = {IMAGE_X: 50, IMAGE_Y: 75, ELEMENT_ID: "default-element"}
-
- # Act
- circle = create_circle(event_data)
-
- # Assert
- assert circle.fill == NORMAL_COLOR
-
- def test_create_moving_circle(self) -> None:
- # Arrange
- event_data = {IMAGE_X: 150.2, IMAGE_Y: 250.8, ELEMENT_ID: "moving-element"}
-
- # Act
- circle = create_moving_circle(event_data, fill="blue")
-
- # Assert
- assert circle.x == 150 # rounded
- assert circle.y == 251 # rounded
- assert circle.id == "moving-element"
- assert circle.fill == "blue"
- assert circle.stroke == MOVING_COLOR
- assert circle.pointer_event == "all"
-
def test_circle_to_coordinates(self) -> None:
# Arrange
circles = [
@@ -461,84 +438,6 @@ def test_on_pointer_down_multiple_points(
assert canvas_form._new_section_points[1].x == 150
assert canvas_form._new_section_points[2].x == 200
- @patch(
- "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui"
- )
- def test_on_svg_pointer_down_edit_mode(
- self,
- mock_ui: Any,
- mock_viewmodel: Mock,
- mock_resource_manager: Mock,
- mock_section: Mock,
- ) -> None:
- # Arrange
- canvas_form = setup_canvas_form_with_mocks(
- mock_ui, mock_viewmodel, mock_resource_manager, current_section=mock_section
- )
-
- event_data = {IMAGE_X: 100, IMAGE_Y: 200, ELEMENT_ID: "test-element"}
-
- # Act
- canvas_form.on_svg_pointer_down(event_data)
-
- # Assert
- assert canvas_form._current_point is not None
- assert canvas_form._current_point.x == 100
- assert canvas_form._current_point.y == 200
- assert canvas_form._current_point.fill == EDIT_COLOR
-
- @patch(
- "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui"
- )
- def test_on_svg_pointer_move_edit_mode(
- self,
- mock_ui: Any,
- mock_viewmodel: Mock,
- mock_resource_manager: Mock,
- mock_section: Mock,
- ) -> None:
- # Arrange
- canvas_form = setup_canvas_form_with_mocks(
- mock_ui, mock_viewmodel, mock_resource_manager, current_section=mock_section
- )
- canvas_form._current_point = MagicMock()
-
- event_data = {IMAGE_X: 150, IMAGE_Y: 250, ELEMENT_ID: "test-element"}
-
- # Act
- canvas_form.on_svg_pointer_move(event_data)
-
- # Assert
- assert canvas_form._current_point.x == 150
- assert canvas_form._current_point.y == 250
-
- @patch(
- "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui"
- )
- def test_on_svg_pointer_up_edit_mode(
- self,
- mock_ui: Any,
- mock_viewmodel: Mock,
- mock_resource_manager: Mock,
- mock_section: Mock,
- ) -> None:
- # Arrange
- canvas_form = setup_canvas_form_with_mocks(
- mock_ui, mock_viewmodel, mock_resource_manager, current_section=mock_section
- )
- canvas_form._current_point = MagicMock()
-
- event_data = {IMAGE_X: 175, IMAGE_Y: 275, ELEMENT_ID: "test-element"}
-
- # Act
- canvas_form.on_svg_pointer_up(event_data)
-
- # Assert
- assert canvas_form._current_point is None
- # Verify circle was added to the section
- circles = canvas_form._circles.by_section.get(mock_section.id.id, {})
- assert len(circles) > 0
-
class TestCanvasFormKeyboardEvents:
"""Test keyboard event handling in CanvasForm."""
@@ -688,6 +587,7 @@ def hotkey_side_effect(key: Any) -> str:
mock_resource_manager,
hotkey_side_effect=hotkey_side_effect,
)
+ canvas_form.start_section_builder()
key_event = MagicMock(spec=KeyEventArguments)
key_event.key = CANCEL_SECTION_GEOMETRY_HOTKEY_VALUE
@@ -1105,3 +1005,742 @@ def test_delete_element(
assert len(canvas_form._sections.lines) == 0
# CircleResources.clear() only clears the circles dict, not by_section
assert len(canvas_form._circles.circles) == 0
+
+
+def create_mouse_event(event_type: str, x: float, y: float) -> MagicMock:
+ event = MagicMock(spec=events.MouseEventArguments)
+ event.type = event_type
+ event.image_x = x
+ event.image_y = y
+ return event
+
+
+def create_key_event(key: str, keydown: bool = True) -> MagicMock:
+ event = MagicMock(spec=KeyEventArguments)
+ event.key = key
+ event.action = MagicMock()
+ event.action.keydown = keydown
+ return event
+
+
+def hotkeys_by_default(key: HotKeys) -> str | None:
+ return DEFAULT_HOTKEYS.get(key)
+
+
+class TestCanvasFormSectionBuilder:
+ """Drawing a new section on the canvas (reference: SectionBuilder)."""
+
+ @patch(
+ "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui"
+ )
+ def test_start_section_builder_subscribes_mouse_move(
+ self, mock_ui: Any, mock_viewmodel: Mock, mock_resource_manager: Mock
+ ) -> None:
+ canvas_form = setup_canvas_form_with_mocks(
+ mock_ui, mock_viewmodel, mock_resource_manager
+ )
+
+ canvas_form.start_section_builder()
+
+ background_image = mock_ui.interactive_image.return_value
+ background_image._props.__setitem__.assert_called_with(
+ "events", [CLICK, MOUSE_MOVE, MOUSE_UP]
+ )
+
+ @patch(
+ "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui"
+ )
+ def test_start_section_builder_releases_focus_for_hotkeys(
+ self, mock_ui: Any, mock_viewmodel: Mock, mock_resource_manager: Mock
+ ) -> None:
+ canvas_form = setup_canvas_form_with_mocks(
+ mock_ui, mock_viewmodel, mock_resource_manager
+ )
+
+ canvas_form.start_section_builder()
+
+ background_image = mock_ui.interactive_image.return_value
+ background_image.client.run_javascript.assert_called_once_with(RELEASE_FOCUS)
+
+ @patch(
+ "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui"
+ )
+ def test_mouse_move_draws_preview_line_from_last_point(
+ self, mock_ui: Any, mock_viewmodel: Mock, mock_resource_manager: Mock
+ ) -> None:
+ canvas_form = setup_canvas_form_with_mocks(
+ mock_ui, mock_viewmodel, mock_resource_manager
+ )
+ canvas_form.start_section_builder()
+ canvas_form.on_mouse(create_mouse_event(CLICK, 10, 20))
+
+ canvas_form.on_mouse(create_mouse_event(MOUSE_MOVE, 50.4, 60.6))
+
+ assert canvas_form._preview_point == (50, 61)
+ assert len(canvas_form._new_section_points) == 1
+ content = mock_ui.interactive_image.return_value.content
+ assert 'points="10,20 50,61"' in content
+
+ @patch(
+ "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui"
+ )
+ def test_mouse_move_without_builder_does_nothing(
+ self, mock_ui: Any, mock_viewmodel: Mock, mock_resource_manager: Mock
+ ) -> None:
+ canvas_form = setup_canvas_form_with_mocks(
+ mock_ui, mock_viewmodel, mock_resource_manager
+ )
+
+ canvas_form.on_mouse(create_mouse_event(MOUSE_MOVE, 50, 60))
+
+ assert canvas_form._preview_point is None
+
+ @patch(
+ "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui"
+ )
+ def test_click_adds_points(
+ self, mock_ui: Any, mock_viewmodel: Mock, mock_resource_manager: Mock
+ ) -> None:
+ canvas_form = setup_canvas_form_with_mocks(
+ mock_ui, mock_viewmodel, mock_resource_manager
+ )
+ canvas_form.start_section_builder()
+
+ canvas_form.on_mouse(create_mouse_event(CLICK, 10, 20))
+ canvas_form.on_mouse(create_mouse_event(CLICK, 30, 40))
+
+ assert circle_to_coordinates(canvas_form._new_section_points) == [
+ (10, 20),
+ (30, 40),
+ ]
+
+ @patch(
+ "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui"
+ )
+ @pytest.mark.asyncio
+ async def test_right_click_finishes_new_section(
+ self, mock_ui: Any, mock_viewmodel: Mock, mock_resource_manager: Mock
+ ) -> None:
+ canvas_form = setup_canvas_form_with_mocks(
+ mock_ui, mock_viewmodel, mock_resource_manager
+ )
+ canvas_form.start_section_builder()
+ canvas_form.on_mouse(create_mouse_event(CLICK, 10, 20))
+ canvas_form.on_mouse(create_mouse_event(CLICK, 30, 40))
+
+ await canvas_form.on_right_click()
+
+ mock_viewmodel.add_new_section.assert_called_once()
+ call_args = mock_viewmodel.add_new_section.call_args
+ assert call_args[1]["coordinates"] == [(10, 20), (30, 40)]
+ assert canvas_form._new_section is False
+
+ @patch(
+ "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui"
+ )
+ @pytest.mark.asyncio
+ async def test_right_click_without_editor_does_nothing(
+ self, mock_ui: Any, mock_viewmodel: Mock, mock_resource_manager: Mock
+ ) -> None:
+ canvas_form = setup_canvas_form_with_mocks(
+ mock_ui, mock_viewmodel, mock_resource_manager
+ )
+
+ await canvas_form.on_right_click()
+
+ mock_viewmodel.add_new_section.assert_not_called()
+ mock_viewmodel.update_section_coordinates.assert_not_called()
+
+ @patch(
+ "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui"
+ )
+ @pytest.mark.asyncio
+ async def test_finish_line_section_with_one_point_is_ignored(
+ self, mock_ui: Any, mock_viewmodel: Mock, mock_resource_manager: Mock
+ ) -> None:
+ canvas_form = setup_canvas_form_with_mocks(
+ mock_ui,
+ mock_viewmodel,
+ mock_resource_manager,
+ hotkey_side_effect=hotkeys_by_default,
+ )
+ canvas_form.start_section_builder()
+ canvas_form.on_mouse(create_mouse_event(CLICK, 10, 20))
+
+ await canvas_form.handle_key(create_key_event(SAVE_SECTION_HOTKEY_VALUE))
+
+ mock_viewmodel.add_new_section.assert_not_called()
+ mock_ui.notify.assert_called_once()
+ assert canvas_form._new_section is True
+ assert len(canvas_form._new_section_points) == 1
+
+ @patch(
+ "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui"
+ )
+ @pytest.mark.asyncio
+ async def test_finish_area_section_with_two_points_is_ignored(
+ self, mock_ui: Any, mock_viewmodel: Mock, mock_resource_manager: Mock
+ ) -> None:
+ canvas_form = setup_canvas_form_with_mocks(
+ mock_ui, mock_viewmodel, mock_resource_manager
+ )
+ canvas_form.start_section_builder(is_area_section=True)
+ canvas_form.on_mouse(create_mouse_event(CLICK, 10, 20))
+ canvas_form.on_mouse(create_mouse_event(CLICK, 30, 40))
+
+ await canvas_form.on_right_click()
+
+ mock_viewmodel.add_new_section.assert_not_called()
+ mock_ui.notify.assert_called_once()
+ assert canvas_form._new_section is True
+
+ @patch(
+ "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui"
+ )
+ @pytest.mark.asyncio
+ async def test_finish_area_section_closes_area(
+ self, mock_ui: Any, mock_viewmodel: Mock, mock_resource_manager: Mock
+ ) -> None:
+ canvas_form = setup_canvas_form_with_mocks(
+ mock_ui, mock_viewmodel, mock_resource_manager
+ )
+ canvas_form.start_section_builder(is_area_section=True)
+ for x, y in [(10, 20), (30, 40), (50, 20)]:
+ canvas_form.on_mouse(create_mouse_event(CLICK, x, y))
+
+ await canvas_form.on_right_click()
+
+ mock_viewmodel.add_new_section.assert_called_once()
+ call_args = mock_viewmodel.add_new_section.call_args
+ assert call_args[1]["coordinates"] == [(10, 20), (30, 40), (50, 20), (10, 20)]
+ assert call_args[1]["is_area_section"] is True
+
+ @patch(
+ "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui"
+ )
+ @pytest.mark.asyncio
+ async def test_escape_aborts_new_section(
+ self, mock_ui: Any, mock_viewmodel: Mock, mock_resource_manager: Mock
+ ) -> None:
+ canvas_form = setup_canvas_form_with_mocks(
+ mock_ui,
+ mock_viewmodel,
+ mock_resource_manager,
+ hotkey_side_effect=hotkeys_by_default,
+ )
+ canvas_form.start_section_builder()
+ canvas_form.on_mouse(create_mouse_event(CLICK, 10, 20))
+
+ await canvas_form.handle_key(
+ create_key_event(CANCEL_SECTION_GEOMETRY_HOTKEY_VALUE)
+ )
+
+ mock_viewmodel.cancel_action.assert_called_once()
+ assert canvas_form._new_section is False
+ assert canvas_form._new_section_points == []
+ assert canvas_form._preview_point is None
+
+ @patch(
+ "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui"
+ )
+ @pytest.mark.asyncio
+ async def test_keys_are_ignored_without_active_editor(
+ self, mock_ui: Any, mock_viewmodel: Mock, mock_resource_manager: Mock
+ ) -> None:
+ canvas_form = setup_canvas_form_with_mocks(
+ mock_ui,
+ mock_viewmodel,
+ mock_resource_manager,
+ hotkey_side_effect=hotkeys_by_default,
+ )
+
+ await canvas_form.handle_key(
+ create_key_event(CANCEL_SECTION_GEOMETRY_HOTKEY_VALUE)
+ )
+ await canvas_form.handle_key(create_key_event(SAVE_SECTION_HOTKEY_VALUE))
+
+ mock_viewmodel.cancel_action.assert_not_called()
+ mock_viewmodel.add_new_section.assert_not_called()
+ mock_viewmodel.update_section_coordinates.assert_not_called()
+
+ @patch(
+ "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui"
+ )
+ @pytest.mark.asyncio
+ async def test_key_up_is_ignored(
+ self, mock_ui: Any, mock_viewmodel: Mock, mock_resource_manager: Mock
+ ) -> None:
+ canvas_form = setup_canvas_form_with_mocks(
+ mock_ui,
+ mock_viewmodel,
+ mock_resource_manager,
+ hotkey_side_effect=hotkeys_by_default,
+ )
+ canvas_form.start_section_builder()
+ canvas_form.on_mouse(create_mouse_event(CLICK, 10, 20))
+ canvas_form.on_mouse(create_mouse_event(CLICK, 30, 40))
+
+ await canvas_form.handle_key(
+ create_key_event(SAVE_SECTION_HOTKEY_VALUE, keydown=False)
+ )
+
+ mock_viewmodel.add_new_section.assert_not_called()
+ assert canvas_form._new_section is True
+
+
+def svg_event(x: float, y: float, element_id: str = "") -> dict:
+ return {IMAGE_X: x, IMAGE_Y: y, ELEMENT_ID: element_id}
+
+
+def knob_id(index: int) -> str:
+ return f"{EDIT_KNOB_ID_PREFIX}{index}"
+
+
+ORIGINAL_COORDINATES = [(10, 20), (110, 20), (210, 20)]
+CANVAS_UI = (
+ "OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.canvas_form.ui"
+)
+
+
+class TestCanvasFormSectionGeometryEditor:
+ """Editing an existing section (reference: SectionGeometryEditor)."""
+
+ @pytest.fixture
+ def canvas_form(
+ self, mock_viewmodel: Mock, mock_resource_manager: Mock, mock_section: Mock
+ ) -> Iterator[Any]:
+ with patch(CANVAS_UI) as mock_ui:
+ canvas_form = setup_canvas_form_with_mocks(
+ mock_ui,
+ mock_viewmodel,
+ mock_resource_manager,
+ hotkey_side_effect=hotkeys_by_default,
+ current_section=mock_section,
+ )
+ canvas_form.mock_ui = mock_ui # type: ignore[attr-defined]
+ yield canvas_form
+
+ @pytest.fixture
+ def area_section(self, mock_section: Mock) -> Mock:
+ mock_section.get_coordinates.return_value = [
+ Coordinate(0, 0),
+ Coordinate(10, 0),
+ Coordinate(10, 10),
+ Coordinate(0, 0),
+ ]
+ return mock_section
+
+ def test_start_editor_releases_focus_for_hotkeys(self, canvas_form: Any) -> None:
+ background_image = canvas_form.mock_ui.interactive_image.return_value
+ background_image.client.run_javascript.assert_called_with(RELEASE_FOCUS)
+
+ def test_start_editor_loads_coordinates(self, canvas_form: Any) -> None:
+ assert canvas_form._edit_coordinates == ORIGINAL_COORDINATES
+ assert canvas_form._edit_temporary == ORIGINAL_COORDINATES
+ assert canvas_form._selected_knob_index is None
+
+ def test_start_editor_for_area_drops_closing_coordinate(
+ self, mock_viewmodel: Mock, mock_resource_manager: Mock, area_section: Mock
+ ) -> None:
+ with patch(CANVAS_UI) as mock_ui:
+ canvas_form = setup_canvas_form_with_mocks(
+ mock_ui, mock_viewmodel, mock_resource_manager
+ )
+ canvas_form.start_section_geometry_editor(
+ area_section, is_area_section=True
+ )
+
+ assert canvas_form._edit_coordinates == [(0, 0), (10, 0), (10, 10)]
+
+ def test_start_editor_subscribes_mouse_move(self, canvas_form: Any) -> None:
+ background_image = canvas_form.mock_ui.interactive_image.return_value
+ background_image._props.__setitem__.assert_called_with(
+ "events", [CLICK, MOUSE_MOVE, MOUSE_UP]
+ )
+
+ def test_hover_highlights_knob_under_mouse(self, canvas_form: Any) -> None:
+ canvas_form.on_svg_pointer_move(svg_event(112, 22))
+
+ assert canvas_form._hovered_knob_index == 1
+ content = canvas_form.mock_ui.interactive_image.return_value.content
+ assert HOVERED_KNOB_COLOR in content
+
+ def test_hover_far_from_knob_highlights_nothing(self, canvas_form: Any) -> None:
+ canvas_form.on_mouse(create_mouse_event(MOUSE_MOVE, 60, 100))
+
+ assert canvas_form._hovered_knob_index is None
+
+ def test_drag_knob_moves_it(self, canvas_form: Any) -> None:
+ canvas_form.on_svg_pointer_down(svg_event(110, 20, knob_id(1)))
+ assert canvas_form._selected_knob_index == 1
+
+ canvas_form.on_svg_pointer_move(svg_event(120, 50, knob_id(1)))
+ assert canvas_form._edit_temporary[1] == (120, 50)
+ assert canvas_form._edit_coordinates == ORIGINAL_COORDINATES
+
+ canvas_form.on_svg_pointer_up(svg_event(121, 51, knob_id(1)))
+
+ assert canvas_form._edit_coordinates == [(10, 20), (121, 51), (210, 20)]
+ assert canvas_form._selected_knob_index is None
+
+ def test_drag_released_outside_of_svg_moves_knob(self, canvas_form: Any) -> None:
+ canvas_form.on_svg_pointer_down(svg_event(10, 20, knob_id(0)))
+ canvas_form.on_mouse(create_mouse_event(MOUSE_MOVE, 30, 70))
+
+ canvas_form.on_mouse(create_mouse_event(MOUSE_UP, 31, 71))
+
+ assert canvas_form._edit_coordinates == [(31, 71), (110, 20), (210, 20)]
+ assert canvas_form._selected_knob_index is None
+
+ def test_click_selects_knob_and_second_click_places_it(
+ self, canvas_form: Any
+ ) -> None:
+ canvas_form.on_svg_pointer_down(svg_event(210, 20, knob_id(2)))
+ canvas_form.on_svg_pointer_up(svg_event(210, 20, knob_id(2)))
+ assert canvas_form._selected_knob_index == 2
+
+ canvas_form.on_svg_pointer_move(svg_event(250, 80, knob_id(2)))
+ canvas_form.on_svg_pointer_down(svg_event(250, 80, knob_id(2)))
+ canvas_form.on_svg_pointer_up(svg_event(250, 80, knob_id(2)))
+
+ assert canvas_form._edit_coordinates == [(10, 20), (110, 20), (250, 80)]
+ assert canvas_form._selected_knob_index is None
+
+ def test_click_on_image_places_selected_knob(self, canvas_form: Any) -> None:
+ canvas_form.on_svg_pointer_down(svg_event(210, 20, knob_id(2)))
+ canvas_form.on_svg_pointer_up(svg_event(210, 20, knob_id(2)))
+
+ canvas_form.on_mouse(create_mouse_event(CLICK, 230, 90))
+
+ assert canvas_form._edit_coordinates == [(10, 20), (110, 20), (230, 90)]
+ assert canvas_form._selected_knob_index is None
+
+ def test_pointer_down_next_to_knob_selects_closest_knob(
+ self, canvas_form: Any
+ ) -> None:
+ canvas_form.on_svg_pointer_down(svg_event(113, 18, "other-element"))
+
+ assert canvas_form._selected_knob_index == 1
+
+ def test_pointer_down_far_from_knobs_selects_nothing(
+ self, canvas_form: Any
+ ) -> None:
+ canvas_form.on_svg_pointer_down(svg_event(60, 100, "other-element"))
+
+ assert canvas_form._selected_knob_index is None
+
+ @pytest.mark.asyncio
+ async def test_plus_inserts_knob_at_mouse_position(self, canvas_form: Any) -> None:
+ canvas_form.on_mouse(create_mouse_event(MOUSE_MOVE, 60, 30))
+
+ await canvas_form.handle_key(create_key_event(ADD_KNOB_HOTKEY_VALUE))
+
+ assert canvas_form._edit_temporary == [
+ (10, 20),
+ (60, 30),
+ (110, 20),
+ (210, 20),
+ ]
+ assert canvas_form._selected_knob_index == 1
+
+ @pytest.mark.asyncio
+ async def test_inserted_knob_is_placed_with_click(self, canvas_form: Any) -> None:
+ canvas_form.on_mouse(create_mouse_event(MOUSE_MOVE, 60, 30))
+ await canvas_form.handle_key(create_key_event(ADD_KNOB_HOTKEY_VALUE))
+
+ canvas_form.on_svg_pointer_move(svg_event(65, 35, knob_id(1)))
+ canvas_form.on_svg_pointer_down(svg_event(65, 35, knob_id(1)))
+ canvas_form.on_svg_pointer_up(svg_event(65, 35, knob_id(1)))
+
+ assert canvas_form._edit_coordinates == [
+ (10, 20),
+ (65, 35),
+ (110, 20),
+ (210, 20),
+ ]
+ assert canvas_form._selected_knob_index is None
+
+ @pytest.mark.asyncio
+ async def test_escape_after_insert_removes_new_knob(
+ self, canvas_form: Any, mock_viewmodel: Mock
+ ) -> None:
+ canvas_form.on_mouse(create_mouse_event(MOUSE_MOVE, 60, 30))
+ await canvas_form.handle_key(create_key_event(ADD_KNOB_HOTKEY_VALUE))
+
+ await canvas_form.handle_key(
+ create_key_event(CANCEL_SECTION_GEOMETRY_HOTKEY_VALUE)
+ )
+
+ assert canvas_form._edit_temporary == ORIGINAL_COORDINATES
+ assert canvas_form._selected_knob_index is None
+ assert canvas_form._current_section is not None
+ mock_viewmodel.cancel_action.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_escape_without_selected_knob_cancels(
+ self, canvas_form: Any, mock_viewmodel: Mock
+ ) -> None:
+ await canvas_form.handle_key(
+ create_key_event(CANCEL_SECTION_GEOMETRY_HOTKEY_VALUE)
+ )
+
+ mock_viewmodel.cancel_action.assert_called_once()
+ mock_viewmodel.update_section_coordinates.assert_not_called()
+ assert canvas_form._current_section is None
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize(
+ "key", [DELETE_KNOB_HOTKEY_VALUE, DELETE_KNOB_ALTERNATIVE_HOTKEY_VALUE]
+ )
+ async def test_delete_removes_selected_knob(
+ self, canvas_form: Any, key: str
+ ) -> None:
+ canvas_form.on_svg_pointer_down(svg_event(110, 20, knob_id(1)))
+ canvas_form.on_svg_pointer_up(svg_event(110, 20, knob_id(1)))
+
+ await canvas_form.handle_key(create_key_event(key))
+
+ assert canvas_form._edit_coordinates == [(10, 20), (210, 20)]
+ assert canvas_form._selected_knob_index is None
+
+ @pytest.mark.asyncio
+ async def test_delete_keeps_minimum_number_of_knobs(self, canvas_form: Any) -> None:
+ canvas_form.on_svg_pointer_down(svg_event(110, 20, knob_id(1)))
+ canvas_form.on_svg_pointer_up(svg_event(110, 20, knob_id(1)))
+ await canvas_form.handle_key(create_key_event(DELETE_KNOB_HOTKEY_VALUE))
+ canvas_form.on_svg_pointer_down(svg_event(10, 20, knob_id(0)))
+ canvas_form.on_svg_pointer_up(svg_event(10, 20, knob_id(0)))
+
+ await canvas_form.handle_key(create_key_event(DELETE_KNOB_HOTKEY_VALUE))
+
+ assert canvas_form._edit_coordinates == [(10, 20), (210, 20)]
+
+ @pytest.mark.asyncio
+ async def test_arrow_keys_shift_selected_knob(self, canvas_form: Any) -> None:
+ canvas_form.on_svg_pointer_down(svg_event(10, 20, knob_id(0)))
+ canvas_form.on_svg_pointer_up(svg_event(10, 20, knob_id(0)))
+
+ await canvas_form.handle_key(create_key_event(SHIFT_KNOB_FORWARD_HOTKEY_VALUE))
+
+ assert canvas_form._edit_temporary == [(110, 20), (10, 20), (210, 20)]
+ assert canvas_form._selected_knob_index == 1
+
+ await canvas_form.handle_key(create_key_event(SHIFT_KNOB_BACKWARD_HOTKEY_VALUE))
+
+ assert canvas_form._edit_temporary == ORIGINAL_COORDINATES
+ assert canvas_form._selected_knob_index == 0
+
+ @pytest.mark.asyncio
+ async def test_enter_saves_edited_coordinates(
+ self, canvas_form: Any, mock_viewmodel: Mock, mock_section: Mock
+ ) -> None:
+ canvas_form.on_svg_pointer_down(svg_event(110, 20, knob_id(1)))
+ canvas_form.on_svg_pointer_move(svg_event(120, 50, knob_id(1)))
+ canvas_form.on_svg_pointer_up(svg_event(120, 50, knob_id(1)))
+
+ await canvas_form.handle_key(create_key_event(SAVE_SECTION_HOTKEY_VALUE))
+
+ mock_viewmodel.update_section_coordinates.assert_called_once_with(
+ mock_section.to_dict.return_value, [(10, 20), (120, 50), (210, 20)]
+ )
+ assert canvas_form._current_section is None
+
+ @pytest.mark.asyncio
+ async def test_right_click_saves_edited_coordinates(
+ self, canvas_form: Any, mock_viewmodel: Mock, mock_section: Mock
+ ) -> None:
+ await canvas_form.on_right_click()
+
+ mock_viewmodel.update_section_coordinates.assert_called_once_with(
+ mock_section.to_dict.return_value, ORIGINAL_COORDINATES
+ )
+
+ @pytest.mark.asyncio
+ async def test_save_area_closes_area(
+ self, mock_viewmodel: Mock, mock_resource_manager: Mock, area_section: Mock
+ ) -> None:
+ with patch(CANVAS_UI) as mock_ui:
+ canvas_form = setup_canvas_form_with_mocks(
+ mock_ui, mock_viewmodel, mock_resource_manager
+ )
+ canvas_form.start_section_geometry_editor(
+ area_section, is_area_section=True
+ )
+
+ await canvas_form.on_right_click()
+
+ mock_viewmodel.update_section_coordinates.assert_called_once_with(
+ area_section.to_dict.return_value, [(0, 0), (10, 0), (10, 10), (0, 0)]
+ )
+
+ def test_edit_knobs_are_drawn(self, canvas_form: Any) -> None:
+ content = canvas_form.mock_ui.interactive_image.return_value.content
+
+ for index in range(len(ORIGINAL_COORDINATES)):
+ assert f'id="{knob_id(index)}"' in content
+ assert EDIT_COLOR in content
+
+
+class TestCanvasFormPresentation:
+ """Presentation of sections and flows (reference: SectionPainter, ArrowPainter)."""
+
+ @pytest.fixture
+ def canvas_form(
+ self, mock_viewmodel: Mock, mock_resource_manager: Mock
+ ) -> Iterator[Any]:
+ with patch(CANVAS_UI) as mock_ui:
+ canvas_form = setup_canvas_form_with_mocks(
+ mock_ui,
+ mock_viewmodel,
+ mock_resource_manager,
+ hotkey_side_effect=hotkeys_by_default,
+ )
+ canvas_form.mock_ui = mock_ui # type: ignore[attr-defined]
+ yield canvas_form
+
+ def content(self, canvas_form: Any) -> str:
+ return canvas_form.mock_ui.interactive_image.return_value.content
+
+ def test_area_section_is_drawn_as_filled_polygon(self, canvas_form: Any) -> None:
+ canvas_form.draw_section(
+ id="area",
+ coordinates=[(0, 0), (10, 0), (10, 10), (0, 0)],
+ is_selected_section=False,
+ is_area_section=True,
+ )
+
+ shape = canvas_form._sections.lines["area"]
+ assert isinstance(shape, Polygon)
+ assert shape.points == [(0, 0), (10, 0), (10, 10)]
+ assert 0 < shape.fill_opacity < 1
+ assert len(canvas_form._circles.by_section["area"]) == 3
+ assert " None:
+ canvas_form.draw_section(
+ id="line", coordinates=[(0, 0), (10, 0)], is_selected_section=False
+ )
+
+ assert isinstance(canvas_form._sections.lines["line"], Polyline)
+
+ def test_section_name_is_drawn(self, canvas_form: Any) -> None:
+ canvas_form.draw_section(
+ id="line",
+ coordinates=[(5, 30), (10, 0)],
+ is_selected_section=False,
+ text="North",
+ )
+
+ content = self.content(canvas_form)
+ assert ">North" in content
+ assert 'x="5"' in content
+
+ def test_section_without_name_has_no_text(self, canvas_form: Any) -> None:
+ canvas_form.draw_section(
+ id="line", coordinates=[(0, 0), (10, 0)], is_selected_section=False
+ )
+
+ assert " None:
+ canvas_form.draw_section(
+ id="line",
+ coordinates=[(0, 0), (10, 0)],
+ is_selected_section=False,
+ text="North",
+ )
+
+ canvas_form.delete_element("line_section")
+ canvas_form.draw_all()
+
+ assert "North" not in self.content(canvas_form)
+
+ def test_new_area_section_is_drawn_as_polygon(self, canvas_form: Any) -> None:
+ canvas_form.start_section_builder(is_area_section=True)
+ for x, y in [(0, 0), (10, 0)]:
+ canvas_form.on_mouse(create_mouse_event(CLICK, x, y))
+ canvas_form.on_mouse(create_mouse_event(MOUSE_MOVE, 10, 10))
+
+ assert 'points="0,0 10,0 10,10"' in self.content(canvas_form)
+ assert " None:
+ start_section, end_section, start_calculator, end_calculator = arrow_mocks()
+
+ canvas_form.draw_arrow(
+ start_section=start_section,
+ end_section=end_section,
+ start_refpt_calculator=start_calculator,
+ end_refpt_calculator=end_calculator,
+ arrow_style={"fill": "#8CFFC2", "width": 4, "dash": (5, 5)},
+ )
+
+ arrow = canvas_form._flows.lines["arrow-start-section-end-section"]
+ assert arrow.color == "#8CFFC2"
+ assert arrow.width == 4
+ assert arrow.dash == (5, 5)
+ assert 'stroke="#8CFFC2"' in self.content(canvas_form)
+
+ def test_draw_arrow_without_style_uses_default(self, canvas_form: Any) -> None:
+ start_section, end_section, start_calculator, end_calculator = arrow_mocks()
+
+ canvas_form.draw_arrow(
+ start_section=start_section,
+ end_section=end_section,
+ start_refpt_calculator=start_calculator,
+ end_refpt_calculator=end_calculator,
+ )
+
+ arrow = canvas_form._flows.lines["arrow-start-section-end-section"]
+ assert arrow.color == NORMAL_COLOR
+
+ def test_disabled_canvas_ignores_editor_input(
+ self, canvas_form: Any, mock_section: Mock
+ ) -> None:
+ canvas_form.start_section_builder()
+ canvas_form.disable()
+
+ canvas_form.on_mouse(create_mouse_event(CLICK, 10, 20))
+
+ assert canvas_form._new_section_points == []
+
+ canvas_form.enable()
+ canvas_form.on_mouse(create_mouse_event(CLICK, 10, 20))
+
+ assert len(canvas_form._new_section_points) == 1
+
+ @pytest.mark.asyncio
+ async def test_disabled_canvas_ignores_keys_and_right_click(
+ self, canvas_form: Any, mock_viewmodel: Mock, mock_section: Mock
+ ) -> None:
+ canvas_form.start_section_geometry_editor(mock_section)
+ canvas_form.disable()
+
+ await canvas_form.handle_key(create_key_event(SAVE_SECTION_HOTKEY_VALUE))
+ await canvas_form.on_right_click()
+ canvas_form.on_svg_pointer_down(svg_event(110, 20, knob_id(1)))
+
+ mock_viewmodel.update_section_coordinates.assert_not_called()
+ assert canvas_form._selected_knob_index is None
+
+ def test_editor_started_by_running_action_is_enabled(
+ self, canvas_form: Any
+ ) -> None:
+ canvas_form.disable()
+ canvas_form.start_section_builder()
+
+ canvas_form.on_mouse(create_mouse_event(CLICK, 10, 20))
+
+ assert len(canvas_form._new_section_points) == 1
+
+
+def arrow_mocks() -> tuple[Mock, Mock, Mock, Mock]:
+ start_section = Mock()
+ start_section.id = "start-section"
+ end_section = Mock()
+ end_section.id = "end-section"
+ start_calculator = Mock()
+ start_calculator.get_reference_point.return_value = (100, 200)
+ end_calculator = Mock()
+ end_calculator.get_reference_point.return_value = (300, 400)
+ return start_section, end_section, start_calculator, end_calculator
diff --git a/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_section_geometry.py b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_section_geometry.py
new file mode 100644
index 000000000..811d5e487
--- /dev/null
+++ b/tests/unit/OTAnalytics/plugin_ui/nicegui_gui/pages/canvas_and_files_form/test_section_geometry.py
@@ -0,0 +1,190 @@
+import pytest
+
+from OTAnalytics.plugin_ui.nicegui_gui.pages.canvas_and_files_form.section_geometry import ( # noqa
+ close_area,
+ closest_knob_index,
+ delete_knob,
+ has_enough_knobs,
+ insert_knob,
+ minimum_knob_count,
+ open_area,
+ shift_knob_backward,
+ shift_knob_forward,
+)
+
+LINE = [(0, 0), (10, 0), (20, 0)]
+SQUARE = [(0, 0), (10, 0), (10, 10), (0, 10)]
+
+
+class TestMinimumKnobCount:
+ def test_line_section_needs_two_knobs(self) -> None:
+ assert minimum_knob_count(is_area_section=False) == 2
+
+ def test_area_section_needs_three_knobs(self) -> None:
+ assert minimum_knob_count(is_area_section=True) == 3
+
+ @pytest.mark.parametrize(
+ "coordinates, is_area_section, expected",
+ [
+ ([], False, False),
+ ([(0, 0)], False, False),
+ ([(0, 0), (1, 1)], False, True),
+ ([(0, 0), (1, 1)], True, False),
+ ([(0, 0), (1, 1), (2, 0)], True, True),
+ ],
+ )
+ def test_has_enough_knobs(
+ self,
+ coordinates: list[tuple[int, int]],
+ is_area_section: bool,
+ expected: bool,
+ ) -> None:
+ assert has_enough_knobs(coordinates, is_area_section) is expected
+
+
+class TestCloseArea:
+ def test_closes_area(self) -> None:
+ coordinates = [(0, 0), (10, 0), (10, 10)]
+
+ assert close_area(coordinates, is_area_section=True) == [
+ (0, 0),
+ (10, 0),
+ (10, 10),
+ (0, 0),
+ ]
+
+ def test_keeps_line_untouched(self) -> None:
+ coordinates = [(0, 0), (10, 0)]
+
+ assert close_area(coordinates, is_area_section=False) == coordinates
+
+ def test_does_not_close_already_closed_area(self) -> None:
+ coordinates = [(0, 0), (10, 0), (10, 10), (0, 0)]
+
+ assert close_area(coordinates, is_area_section=True) == coordinates
+
+
+class TestOpenArea:
+ def test_drops_closing_coordinate_of_area(self) -> None:
+ assert open_area(SQUARE + [(0, 0)], is_area_section=True) == SQUARE
+
+ def test_keeps_open_area(self) -> None:
+ assert open_area(SQUARE, is_area_section=True) == SQUARE
+
+ def test_keeps_line(self) -> None:
+ assert open_area([(0, 0), (0, 0)], is_area_section=False) == [(0, 0), (0, 0)]
+
+
+class TestClosestKnobIndex:
+ def test_returns_closest_knob_in_radius(self) -> None:
+ assert closest_knob_index(LINE, (12, 3), radius=5) == 1
+
+ def test_returns_none_if_no_knob_in_radius(self) -> None:
+ assert closest_knob_index(LINE, (5, 8), radius=5) is None
+
+ def test_returns_none_for_empty_coordinates(self) -> None:
+ assert closest_knob_index([], (5, 8), radius=5) is None
+
+ def test_prefers_closer_knob(self) -> None:
+ assert closest_knob_index([(0, 0), (4, 0)], (3, 0), radius=5) == 1
+
+
+class TestInsertKnob:
+ def test_inserts_between_closest_knobs_of_line(self) -> None:
+ coordinates, index = insert_knob(LINE, (14, 2), is_area_section=False)
+
+ assert coordinates == [(0, 0), (10, 0), (14, 2), (20, 0)]
+ assert index == 2
+
+ def test_prepends_before_start_of_line(self) -> None:
+ coordinates, index = insert_knob(LINE, (-5, 1), is_area_section=False)
+
+ assert coordinates == [(-5, 1), (0, 0), (10, 0), (20, 0)]
+ assert index == 0
+
+ def test_appends_after_end_of_line(self) -> None:
+ coordinates, index = insert_knob(LINE, (25, 1), is_area_section=False)
+
+ assert coordinates == [(0, 0), (10, 0), (20, 0), (25, 1)]
+ assert index == 3
+
+ def test_inserts_into_closing_segment_of_area(self) -> None:
+ coordinates, index = insert_knob(SQUARE, (-1, 5), is_area_section=True)
+
+ assert coordinates == SQUARE + [(-1, 5)]
+ assert index == 4
+
+ def test_inserts_between_knobs_of_area(self) -> None:
+ coordinates, index = insert_knob(SQUARE, (11, 5), is_area_section=True)
+
+ assert coordinates == [(0, 0), (10, 0), (11, 5), (10, 10), (0, 10)]
+ assert index == 2
+
+ def test_appends_to_single_knob(self) -> None:
+ coordinates, index = insert_knob([(0, 0)], (5, 5), is_area_section=False)
+
+ assert coordinates == [(0, 0), (5, 5)]
+ assert index == 1
+
+ def test_does_not_modify_input(self) -> None:
+ original = list(LINE)
+
+ insert_knob(original, (14, 2), is_area_section=False)
+
+ assert original == LINE
+
+
+class TestShiftKnob:
+ def test_shift_forward(self) -> None:
+ coordinates, index = shift_knob_forward(LINE, 0, is_area_section=False)
+
+ assert coordinates == [(10, 0), (0, 0), (20, 0)]
+ assert index == 1
+
+ def test_shift_forward_at_end_of_line_is_ignored(self) -> None:
+ coordinates, index = shift_knob_forward(LINE, 2, is_area_section=False)
+
+ assert coordinates == LINE
+ assert index == 2
+
+ def test_shift_forward_at_end_of_area_wraps_around(self) -> None:
+ coordinates, index = shift_knob_forward(SQUARE, 3, is_area_section=True)
+
+ assert coordinates == [(0, 10), (10, 0), (10, 10), (0, 0)]
+ assert index == 0
+
+ def test_shift_backward(self) -> None:
+ coordinates, index = shift_knob_backward(LINE, 2, is_area_section=False)
+
+ assert coordinates == [(0, 0), (20, 0), (10, 0)]
+ assert index == 1
+
+ def test_shift_backward_at_start_of_line_is_ignored(self) -> None:
+ coordinates, index = shift_knob_backward(LINE, 0, is_area_section=False)
+
+ assert coordinates == LINE
+ assert index == 0
+
+ def test_shift_backward_at_start_of_area_wraps_around(self) -> None:
+ coordinates, index = shift_knob_backward(SQUARE, 0, is_area_section=True)
+
+ assert coordinates == [(0, 10), (10, 0), (10, 10), (0, 0)]
+ assert index == 3
+
+
+class TestDeleteKnob:
+ def test_deletes_knob(self) -> None:
+ assert delete_knob(LINE, 1, is_area_section=False) == [(0, 0), (20, 0)]
+
+ def test_keeps_minimum_of_line(self) -> None:
+ coordinates = [(0, 0), (10, 0)]
+
+ assert delete_knob(coordinates, 1, is_area_section=False) == coordinates
+
+ def test_keeps_minimum_of_area(self) -> None:
+ coordinates = [(0, 0), (10, 0), (10, 10)]
+
+ assert delete_knob(coordinates, 1, is_area_section=True) == coordinates
+
+ def test_deletes_knob_of_area(self) -> None:
+ assert delete_knob(SQUARE, 0, is_area_section=True) == SQUARE[1:]