Repository: levii/jig-py

Issue:
スタイル指定をポリシールール化して、dig操作してもスタイルが維持されるようにしたい
## 今困っていること

 - dig() で深堀りしたときに、深堀りする前に設定していたスタイルが消えてしまう
     - ので、 dig() した後にもう一度スタイル指定をしないといけない
 - auto_highlight を ON にした後に、 auto_highlight を OFF に戻すことができない
     - reset_style() で全てのスタイルをリセットする必要がある

## 考慮事項

 - restore したときにもスタイルポリシーが適用されてほしい
     - remove() でノード・エッジを取り除くときには、スタイルポリシーは削除されない（保持されたまま）
 - auto_highlight はこの Issue のスコープ外だが、スタイルポリシーの生成・適用方法の実装時に考慮すること
     - dig() した後に、深堀りして増えたノード、エッジも含めて、依存関係など再解析してポリシーを当てる必要がある
     - レンダリングする度にスタイルポリシーを動的に生成される感じになりそう

## 実現したいこと

 - dig() で深堀りした時にも、スタイル指定が維持されている
     - 深堀りしたことで増えたノード、エッジに対してスタイルが適用される




Discussion:

Patch:
diff --git a/jig/visualizer/module_dependency/domain/model/graph.py b/jig/visualizer/module_dependency/domain/model/graph.py
index 9ebb1f0..13bacde 100644
--- a/jig/visualizer/module_dependency/domain/model/graph.py
+++ b/jig/visualizer/module_dependency/domain/model/graph.py
@@ -1,18 +1,25 @@
 import dataclasses
 from typing import Set, List, Dict, Optional, Union
 
+from jig.visualizer.module_dependency.domain.model.graph_style import GraphStyle
 from jig.visualizer.module_dependency.domain.model.master_graph import MasterGraph
 from jig.visualizer.module_dependency.domain.value.cluster import Cluster
 from jig.visualizer.module_dependency.domain.value.module_edge import (
     ModuleEdge,
     ModuleEdgeCollection,
+)
+from jig.visualizer.module_dependency.domain.value.edge_style import EdgeStyle
+from jig.visualizer.module_dependency.domain.value.module_edge_style import (
     ModuleEdgeStyle,
 )
 from jig.visualizer.module_dependency.domain.value.module_node import (
     ModuleNode,
-    ModuleNodeStyle,
 )
 from jig.visualizer.module_dependency.domain.value.module_path import ModulePath
+from jig.visualizer.module_dependency.domain.value.module_node_style import (
+    ModuleNodeStyle,
+)
+from jig.visualizer.module_dependency.domain.value.node_style import NodeStyle
 from jig.visualizer.module_dependency.domain.value.penwidth import Color, PenWidth
 
 
@@ -29,6 +36,7 @@ class InvalidRestoreTargetError(Exception):
 @dataclasses.dataclass
 class Graph:
     master_graph: MasterGraph = dataclasses.field(default_factory=MasterGraph)
+    graph_style: GraphStyle = dataclasses.field(default_factory=GraphStyle)
     nodes: Set[ModuleNode] = dataclasses.field(default_factory=set)
     edges: Set[ModuleEdge] = dataclasses.field(default_factory=set)
     clusters: Dict[ModulePath, Cluster] = dataclasses.field(default_factory=dict)
@@ -281,34 +289,30 @@ class Graph:
     def style(
         self, node: ModuleNode, color: Color, fontcolor: Color, penwidth: PenWidth
     ):
-        node_style = ModuleNodeStyle(
-            color=color, fontcolor=fontcolor, penwidth=penwidth
-        )
-        edge_style = ModuleEdgeStyle(
-            color=color, fontcolor=fontcolor, penwidth=penwidth
-        )
+        node_style = NodeStyle(color=color, fontcolor=fontcolor, penwidth=penwidth)
+        edge_style = EdgeStyle(color=color, fontcolor=fontcolor, penwidth=penwidth)
 
-        if node in self.nodes:
-            self.nodes.remove(node)
-            self.nodes.add(node.with_style(style=node_style))
+        if self.master_graph.has_module(node.path):
+            self.graph_style.add_node_style(
+                ModuleNodeStyle(module_path=node.path, style=node_style)
+            )
 
         edges = list(filter(lambda e: e.has_node(node), self.edges))
         for edge in edges:
-            self.edges.remove(edge)
-            self.edges.add(edge.with_style(style=edge_style))
+            self.graph_style.add_edge_style(
+                ModuleEdgeStyle(
+                    tail=edge.tail.path, head=edge.head.path, style=edge_style
+                )
+            )
 
     def edge_style(
         self, tail: ModuleNode, head: ModuleNode, color: Color, penwidth: PenWidth
     ):
-        edge_style = ModuleEdgeStyle(color=color, penwidth=penwidth)
-        e = ModuleEdge(tail=tail, head=head)
+        edge_style = EdgeStyle(color=color, penwidth=penwidth)
 
-        edges = [
-            edge.with_style(edge_style) if edge.belongs_to(e) else edge
-            for edge in self.edges
-        ]
-        self.edges.clear()
-        self.edges.update(edges)
+        self.graph_style.add_edge_style(
+            ModuleEdgeStyle(tail=tail.path, head=head.path, style=edge_style)
+        )
 
     def reset_style(self) -> None:
         nodes = [node.reset_style() for node in self.nodes]
@@ -319,6 +323,8 @@ class Graph:
         self.edges.clear()
         self.edges.update(edges)
 
+        self.graph_style.reset_all_styles()
+
     def auto_highlight(self):
         source_nodes: Dict[ModuleNode, List[ModuleEdge]] = dict(
             [(node, []) for node in self.nodes]
@@ -327,12 +333,12 @@ class Graph:
             [(node, []) for node in self.nodes]
         )
         for edge in self.edges:
-            source_nodes[edge.tail].append(edge)
-            dest_nodes[edge.head].append(edge)
+            dest_nodes[edge.tail].append(edge)
+            source_nodes[edge.head].append(edge)
 
         # entrypoint
-        entrypoint_node_style = ModuleNodeStyle(
-            color=Color.Purple, fontcolor=Color.White, filled=True
+        entrypoint_node_style = NodeStyle(
+            color=Color.Teal, fontcolor=Color.White, filled=True
         )
         for node, edges in source_nodes.items():
             if len(edges) == 0:
@@ -340,8 +346,8 @@ class Graph:
                 self.nodes.add(node.with_style(style=entrypoint_node_style))
 
         # fundamental
-        fundamental_node_style = ModuleNodeStyle(
-            color=Color.Teal, fontcolor=Color.White, filled=True
+        fundamental_node_style = NodeStyle(
+            color=Color.Purple, fontcolor=Color.White, filled=True
         )
         for node, edges in dest_nodes.items():
             if len(edges) == 0:
@@ -349,9 +355,7 @@ class Graph:
                 self.nodes.add(node.with_style(style=fundamental_node_style))
 
         # both reference
-        both_reference_edge_style = ModuleEdgeStyle(
-            color=Color.Red, penwidth=PenWidth.Bold
-        )
+        both_reference_edge_style = EdgeStyle(color=Color.Red, penwidth=PenWidth.Bold)
         for edge in self.edges:
             reverse = edge.build_reverse()
             if reverse in self.edges:
diff --git a/jig/visualizer/module_dependency/domain/model/graph_style.py b/jig/visualizer/module_dependency/domain/model/graph_style.py
new file mode 100644
index 0000000..e8bdb30
--- /dev/null
+++ b/jig/visualizer/module_dependency/domain/model/graph_style.py
@@ -0,0 +1,92 @@
+import dataclasses
+from typing import List, Optional
+
+from jig.visualizer.module_dependency.domain.value.module_edge_style import (
+    ModuleEdgeStyle,
+)
+from jig.visualizer.module_dependency.domain.value.module_node_style import (
+    ModuleNodeStyle,
+)
+from jig.visualizer.module_dependency.domain.value.module_path import ModulePath
+
+
+@dataclasses.dataclass
+class GraphStyle:
+    node_styles: List[ModuleNodeStyle] = dataclasses.field(default_factory=list)
+    edge_styles: List[ModuleEdgeStyle] = dataclasses.field(default_factory=list)
+
+    def reset_all_styles(self):
+        self.reset_node_styles()
+        self.reset_edge_styles()
+
+    def reset_node_styles(self):
+        self.node_styles.clear()
+
+    def reset_edge_styles(self):
+        self.edge_styles.clear()
+
+    def add_node_style(self, node_style: ModuleNodeStyle):
+        # 同じモジュールパスへのスタイルは2つ追加させないが、適用順の整合性を合わせるため消してから後ろに追加する
+        index = next(
+            (
+                i
+                for i, v in enumerate(self.node_styles)
+                if v.module_path == node_style.module_path
+            ),
+            None,
+        )
+        if index is not None:
+            self.node_styles.pop(index)
+
+        self.node_styles.append(node_style)
+
+    def add_edge_style(self, edge_style: ModuleEdgeStyle):
+        # 同じモジュールパスへのスタイルは2つ追加させないが、適用順の整合性を合わせるため消してから後ろに追加する
+        index = next(
+            (
+                i
+                for i, style in enumerate(self.edge_styles)
+                if style.has_same_edge(edge_style)
+            ),
+            None,
+        )
+        if index is not None:
+            self.edge_styles.pop(index)
+
+        self.edge_styles.append(edge_style)
+
+    def find_node_style(self, path: ModulePath) -> Optional[ModuleNodeStyle]:
+        # 親のパスに対するスタイルも対象としてスタイルを取得する
+        styles = [
+            style for style in self.node_styles if path.belongs_to(style.module_path)
+        ]
+
+        if not styles:
+            return None
+
+        # 複数ある場合は詳細度が高い（path_levelが大きい) ものを優先して返す。
+        return sorted(styles, reverse=True, key=lambda s: s.module_path.path_level)[0]
+
+    def find_edge_style(
+        self, tail_path: ModulePath, head_path: ModulePath
+    ) -> Optional[ModuleEdgeStyle]:
+        # 親のパスに対するスタイルも対象としてスタイルを取得する
+        styles = [
+            style
+            for style in self.edge_styles
+            if tail_path.belongs_to(style.tail) and head_path.belongs_to(style.head)
+        ]
+
+        if not styles:
+            return None
+
+        # 複数ある場合は詳細度が高い（path_levelが大きい) ものを優先して返す。
+        # 特に理由はないが、tailの詳細度をheadより優先する。
+        return sorted(
+            styles,
+            reverse=True,
+            key=lambda s: (
+                s.tail.path_level,
+                s.head.path_level,
+            ),
+        )[0]
diff --git a/jig/visualizer/module_dependency/domain/value/edge_style.py b/jig/visualizer/module_dependency/domain/value/edge_style.py
new file mode 100644
index 0000000..e058622
--- /dev/null
+++ b/jig/visualizer/module_dependency/domain/value/edge_style.py
@@ -0,0 +1,39 @@
+import dataclasses
+from typing import Dict
+
+from jig.visualizer.module_dependency.domain.value.penwidth import Color, PenWidth
+
+
+@dataclasses.dataclass(frozen=True)
+class EdgeStyle:
+    color: Color = dataclasses.field(default=Color.Black)
+    fontcolor: Color = dataclasses.field(default=Color.Black)
+    penwidth: PenWidth = dataclasses.field(default=PenWidth.Normal)
+    filled: bool = False
+    invisible: bool = False
+
+    def to_dict(self) -> Dict[str, str]:
+        return {
+            "color": self.color.value,
+            "fontcolor": self.fontcolor.value,
+            "penwidth": self.penwidth.to_size(self.penwidth),
+            "style": self.style,
+            "dir": self.dir,
+        }
+
+    @property
+    def dir(self) -> str:
+        # invisible edge の場合には、無向グラフとして振る舞う
+        # (style="invisible" としても矢印が残ってしまうので)
+        if self.invisible:
+            return "none"
+        return "directed"
+
+    @property
+    def style(self) -> str:
+        if self.invisible:
+            return "invisible"
+        if self.filled:
+            return "filled"
+
+        return ""
diff --git a/jig/visualizer/module_dependency/domain/value/module_edge.py b/jig/visualizer/module_dependency/domain/value/module_edge.py
index e0a8f3f..0a08880 100644
--- a/jig/visualizer/module_dependency/domain/value/module_edge.py
+++ b/jig/visualizer/module_dependency/domain/value/module_edge.py
@@ -1,52 +1,15 @@
 import dataclasses
-from typing import Optional, List, Tuple, Dict
+from typing import Optional, List, Tuple
 
-from .penwidth import Color, PenWidth
+from jig.visualizer.module_dependency.domain.value.edge_style import EdgeStyle
 from .module_node import ModuleNode
 
 
-@dataclasses.dataclass(frozen=True)
-class ModuleEdgeStyle:
-    color: Color = dataclasses.field(default=Color.Black)
-    fontcolor: Color = dataclasses.field(default=Color.Black)
-    penwidth: PenWidth = dataclasses.field(default=PenWidth.Normal)
-    filled: bool = False
-    invisible: bool = False
-
-    def to_dict(self) -> Dict[str, str]:
-        return {
-            "color": self.color.value,
-            "fontcolor": self.fontcolor.value,
-            "penwidth": self.penwidth.to_size(self.penwidth),
-            "style": self.style,
-            "dir": self.dir,
-        }
-
-    @property
-    def dir(self) -> str:
-        # invisible edge の場合には、無向グラフとして振る舞う
-        # (style="invisible" としても矢印が残ってしまうので)
-        if self.invisible:
-            return "none"
-        return "directed"
-
-    @property
-    def style(self) -> str:
-        if self.invisible:
-            return "invisible"
-        if self.filled:
-            return "filled"
-
-        return ""
-
-
 @dataclasses.dataclass(frozen=True)
 class ModuleEdge:
     tail: ModuleNode
     head: ModuleNode
-    style: ModuleEdgeStyle = dataclasses.field(
-        default_factory=ModuleEdgeStyle, compare=False
-    )
+    style: EdgeStyle = dataclasses.field(default_factory=EdgeStyle, compare=False)
 
     @classmethod
     def from_str(cls, tail: str, head: str) -> "ModuleEdge":
@@ -54,9 +17,9 @@ class ModuleEdge:
 
     @classmethod
     def build(
-        cls, tail: ModuleNode, head: ModuleNode, style: Optional[ModuleEdgeStyle] = None
+        cls, tail: ModuleNode, head: ModuleNode, style: Optional[EdgeStyle] = None
     ) -> "ModuleEdge":
-        return cls(tail=tail, head=head, style=style or ModuleEdgeStyle())
+        return cls(tail=tail, head=head, style=style or EdgeStyle())
 
     def belongs_to(self, other: "ModuleEdge") -> bool:
         return self.tail.belongs_to(other.tail) and self.head.belongs_to(other.head)
@@ -75,20 +38,20 @@ class ModuleEdge:
         return ModuleEdge(tail=new_tail, head=new_head)
 
     def __lt__(self, other: "ModuleEdge"):
-        if self.head == other.head:
-            return self.tail < other.tail
+        if self.tail == other.tail:
+            return self.head < other.head
 
-        return self.head < other.head
+        return self.tail < other.tail
 
     def build_reverse(self) -> "ModuleEdge":
         return self.build(tail=self.head, head=self.tail, style=self.style)
 
     def to_invisible(self) -> "ModuleEdge":
         return self.build(
-            tail=self.tail, head=self.head, style=ModuleEdgeStyle(invisible=True)
+            tail=self.tail, head=self.head, style=EdgeStyle(invisible=True)
         )
 
-    def with_style(self, style: ModuleEdgeStyle) -> "ModuleEdge":
+    def with_style(self, style: EdgeStyle) -> "ModuleEdge":
         return self.build(tail=self.tail, head=self.head, style=style)
 
     def reset_style(self) -> "ModuleEdge":
diff --git a/jig/visualizer/module_dependency/domain/value/module_edge_style.py b/jig/visualizer/module_dependency/domain/value/module_edge_style.py
new file mode 100644
index 0000000..9c011b4
--- /dev/null
+++ b/jig/visualizer/module_dependency/domain/value/module_edge_style.py
@@ -0,0 +1,14 @@
+import dataclasses
+
+from jig.visualizer.module_dependency.domain.value.edge_style import EdgeStyle
+from jig.visualizer.module_dependency.domain.value.module_path import ModulePath
+
+
+@dataclasses.dataclass(frozen=True)
+class ModuleEdgeStyle:
+    tail: ModulePath
+    head: ModulePath
+    style: EdgeStyle = dataclasses.field(default_factory=EdgeStyle)
+
+    def has_same_edge(self, other: "ModuleEdgeStyle") -> bool:
+        return self.tail == other.tail and self.head == other.head
diff --git a/jig/visualizer/module_dependency/domain/value/module_node.py b/jig/visualizer/module_dependency/domain/value/module_node.py
index ff94002..e2592c7 100644
--- a/jig/visualizer/module_dependency/domain/value/module_node.py
+++ b/jig/visualizer/module_dependency/domain/value/module_node.py
@@ -1,58 +1,22 @@
 import dataclasses
-from typing import Dict, Optional
+from typing import Optional
 
-from .penwidth import Color, PenWidth
+from jig.visualizer.module_dependency.domain.value.node_style import NodeStyle
 from .module_path import ModulePath
 
 
-@dataclasses.dataclass(frozen=True)
-class ModuleNodeStyle:
-    color: Color = dataclasses.field(default=Color.Black)
-    fontcolor: Color = dataclasses.field(default=Color.Black)
-    penwidth: PenWidth = dataclasses.field(default=PenWidth.Normal)
-    filled: bool = False
-    invisible: bool = False
-
-    def to_dict(self) -> Dict[str, str]:
-        return {
-            "color": self.color.value,
-            "fontcolor": self.fontcolor.value,
-            "penwidth": self.penwidth.to_size(self.penwidth),
-            "style": self.style,
-        }
-
-    def _clone(self, **changes) -> "ModuleNodeStyle":
-        return dataclasses.replace(self, **changes)
-
-    def with_penwidth(self, penwidth: PenWidth) -> "ModuleNodeStyle":
-        return self._clone(penwidth=penwidth)
-
-    @property
-    def style(self) -> str:
-        if self.invisible:
-            return "invisible"
-        if self.filled:
-            return "filled"
-
-        return ""
-
-
 @dataclasses.dataclass(frozen=True)
 class ModuleNode:
     path: ModulePath
-    style: ModuleNodeStyle = dataclasses.field(
-        default_factory=ModuleNodeStyle, compare=False
-    )
+    style: NodeStyle = dataclasses.field(default_factory=NodeStyle, compare=False)
 
     @classmethod
     def from_str(cls, path: str) -> "ModuleNode":
         return cls(path=ModulePath(name=path))
 
     @classmethod
-    def build(
-        cls, path: ModulePath, style: Optional[ModuleNodeStyle] = None
-    ) -> "ModuleNode":
-        return cls(path=path, style=style or ModuleNodeStyle())
+    def build(cls, path: ModulePath, style: Optional[NodeStyle] = None) -> "ModuleNode":
+        return cls(path=path, style=style or NodeStyle())
 
     @property
     def name(self) -> str:
@@ -78,9 +42,9 @@ class ModuleNode:
         return ModuleNode(new_path)
 
     def to_invisible(self) -> "ModuleNode":
-        return self.build(path=self.path, style=ModuleNodeStyle(invisible=True))
+        return self.build(path=self.path, style=NodeStyle(invisible=True))
 
-    def with_style(self, style: ModuleNodeStyle) -> "ModuleNode":
+    def with_style(self, style: NodeStyle) -> "ModuleNode":
         return self.build(path=self.path, style=style)
 
     def reset_style(self) -> "ModuleNode":
diff --git a/jig/visualizer/module_dependency/domain/value/module_node_style.py b/jig/visualizer/module_dependency/domain/value/module_node_style.py
new file mode 100644
index 0000000..18006c9
--- /dev/null
+++ b/jig/visualizer/module_dependency/domain/value/module_node_style.py
@@ -0,0 +1,10 @@
+import dataclasses
+
+from jig.visualizer.module_dependency.domain.value.module_path import ModulePath
+from jig.visualizer.module_dependency.domain.value.node_style import NodeStyle
+
+
+@dataclasses.dataclass(frozen=True)
+class ModuleNodeStyle:
+    module_path: ModulePath
+    style: NodeStyle = dataclasses.field(default_factory=NodeStyle)
diff --git a/jig/visualizer/module_dependency/domain/value/node_style.py b/jig/visualizer/module_dependency/domain/value/node_style.py
new file mode 100644
index 0000000..8033c5a
--- /dev/null
+++ b/jig/visualizer/module_dependency/domain/value/node_style.py
@@ -0,0 +1,36 @@
+import dataclasses
+from typing import Dict
+
+from jig.visualizer.module_dependency.domain.value.penwidth import Color, PenWidth
+
+
+@dataclasses.dataclass(frozen=True)
+class NodeStyle:
+    color: Color = dataclasses.field(default=Color.Black)
+    fontcolor: Color = dataclasses.field(default=Color.Black)
+    penwidth: PenWidth = dataclasses.field(default=PenWidth.Normal)
+    filled: bool = False
+    invisible: bool = False
+
+    def to_dict(self) -> Dict[str, str]:
+        return {
+            "color": self.color.value,
+            "fontcolor": self.fontcolor.value,
+            "penwidth": self.penwidth.to_size(self.penwidth),
+            "style": self.style,
+        }
+
+    def _clone(self, **changes) -> "NodeStyle":
+        return dataclasses.replace(self, **changes)
+
+    def with_penwidth(self, penwidth: PenWidth) -> "NodeStyle":
+        return self._clone(penwidth=penwidth)
+
+    @property
+    def style(self) -> str:
+        if self.invisible:
+            return "invisible"
+        if self.filled:
+            return "filled"
+
+        return ""
diff --git a/jig/visualizer/module_dependency/presentation/renderer/graph_renderer.py b/jig/visualizer/module_dependency/presentation/renderer/graph_renderer.py
index 4f2945e..49c9b87 100644
--- a/jig/visualizer/module_dependency/presentation/renderer/graph_renderer.py
+++ b/jig/visualizer/module_dependency/presentation/renderer/graph_renderer.py
@@ -14,14 +14,22 @@ class GraphRenderer:
         d = Digraph()
 
         for node in sorted(self.graph.nodes):
-            node_options = node.style.to_dict()
+            module_node_style = self.graph.graph_style.find_node_style(node.path)
+            style = module_node_style.style if module_node_style else node.style
+
+            node_options = style.to_dict()
             # 描画する node の配下に位置する node が存在する場合には、 shape=rect として表示する
             if self.graph.child_node_exists(node):
                 node_options["shape"] = "rect"
             d.node(name=node.path.name, **node_options)
 
         for edge in sorted(self.graph.edges):
-            d.edge(edge.tail.name, edge.head.name, **edge.style.to_dict())
+            module_edge_style = self.graph.graph_style.find_edge_style(
+                edge.tail.path, edge.head.path
+            )
+            edge_style = module_edge_style.style if module_edge_style else edge.style
+
+            d.edge(edge.tail.name, edge.head.name, **edge_style.to_dict())
 
         for cluster in self.graph.clusters.values():
             cluster_renderer = ClusterRenderer(cluster)


Test patch:
diff --git a/tests/visualizer/module_dependency/domain/model/test_graph_style.py b/tests/visualizer/module_dependency/domain/model/test_graph_style.py
new file mode 100644
index 0000000..2ce5a32
--- /dev/null
+++ b/tests/visualizer/module_dependency/domain/model/test_graph_style.py
@@ -0,0 +1,121 @@
+from typing import Optional
+
+from jig.visualizer.module_dependency.domain.model.graph_style import GraphStyle
+from jig.visualizer.module_dependency.domain.value.edge_style import EdgeStyle
+from jig.visualizer.module_dependency.domain.value.module_edge_style import (
+    ModuleEdgeStyle,
+)
+from jig.visualizer.module_dependency.domain.value.module_node_style import (
+    ModuleNodeStyle,
+)
+from jig.visualizer.module_dependency.domain.value.module_path import ModulePath
+from jig.visualizer.module_dependency.domain.value.node_style import NodeStyle
+from jig.visualizer.module_dependency.domain.value.penwidth import Color
+
+
+def path(path_str: str):
+    return ModulePath(path_str)
+
+
+def node_style(path_str: str, style: Optional[NodeStyle] = None):
+    if not node_style:
+        return ModuleNodeStyle(module_path=path(path_str))
+
+    return ModuleNodeStyle(module_path=path(path_str), style=style)
+
+
+def edge_style(tail: str, head: str, style: Optional[EdgeStyle] = None):
+    if not node_style:
+        return ModuleEdgeStyle(tail=path(tail), head=path(head))
+
+    return ModuleEdgeStyle(
+        tail=path(tail),
+        head=path(head),
+        style=style,
+    )
+
+
+class TestGraphStyle:
+    def test_reset_all_styles(self):
+        graph_style = GraphStyle()
+
+        graph_style.add_node_style(node_style("jig"))
+        graph_style.add_edge_style(edge_style("build", "jig"))
+
+        graph_style.reset_all_styles()
+        assert graph_style == GraphStyle()
+
+    def test_add_node_style(self):
+        graph_style = GraphStyle()
+
+        assert len(graph_style.node_styles) == 0
+
+        jig_style = node_style("jig", NodeStyle(color=Color.Blue))
+
+        graph_style.add_node_style(jig_style)
+        assert len(graph_style.node_styles) == 1
+
+        tests_style = node_style("tests")
+        graph_style.add_node_style(tests_style)
+
+        assert len(graph_style.node_styles) == 2
+        assert graph_style.node_styles == [jig_style, tests_style]
+
+        jig_style2 = node_style("jig", NodeStyle(color=Color.Red))
+
+        graph_style.add_node_style(jig_style2)
+        assert len(graph_style.node_styles) == 2
+        assert graph_style.node_styles == [tests_style, jig_style2]
+
+    def test_add_edge_style(self):
+        graph_style = GraphStyle()
+
+        assert len(graph_style.edge_styles) == 0
+
+        style1 = edge_style("tests", "jig", EdgeStyle(color=Color.Red))
+        graph_style.add_edge_style(style1)
+        assert len(graph_style.edge_styles) == 1
+
+        style2 = edge_style("build", "jig", EdgeStyle(color=Color.Blue))
+        graph_style.add_edge_style(style2)
+        assert len(graph_style.edge_styles) == 2
+        assert graph_style.edge_styles == [style1, style2]
+
+        style3 = edge_style("tests", "jig", EdgeStyle(color=Color.Green))
+        graph_style.add_edge_style(style3)
+        assert len(graph_style.edge_styles) == 2
+        assert graph_style.edge_styles == [style2, style3]
+
+    def test_find_node_style(self):
+        graph_style = GraphStyle()
+
+        style = node_style("jig.collector", NodeStyle(color=Color.Blue))
+        graph_style.add_node_style(style)
+
+        assert graph_style.find_node_style(path("jig")) is None
+        assert graph_style.find_node_style(path("jig.collector")) == style
+        assert graph_style.find_node_style(path("jig.collector.domain")) == style
+
+        # より詳細度の低いスタイルの適用
+        jig_style = node_style("jig", NodeStyle(color=Color.Green))
+        graph_style.add_node_style(jig_style)
+        assert graph_style.find_node_style(path("jig")) == jig_style
+        assert graph_style.find_node_style(path("jig.collector")) == style
+        assert graph_style.find_node_style(path("jig.collector.domain")) == style
+
+    def test_find_edge_style(self):
+        gs = GraphStyle()
+
+        style1 = edge_style("tests", "jig.collector", EdgeStyle(color=Color.Green))
+        gs.add_edge_style(style1)
+
+        assert gs.find_edge_style(path("tests"), path("jig")) is None
+        assert gs.find_edge_style(path("tests"), path("jig.collector")) == style1
+        assert gs.find_edge_style(path("tests"), path("jig.collector.domain")) == style1
+
+        style2 = edge_style("tests", "jig", EdgeStyle(color=Color.Red))
+        gs.add_edge_style(style2)
+
+        assert gs.find_edge_style(path("tests"), path("jig")) == style2
+        assert gs.find_edge_style(path("tests"), path("jig.collector")) == style1
+        assert gs.find_edge_style(path("tests"), path("jig.collector.domain")) == style1
diff --git a/tests/visualizer/module_dependency/presentation/renderer/test_graph_renderer.py b/tests/visualizer/module_dependency/presentation/renderer/test_graph_renderer.py
index c277c9d..e0aab27 100644
--- a/tests/visualizer/module_dependency/presentation/renderer/test_graph_renderer.py
+++ b/tests/visualizer/module_dependency/presentation/renderer/test_graph_renderer.py
@@ -1,16 +1,18 @@
 from graphviz import Digraph
 
 from jig.visualizer.module_dependency.domain.model.graph import Graph
+from jig.visualizer.module_dependency.domain.model.master_graph import MasterGraph
 from jig.visualizer.module_dependency.domain.value.cluster import Cluster
 from jig.visualizer.module_dependency.domain.value.module_edge import (
     ModuleEdge,
-    ModuleEdgeStyle,
 )
+from jig.visualizer.module_dependency.domain.value.edge_style import EdgeStyle
 from jig.visualizer.module_dependency.domain.value.module_node import (
     ModuleNode,
-    ModuleNodeStyle,
 )
 from jig.visualizer.module_dependency.domain.value.module_path import ModulePath
+from jig.visualizer.module_dependency.domain.value.node_style import NodeStyle
+from jig.visualizer.module_dependency.domain.value.penwidth import Color, PenWidth
 from jig.visualizer.module_dependency.presentation.renderer.graph_renderer import (
     GraphRenderer,
 )
@@ -45,11 +47,11 @@ class TestGraphRenderer:
 
         g = Digraph()
 
-        node_style = ModuleNodeStyle().to_dict()
+        node_style = NodeStyle().to_dict()
         g.node("jig.analyzer", **node_style)
         g.node("jig.collector", **node_style)
 
-        edge_style = ModuleEdgeStyle().to_dict()
+        edge_style = EdgeStyle().to_dict()
         g.edge("jig.analyzer", "jig.collector", **edge_style)
 
         child = Digraph(name="cluster_jig")
@@ -65,3 +67,121 @@ class TestGraphRenderer:
         g.subgraph(child)
 
         assert str(renderer.render()) == str(g)
+
+    def test_render__with_style(self):
+        master_graph = MasterGraph.from_tuple_list([("a", "b"), ("b", "c")])
+        graph = Graph(master_graph)
+
+        graph.style(
+            node("b"), color=Color.Blue, fontcolor=Color.Red, penwidth=PenWidth.Bold
+        )
+        renderer = GraphRenderer(graph=graph)
+
+        g = Digraph()
+        default_node_style = NodeStyle().to_dict()
+        node_style = NodeStyle().to_dict()
+        node_style.update(
+            {
+                "color": "blue",
+                "fontcolor": "red",
+                "penwidth": PenWidth.to_size(PenWidth.Bold),
+            }
+        )
+        g.node("a", **default_node_style)
+        g.node("b", **node_style)
+        g.node("c", **default_node_style)
+
+        edge_style = EdgeStyle().to_dict()
+        edge_style.update(
+            {
+                "color": "blue",
+                "fontcolor": "red",
+                "penwidth": PenWidth.to_size(PenWidth.Bold),
+            }
+        )
+        g.edge("a", "b", **edge_style)
+        g.edge("b", "c", **edge_style)
+
+        assert str(renderer.render()) == str(g)
+
+    def test_render__with_edge_style(self):
+        master_graph = MasterGraph.from_tuple_list([("a", "b"), ("b", "c")])
+        graph = Graph(master_graph)
+
+        graph.edge_style(node("a"), node("b"), color=Color.Red, penwidth=PenWidth.Thin)
+        renderer = GraphRenderer(graph=graph)
+
+        g = Digraph()
+        default_node_style = NodeStyle().to_dict()
+        g.node("a", **default_node_style)
+        g.node("b", **default_node_style)
+        g.node("c", **default_node_style)
+
+        default_edge_style = EdgeStyle().to_dict()
+        edge_style = EdgeStyle().to_dict()
+        edge_style.update({"color": "red", "penwidth": PenWidth.to_size(PenWidth.Thin)})
+        g.edge("a", "b", **edge_style)
+        g.edge("b", "c", **default_edge_style)
+
+        assert str(renderer.render()) == str(g)
+
+    def test_render__reset_style(self):
+        master_graph = MasterGraph.from_tuple_list([("a", "b"), ("b", "c")])
+        graph = Graph(master_graph)
+
+        graph.style(
+            node("b"), color=Color.Blue, fontcolor=Color.Red, penwidth=PenWidth.Bold
+        )
+        graph.reset_style()
+        renderer = GraphRenderer(graph=graph)
+
+        g = Digraph()
+        default_node_style = NodeStyle().to_dict()
+        g.node("a", **default_node_style)
+        g.node("b", **default_node_style)
+        g.node("c", **default_node_style)
+
+        default_edge_style = EdgeStyle().to_dict()
+        g.edge("a", "b", **default_edge_style)
+        g.edge("b", "c", **default_edge_style)
+
+        assert str(renderer.render()) == str(g)
+
+    def test_render__with_autohighlight(self):
+        # +---+    +---+    +---+    +---+
+        # |   |    |   |--->|   |    |   |
+        # | a |--->| b |    | c |--->| d |
+        # |   |    |   |<---|   |    |   |
+        # +---+    +---+    +---+    +---+
+        master_graph = MasterGraph.from_tuple_list(
+            [("a", "b"), ("b", "c"), ("c", "b"), ("c", "d")]
+        )
+        graph = Graph(master_graph)
+        renderer = GraphRenderer(graph=graph)
+
+        graph.auto_highlight()
+
+        g = Digraph()
+
+        default_node_style = NodeStyle().to_dict()
+        entry_point_node_style = NodeStyle(
+            color=Color.Teal, fontcolor=Color.White, filled=True
+        ).to_dict()
+        fundamental_node_style = NodeStyle(
+            color=Color.Purple, fontcolor=Color.White, filled=True
+        ).to_dict()
+        g.node("a", **entry_point_node_style)
+        g.node("b", **default_node_style)
+        g.node("c", **default_node_style)
+        g.node("d", **fundamental_node_style)
+
+        default_edge_style = EdgeStyle().to_dict()
+        warning_edge_style = EdgeStyle(
+            color=Color.Red, penwidth=PenWidth.Bold
+        ).to_dict()
+        g.edge("a", "b", **default_edge_style)
+        g.edge("b", "c", **warning_edge_style)
+        g.edge("c", "b", **warning_edge_style)
+        g.edge("c", "d", **default_edge_style)
+
+        assert str(renderer.render()) == str(g)