diff --git a/examples/convert_ewoks_to_elk_graph.py b/examples/convert_ewoks_to_elk_graph.py index c0ec650..0abb8cb 100644 --- a/examples/convert_ewoks_to_elk_graph.py +++ b/examples/convert_ewoks_to_elk_graph.py @@ -3,9 +3,9 @@ from ewokscore import load_graph from ewokscore.tests.examples.graphs import get_graph -from ewoksdraw import build_svg_task_group -from ewoksdraw.layout.elk_converter import ElkGraph +from ewoksdraw.layout.elk_converter import ElkGraphBeforeLayout from ewoksdraw.layout.elk_converter import convert_ewoks_to_elk_graph +from ewoksdraw.layout.ewoks_task_group_builder import build_svg_task_group from ewoksdraw.svg.svg_task_group import SvgTaskGroup from ewoksdraw.svg.svg_task_group import TaskInputPositions from ewoksdraw.svg.svg_task_group import TaskOutputPositions @@ -18,7 +18,7 @@ task_sizes: TaskSizes = svg_task_group.extract_task_sizes() task_input_positions: TaskInputPositions = svg_task_group.extract_input_positions() task_output_positions: TaskOutputPositions = svg_task_group.extract_output_positions() -elk_graph: ElkGraph = convert_ewoks_to_elk_graph( +elk_graph: ElkGraphBeforeLayout = convert_ewoks_to_elk_graph( ewoks_graph, task_sizes, task_input_positions, task_output_positions ) diff --git a/src/ewoksdraw/__init__.py b/src/ewoksdraw/__init__.py index 5ce8edf..e0ad43c 100644 --- a/src/ewoksdraw/__init__.py +++ b/src/ewoksdraw/__init__.py @@ -1,35 +1,3 @@ -from pathlib import Path +from .bindings import graph_to_svg -from ewokscore.graph import TaskGraph -from ewokscore.graph.inputs import _get_all_node_inputs -from ewokscore.graph.inputs import _get_all_task_output_names - -from .svg.svg_canvas import SvgCanvas -from .svg.svg_task import SvgTask -from .svg.svg_task_group import SvgTaskGroup - -GAP = 10.0 -DEFAULT_HEIGHT = 500 - - -def build_svg_task_group(graph: TaskGraph) -> SvgTaskGroup: - svg_tasks = {} - for node_id, node_attrs in graph.graph.nodes.items(): - node_inputs = _get_all_node_inputs(node_id, node_attrs) - node_outputs = _get_all_task_output_names( - node_attrs["task_type"], node_attrs["task_identifier"] - ) - svg_tasks[node_id] = SvgTask( - task_name=node_id, - input_names=[n.name for n in node_inputs], - output_names=node_outputs, - ) - return SvgTaskGroup(svg_tasks, horizontal_gap=GAP, group_id=str(graph.graph_id)) - - -def graph_to_svg(graph: TaskGraph, output_path: str | Path) -> None: - task_group = build_svg_task_group(graph) - canvas = SvgCanvas(width=task_group.width, height=task_group.height + 2 * GAP) - canvas.add_background() - canvas.add_element(task_group) - canvas.draw(output_path) +__all__ = ["graph_to_svg"] diff --git a/src/ewoksdraw/bindings.py b/src/ewoksdraw/bindings.py new file mode 100644 index 0000000..6f949f0 --- /dev/null +++ b/src/ewoksdraw/bindings.py @@ -0,0 +1,36 @@ +from pathlib import Path + +from ewokscore.graph import TaskGraph +from pyelk import ELK + +from .layout.elk_converter import ElkGraph +from .layout.elk_converter import ElkGraphBeforeLayout +from .layout.elk_converter import convert_ewoks_to_elk_graph +from .layout.elk_converter import extract_task_positions_from_elk_graph +from .layout.elk_link_group_builder import build_svg_link_group +from .layout.ewoks_task_group_builder import build_svg_task_group +from .svg.svg_canvas import SvgCanvas + +__all__ = ["graph_to_svg"] + + +def graph_to_svg(graph: TaskGraph, output_path: str | Path) -> None: + task_group = build_svg_task_group(graph) + elk_graph_before_layout: ElkGraphBeforeLayout = convert_ewoks_to_elk_graph( + graph, + task_group.extract_task_sizes(), + task_group.extract_input_positions(), + task_group.extract_output_positions(), + ) + elk_graph: ElkGraph = ELK().layout(elk_graph_before_layout) + + task_positions = extract_task_positions_from_elk_graph(elk_graph) + task_group.set_task_positions(task_positions) + + canvas = SvgCanvas(width=elk_graph["width"], height=elk_graph["height"]) + canvas.add_background() + canvas.add_element( + build_svg_link_group(elk_graph, group_id=f"{graph.graph_id}-links") + ) + canvas.add_element(task_group) + canvas.draw(output_path) diff --git a/src/ewoksdraw/config/constants.py b/src/ewoksdraw/config/constants.py index e7f9616..3c5f9d5 100644 --- a/src/ewoksdraw/config/constants.py +++ b/src/ewoksdraw/config/constants.py @@ -10,6 +10,9 @@ IO_ANCHOR_TEXT_MARGIN = 10 IO_TOP_MARGIN = 5 IO_INTER_IO_MARGIN = 3 +LINK_TURN_RADIUS = 5 +TASK_GROUP_HORIZONTAL_GAP = 10.0 + ELK_LAYOUT_OPTIONS = { "org.eclipse.elk.algorithm": "layered", diff --git a/src/ewoksdraw/geometry/cubic_bezier_path.py b/src/ewoksdraw/geometry/cubic_bezier_path.py index b2dbd62..66ed46d 100644 --- a/src/ewoksdraw/geometry/cubic_bezier_path.py +++ b/src/ewoksdraw/geometry/cubic_bezier_path.py @@ -1,13 +1,19 @@ import sys from dataclasses import dataclass from typing import Sequence +from typing import TypedDict if sys.version_info < (3, 11): from typing_extensions import Self else: from typing import Self -Point = tuple[float, float] + +class Point(TypedDict): + x: float + y: float + + Vector = tuple[float, float] @@ -69,17 +75,17 @@ def from_points(cls, points: Sequence[Point], radius: float) -> Self: def _straight_segment(start: Point, end: Point) -> CubicBezierSegment: """Create a cubic Bezier segment that renders as a straight line.""" - start_x, start_y = start - end_x, end_y = end - # Control points are set to 1/2; 2/3 arbitrarly so they are not combine with # start and end points. return CubicBezierSegment( - control1=(start_x + (end_x - start_x) / 3, start_y + (end_y - start_y) / 3), - control2=( - start_x + 2 * (end_x - start_x) / 3, - start_y + 2 * (end_y - start_y) / 3, - ), + control1={ + "x": start["x"] + (end["x"] - start["x"]) / 3, + "y": start["y"] + (end["y"] - start["y"]) / 3, + }, + control2={ + "x": start["x"] + 2 * (end["x"] - start["x"]) / 3, + "y": start["y"] + 2 * (end["y"] - start["y"]) / 3, + }, end=end, ) @@ -89,15 +95,18 @@ def _direction(start: Point, end: Point) -> Vector: Return the horizontal or vertical direction from start to end. Example : (1, 0) right; (-1, 0) left ... """ - if start[0] == end[0]: - return (0, 1 if end[1] > start[1] else -1) - return (1 if end[0] > start[0] else -1, 0) + if start["x"] == end["x"]: + return (0, 1 if end["y"] > start["y"] else -1) + return (1 if end["x"] > start["x"] else -1, 0) def _l1_distance(start: Point, end: Point) -> float: - return abs(end[0] - start[0]) + abs(end[1] - start[1]) + return abs(end["x"] - start["x"]) + abs(end["y"] - start["y"]) def _move(point: Point, direction: Vector, distance: float) -> Point: """Move a point along a direction by a distance.""" - return (point[0] + direction[0] * distance, point[1] + direction[1] * distance) + return { + "x": point["x"] + direction[0] * distance, + "y": point["y"] + direction[1] * distance, + } diff --git a/src/ewoksdraw/layout/elk_converter.py b/src/ewoksdraw/layout/elk_converter.py index 20328e4..4e51a29 100644 --- a/src/ewoksdraw/layout/elk_converter.py +++ b/src/ewoksdraw/layout/elk_converter.py @@ -6,9 +6,12 @@ from ewoksdraw.config.constants import ELK_LAYOUT_OPTIONS +from ..geometry.cubic_bezier_path import Point from ..svg.svg_task import TaskIOPosition +from ..svg.svg_task import TaskPosition from ..svg.svg_task_group import TaskInputPositions from ..svg.svg_task_group import TaskOutputPositions +from ..svg.svg_task_group import TaskPositions from ..svg.svg_task_group import TaskSizes @@ -21,7 +24,9 @@ class ElkPort(TypedDict): layoutOptions: dict[str, Any] -class ElkChild(TypedDict): +class ElkChildBeforeLayout(TypedDict): + """An ELK child before layout.""" + id: str width: float height: float @@ -29,25 +34,76 @@ class ElkChild(TypedDict): ports: list[ElkPort] -class ElkEdge(TypedDict): +class ElkChild(ElkChildBeforeLayout): + """An ELK child with coordinates computed by ELK.""" + + x: float + y: float + + +class ElkPoint(Point): + """A point in an ELK layout.""" + + +class ElkSection(TypedDict): + id: str + startPoint: ElkPoint + bendPoints: list[ElkPoint] + endPoint: ElkPoint + routing: str + + +class ElkEdgeBeforeLayout(TypedDict): + """An ELK edge before layout.""" + id: str sources: list[str] targets: list[str] -class ElkGraph(TypedDict): +class ElkEdge(ElkEdgeBeforeLayout): + """An ELK edge with routing sections computed by ELK.""" + + sections: list[ElkSection] + + +class ElkGraphBase(TypedDict): id: str layoutOptions: dict[str, Any] + + +class ElkGraphBeforeLayout(ElkGraphBase): + """An ELK graph before layout.""" + + children: list[ElkChildBeforeLayout] + edges: list[ElkEdgeBeforeLayout] + + +class ElkGraph(ElkGraphBase): + """An ELK graph with coordinates and routing computed by ELK.""" + + width: float + height: float children: list[ElkChild] edges: list[ElkEdge] +def extract_task_positions_from_elk_graph( + elk_graph: ElkGraph, +) -> TaskPositions: + """Extract SVG task positions from a laid-out ELK graph.""" + return { + child["id"]: TaskPosition(name=child["id"], x=child["x"], y=child["y"]) + for child in elk_graph["children"] + } + + def convert_ewoks_to_elk_graph( ewoks_graph: TaskGraph, task_sizes: TaskSizes, task_input_positions: TaskInputPositions, task_output_positions: TaskOutputPositions, -) -> ElkGraph: +) -> ElkGraphBeforeLayout: """Convert an Ewoks task graph into an ELK layout graph. :param ewoks_graph: the task graph to convert, e.g. from ``ewokscore.load_graph``. @@ -76,7 +132,7 @@ def convert_ewoks_to_elk_graph( f"{sorted(node_ids)}" ) - children: list[ElkChild] = [] + children: list[ElkChildBeforeLayout] = [] used_ids: set[str] = set() for task_id in ewoks_graph.graph.nodes: ports = _convert_io_positions_to_elk_ports( @@ -101,7 +157,7 @@ def convert_ewoks_to_elk_graph( root_id = _available_elk_id("__ewoksdraw_root__", used_ids) used_ids.add(root_id) - edges: list[ElkEdge] = [] + edges: list[ElkEdgeBeforeLayout] = [] for source, target, link_attrs in ewoks_graph.graph.edges(data=True): if link_attrs.get("map_all_data", False): warnings.warn( diff --git a/src/ewoksdraw/layout/elk_link_group_builder.py b/src/ewoksdraw/layout/elk_link_group_builder.py new file mode 100644 index 0000000..a6502b2 --- /dev/null +++ b/src/ewoksdraw/layout/elk_link_group_builder.py @@ -0,0 +1,56 @@ +from ..config.constants import LINK_TURN_RADIUS +from ..geometry.cubic_bezier_path import CubicBezierPath +from ..geometry.cubic_bezier_path import Point +from ..svg.svg_group import SvgGroup +from ..svg.svg_link_cubic_bezier import SvgLinkCubicBezier +from .elk_converter import ElkGraph +from .elk_converter import ElkSection + + +def build_svg_link_group( + elk_graph: ElkGraph, group_id: str | None = None +) -> SvgGroup[SvgLinkCubicBezier]: + """Build an SVG link group from the routed edges of an ELK graph.""" + link_group: SvgGroup[SvgLinkCubicBezier] = SvgGroup(group_id=group_id) + svg_links: list[SvgLinkCubicBezier] = [] + + for edge in elk_graph["edges"]: + for section in edge["sections"]: + points = _section_points(section) + cubic_bezier_path = CubicBezierPath.from_points( + points=points, + radius=LINK_TURN_RADIUS, + ) + svg_link = SvgLinkCubicBezier(cubic_bezier_path) + svg_links.append(svg_link) + + link_group.add_elements(svg_links) + return link_group + + +def _section_points(section: ElkSection) -> list[Point]: + """Convert an ELK edge section into an ordered list of points. + + :param section: an ELK edge section containing start, bend and end points. + For example:: + + { + "startPoint": {"x": 10.0, "y": 20.0}, + "bendPoints": [{"x": 30.0, "y": 20.0}], + "endPoint": {"x": 30.0, "y": 40.0}, + } + + :return: the points ordered from start to end. + For example:: + + [ + {"x": 10.0, "y": 20.0}, + {"x": 30.0, "y": 20.0}, + {"x": 30.0, "y": 40.0}, + ] + """ + return [ + section["startPoint"], + *section["bendPoints"], + section["endPoint"], + ] diff --git a/src/ewoksdraw/layout/ewoks_task_group_builder.py b/src/ewoksdraw/layout/ewoks_task_group_builder.py new file mode 100644 index 0000000..287849d --- /dev/null +++ b/src/ewoksdraw/layout/ewoks_task_group_builder.py @@ -0,0 +1,27 @@ +from ewokscore.graph import TaskGraph +from ewokscore.graph.inputs import _get_all_node_inputs +from ewokscore.graph.inputs import _get_all_task_output_names + +from ..config.constants import TASK_GROUP_HORIZONTAL_GAP +from ..svg.svg_task import SvgTask +from ..svg.svg_task_group import SvgTaskGroup + + +def build_svg_task_group(graph: TaskGraph) -> SvgTaskGroup: + """Build an SVG task group from an Ewoks task graph.""" + svg_tasks = {} + for node_id, node_attrs in graph.graph.nodes.items(): + node_inputs = _get_all_node_inputs(node_id, node_attrs) + node_outputs = _get_all_task_output_names( + node_attrs["task_type"], node_attrs["task_identifier"] + ) + svg_tasks[node_id] = SvgTask( + task_name=node_id, + input_names=[node_input.name for node_input in node_inputs], + output_names=node_outputs, + ) + return SvgTaskGroup( + svg_tasks, + horizontal_gap=TASK_GROUP_HORIZONTAL_GAP, + group_id=str(graph.graph_id), + ) diff --git a/src/ewoksdraw/svg/svg_link_cubic_bezier.py b/src/ewoksdraw/svg/svg_link_cubic_bezier.py index cf62fe1..97d3136 100644 --- a/src/ewoksdraw/svg/svg_link_cubic_bezier.py +++ b/src/ewoksdraw/svg/svg_link_cubic_bezier.py @@ -42,15 +42,13 @@ def _convert_path_data_to_svg_attribute(self, path: CubicBezierPath) -> str: if not path.segments: raise ValueError("A cubic Bezier path needs at least one segment.") - start_x, start_y = path.start - commands = [f"M {start_x},{start_y}"] + commands = [f"M {path.start['x']},{path.start['y']}"] for segment in path.segments: - control1_x, control1_y = segment.control1 - control2_x, control2_y = segment.control2 - end_x, end_y = segment.end commands.append( - f"C {control1_x},{control1_y} {control2_x},{control2_y} {end_x},{end_y}" + f"C {segment.control1['x']},{segment.control1['y']} " + f"{segment.control2['x']},{segment.control2['y']} " + f"{segment.end['x']},{segment.end['y']}" ) return " ".join(commands) diff --git a/src/ewoksdraw/svg/svg_task.py b/src/ewoksdraw/svg/svg_task.py index ff6d579..cf2a39c 100644 --- a/src/ewoksdraw/svg/svg_task.py +++ b/src/ewoksdraw/svg/svg_task.py @@ -20,6 +20,12 @@ class TaskIOPosition(NamedTuple): y: float +class TaskPosition(NamedTuple): + name: str + x: float + y: float + + class SvgTask(SvgGroup): """ Represents a task as an SVG group containing title, input/output groups, box, and diff --git a/src/ewoksdraw/svg/svg_task_group.py b/src/ewoksdraw/svg/svg_task_group.py index 5a011c0..7fd8afe 100644 --- a/src/ewoksdraw/svg/svg_task_group.py +++ b/src/ewoksdraw/svg/svg_task_group.py @@ -1,9 +1,11 @@ from .svg_group import SvgGroup from .svg_task import SvgTask from .svg_task import TaskIOPosition +from .svg_task import TaskPosition from .svg_task import TaskSize TaskSizes = dict[str, TaskSize] +TaskPositions = dict[str, TaskPosition] TaskInputPositions = dict[str, list[TaskIOPosition]] TaskOutputPositions = dict[str, list[TaskIOPosition]] @@ -68,3 +70,29 @@ def extract_output_positions(self) -> TaskOutputPositions: task_id: svg_task.get_output_positions() for task_id, svg_task in self._svg_tasks.items() } + + def set_task_positions(self, task_positions: TaskPositions) -> None: + """Set each task's translation from absolute layout coordinates.""" + + svg_task_ids = set(self._svg_tasks) + positioned_task_ids = set(task_positions) + if svg_task_ids != positioned_task_ids: + raise ValueError( + f"task_positions {sorted(positioned_task_ids)} do not match SVG task " + "ids " + f"{sorted(svg_task_ids)}" + ) + + inconsistent_task_ids = { + task_id: position.name + for task_id, position in task_positions.items() + if task_id != position.name + } + if inconsistent_task_ids: + raise ValueError( + "task_positions keys do not match their position names: " + f"{inconsistent_task_ids}" + ) + + for task_id, position in task_positions.items(): + self._svg_tasks[task_id].set_translation(x=position.x, y=position.y) diff --git a/src/ewoksdraw/tests/test_cubic_bezier_path.py b/src/ewoksdraw/tests/test_cubic_bezier_path.py index 888606c..1e6b2b1 100644 --- a/src/ewoksdraw/tests/test_cubic_bezier_path.py +++ b/src/ewoksdraw/tests/test_cubic_bezier_path.py @@ -9,20 +9,24 @@ from ewoksdraw.geometry.cubic_bezier_path import _straight_segment +def _point(x: float, y: float) -> Point: + return {"x": x, "y": y} + + def test_straight_segment() -> None: - segment = _straight_segment((0, 0), (9, 0)) - assert segment.control1 == pytest.approx((3, 0)) - assert segment.control2 == pytest.approx((6, 0)) - assert segment.end == (9, 0) + segment = _straight_segment(_point(0, 0), _point(9, 0)) + assert segment.control1 == pytest.approx(_point(3, 0)) + assert segment.control2 == pytest.approx(_point(6, 0)) + assert segment.end == _point(9, 0) @pytest.mark.parametrize( "start, end, expected", [ - ((0, 0), (5, 0), (1, 0)), - ((5, 0), (0, 0), (-1, 0)), - ((0, 0), (0, 5), (0, 1)), - ((0, 5), (0, 0), (0, -1)), + (_point(0, 0), _point(5, 0), (1, 0)), + (_point(5, 0), _point(0, 0), (-1, 0)), + (_point(0, 0), _point(0, 5), (0, 1)), + (_point(0, 5), _point(0, 0), (0, -1)), ], ) def test_direction(start: Point, end: Point, expected: Vector) -> None: @@ -32,9 +36,9 @@ def test_direction(start: Point, end: Point, expected: Vector) -> None: @pytest.mark.parametrize( "start, end, expected", [ - ((0, 0), (3, 4), 7), - ((0, 0), (0, 0), 0), - ((-1, -1), (1, 1), 4), + (_point(0, 0), _point(3, 4), 7), + (_point(0, 0), _point(0, 0), 0), + (_point(-1, -1), _point(1, 1), 4), ], ) def test_distance(start: Point, end: Point, expected: float) -> None: @@ -44,10 +48,10 @@ def test_distance(start: Point, end: Point, expected: float) -> None: @pytest.mark.parametrize( "point, direction, distance, expected", [ - ((0, 0), (1, 0), 5, (5, 0)), - ((0, 0), (-1, 0), 5, (-5, 0)), - ((0, 0), (0, 1), 5, (0, 5)), - ((0, 0), (0, 1), -5, (0, -5)), + (_point(0, 0), (1, 0), 5, _point(5, 0)), + (_point(0, 0), (-1, 0), 5, _point(-5, 0)), + (_point(0, 0), (0, 1), 5, _point(0, 5)), + (_point(0, 0), (0, 1), -5, _point(0, -5)), ], ) def test_move( @@ -57,36 +61,42 @@ def test_move( def test_from_points_two_points_is_a_single_straight_segment() -> None: - path = CubicBezierPath.from_points(points=[(0, 0), (10, 0)], radius=2) + path = CubicBezierPath.from_points(points=[_point(0, 0), _point(10, 0)], radius=2) - assert path.start == (0, 0) + assert path.start == _point(0, 0) assert len(path.segments) == 1 - assert path.segments[0].end == (10, 0) + assert path.segments[0].end == _point(10, 0) def test_from_points_single_corner_produces_three_segments() -> None: - path = CubicBezierPath.from_points(points=[(0, 0), (10, 0), (10, 10)], radius=2) + path = CubicBezierPath.from_points( + points=[_point(0, 0), _point(10, 0), _point(10, 10)], radius=2 + ) - assert path.start == (0, 0) + assert path.start == _point(0, 0) assert len(path.segments) == 3 straight_before, corner, straight_after = path.segments - assert straight_before.end == (8, 0) - assert corner.end == (10, 2) - assert straight_after.end == (10, 10) + assert straight_before.end == _point(8, 0) + assert corner.end == _point(10, 2) + assert straight_after.end == _point(10, 10) def test_from_points_radius_clamped_to_half_shorter_adjacent_segment() -> None: - path = CubicBezierPath.from_points(points=[(0, 0), (2, 0), (2, 10)], radius=100) + path = CubicBezierPath.from_points( + points=[_point(0, 0), _point(2, 0), _point(2, 10)], radius=100 + ) _, corner, _ = path.segments - assert corner.end == (2, 1) + assert corner.end == _point(2, 1) def test_from_points_zero_radius_collapses_corner_to_point() -> None: - path = CubicBezierPath.from_points(points=[(0, 0), (10, 0), (10, 10)], radius=0) + path = CubicBezierPath.from_points( + points=[_point(0, 0), _point(10, 0), _point(10, 10)], radius=0 + ) straight_before, corner, straight_after = path.segments - assert straight_before.end == (10, 0) - assert corner.end == (10, 0) - assert straight_after.end == (10, 10) + assert straight_before.end == _point(10, 0) + assert corner.end == _point(10, 0) + assert straight_after.end == _point(10, 10) diff --git a/src/ewoksdraw/tests/test_elk_converter.py b/src/ewoksdraw/tests/test_elk_converter.py index 23bd9fd..8ef39c9 100644 --- a/src/ewoksdraw/tests/test_elk_converter.py +++ b/src/ewoksdraw/tests/test_elk_converter.py @@ -5,10 +5,13 @@ from ewokscore.tests.examples.graphs import graph_names from pyelk.graph import validate_graph -from ewoksdraw import build_svg_task_group from ewoksdraw.config.constants import ELK_LAYOUT_OPTIONS +from ewoksdraw.layout.elk_converter import ElkGraph from ewoksdraw.layout.elk_converter import convert_ewoks_to_elk_graph +from ewoksdraw.layout.elk_converter import extract_task_positions_from_elk_graph +from ewoksdraw.layout.ewoks_task_group_builder import build_svg_task_group from ewoksdraw.svg.svg_task import TaskIOPosition +from ewoksdraw.svg.svg_task import TaskPosition from ewoksdraw.svg.svg_task_group import TaskInputPositions from ewoksdraw.svg.svg_task_group import TaskOutputPositions from ewoksdraw.svg.svg_task_group import TaskSize @@ -36,6 +39,31 @@ def _task_output_positions(graph: TaskGraph) -> TaskOutputPositions: return {node_id: [] for node_id in graph.graph.nodes} +def test_extract_task_positions_from_elk_graph() -> None: + elk_graph: ElkGraph = { + "id": "root", + "width": 100.0, + "height": 100.0, + "layoutOptions": {}, + "children": [ + { + "id": "task", + "width": 20.0, + "height": 30.0, + "x": 12.0, + "y": 34.0, + "layoutOptions": {}, + "ports": [], + } + ], + "edges": [], + } + + assert extract_task_positions_from_elk_graph(elk_graph) == { + "task": TaskPosition(name="task", x=12.0, y=34.0) + } + + def test_top_level_structure() -> None: graph_description, _ = get_graph("acyclic1") graph = load_graph(graph_description) diff --git a/src/ewoksdraw/tests/test_graph_to_svg.py b/src/ewoksdraw/tests/test_graph_to_svg.py index 712d137..e5dfee8 100644 --- a/src/ewoksdraw/tests/test_graph_to_svg.py +++ b/src/ewoksdraw/tests/test_graph_to_svg.py @@ -4,11 +4,20 @@ import pytest from defusedxml import ElementTree from ewokscore import load_graph +from ewokscore.graph import TaskGraph from ewokscore.tests.examples.graphs import get_graph from ewokscore.tests.examples.graphs import graph_names from ewoksdraw import graph_to_svg +pytestmark = pytest.mark.filterwarnings("ignore:.*uses 'map_all_data'.*:UserWarning") + + +@pytest.fixture(params=graph_names(), ids=graph_names()) +def ewoks_graph(request: pytest.FixtureRequest) -> TaskGraph: + graph_description, _ = get_graph(request.param) + return load_graph(graph_description) + def _find_svg_group(parent: Element, group_id: str) -> Element: return next( @@ -18,19 +27,67 @@ def _find_svg_group(parent: Element, group_id: str) -> Element: ) -@pytest.mark.parametrize("graph_name", graph_names()) -def test_groups_are_matching_nodes(graph_name: str, tmp_path: Path) -> None: - output_path = tmp_path / f"{graph_name}.svg" +def _translation(group: Element) -> tuple[float, float]: + transform = group.attrib["transform"] + coordinates = transform.removeprefix("translate(").removesuffix(")").split(",") + return float(coordinates[0]), float(coordinates[1]) - graph, _ = get_graph(graph_name) - ewoksgraph = load_graph(graph) - graph_to_svg(ewoksgraph, output_path) +def test_groups_are_matching_nodes(ewoks_graph: TaskGraph, tmp_path: Path) -> None: + output_path = tmp_path / f"{ewoks_graph.graph_id}.svg" + graph_to_svg(ewoks_graph, output_path) assert output_path.is_file() tree = ElementTree.parse(output_path) - task_group = _find_svg_group(tree.getroot(), str(ewoksgraph.graph_id)) - for node_name in ewoksgraph.graph.nodes: + task_group = _find_svg_group(tree.getroot(), str(ewoks_graph.graph_id)) + for node_name in ewoks_graph.graph.nodes: svg_task = _find_svg_group(task_group, str(node_name)) assert svg_task[0].text == node_name + + +def test_elk_positions_data_mapped_tasks( + ewoks_graph: TaskGraph, tmp_path: Path +) -> None: + """Testing Elk computed layout give left->right connected tasks position""" + if ewoks_graph.is_cyclic: + pytest.skip("Left-to-right edge ordering does not apply to cyclic workflows") + + output_path = tmp_path / f"{ewoks_graph.graph_id}.svg" + graph_to_svg(ewoks_graph, output_path) + + # SVG + root = ElementTree.parse(output_path).getroot() + task_group = _find_svg_group(root, str(ewoks_graph.graph_id)) + positions: dict[str, tuple[float, float]] = {} + + # Ewoks Graph + for task_id in ewoks_graph.graph.nodes: + svg_task = _find_svg_group(task_group, task_id) + task_position = _translation(svg_task) + positions[task_id] = task_position + + for source, target, attributes in ewoks_graph.graph.edges(data=True): + if not attributes.get("data_mapping"): + continue + + assert positions[source][0] < positions[target][0] + + +def test_elk_links_are_rendered(ewoks_graph: TaskGraph, tmp_path: Path) -> None: + output_path = tmp_path / f"{ewoks_graph.graph_id}.svg" + graph_to_svg(ewoks_graph, output_path) + + # SVG + root = ElementTree.parse(output_path).getroot() + link_group = _find_svg_group(root, f"{ewoks_graph.graph_id}-links") + links = [element for element in link_group if element.tag.endswith("path")] + + # Ewoks Graph + expected_link_count = sum( + len(attributes.get("data_mapping", [])) + for _, _, attributes in ewoks_graph.graph.edges(data=True) + ) + assert len(links) == expected_link_count + assert all(link.get("class") == "link_cubic_bezier" for link in links) + assert all((link.get("d") or "").startswith("M ") for link in links) diff --git a/src/ewoksdraw/tests/test_svg_link_cubic_bezier.py b/src/ewoksdraw/tests/test_svg_link_cubic_bezier.py index dc5fec7..a57690e 100644 --- a/src/ewoksdraw/tests/test_svg_link_cubic_bezier.py +++ b/src/ewoksdraw/tests/test_svg_link_cubic_bezier.py @@ -5,15 +5,29 @@ from ewoksdraw.svg.svg_link_cubic_bezier import SvgLinkCubicBezier SIMPLE_PATH = CubicBezierPath( - start=(0, 0), - segments=[CubicBezierSegment(control1=(1, 1), control2=(2, 2), end=(3, 3))], + start={"x": 0, "y": 0}, + segments=[ + CubicBezierSegment( + control1={"x": 1, "y": 1}, + control2={"x": 2, "y": 2}, + end={"x": 3, "y": 3}, + ) + ], ) TWO_SEGMENT_PATH = CubicBezierPath( - start=(0, 0), + start={"x": 0, "y": 0}, segments=[ - CubicBezierSegment(control1=(1, 1), control2=(2, 2), end=(3, 3)), - CubicBezierSegment(control1=(4, 4), control2=(5, 5), end=(6, 6)), + CubicBezierSegment( + control1={"x": 1, "y": 1}, + control2={"x": 2, "y": 2}, + end={"x": 3, "y": 3}, + ), + CubicBezierSegment( + control1={"x": 4, "y": 4}, + control2={"x": 5, "y": 5}, + end={"x": 6, "y": 6}, + ), ], ) @@ -29,7 +43,7 @@ def test_d_attribute_has_one_curve_command_per_segment() -> None: def test_raises_when_path_has_no_segments() -> None: - empty_path = CubicBezierPath(start=(0, 0), segments=[]) + empty_path = CubicBezierPath(start={"x": 0, "y": 0}, segments=[]) with pytest.raises(ValueError): SvgLinkCubicBezier(empty_path) diff --git a/src/ewoksdraw/tests/test_svg_path_integration.py b/src/ewoksdraw/tests/test_svg_path_integration.py index 3f9a1c9..3f091c1 100644 --- a/src/ewoksdraw/tests/test_svg_path_integration.py +++ b/src/ewoksdraw/tests/test_svg_path_integration.py @@ -9,8 +9,14 @@ from ewoksdraw.svg.svg_link_cubic_bezier import SvgLinkCubicBezier PATH = CubicBezierPath( - start=(0, 0), - segments=[CubicBezierSegment(control1=(1, 1), control2=(2, 2), end=(3, 3))], + start={"x": 0, "y": 0}, + segments=[ + CubicBezierSegment( + control1={"x": 1, "y": 1}, + control2={"x": 2, "y": 2}, + end={"x": 3, "y": 3}, + ) + ], )