diff --git a/backend/app/core/wiki_content_writer/diagram_generator.py b/backend/app/core/wiki_content_writer/diagram_generator.py index 028d23e..6dfd594 100644 --- a/backend/app/core/wiki_content_writer/diagram_generator.py +++ b/backend/app/core/wiki_content_writer/diagram_generator.py @@ -96,6 +96,33 @@ def _node_name(node: dict) -> str: "member_uses", "inheritance", "composition", } +# Symbol types considered "class-like" for classDiagram. +_CLASS_LIKE_TYPES = {"class", "interface", "struct", "enum", "trait"} + +# Symbol types considered member-level (rendered inside the class body). +_MEMBER_TYPES = {"method", "function", "property", "field"} + +# Maximum class nodes to include in a classDiagram. +_MAX_CLASS_NODES = 8 + +# Maximum data-model nodes to include in an erDiagram. +_MAX_DATA_MODEL_NODES = 8 + +# Source-text patterns that identify data-model classes. +_DATA_MODEL_PATTERNS = ( + "BaseModel", + "@dataclass", + "db.Model", + "DeclarativeBase", + "Table(", + "Column(", + "@Entity", + "@Column", + "@Table", + 'gorm:"', + 'json:"', +) + class DiagramGenerator: def __init__(self, storage: Any, llm_client: Any = None) -> None: @@ -232,6 +259,226 @@ def generate_cluster_diagram(self, cluster_id: int, page_spec: Any) -> str: return f"## Architecture\n\n{explanation}\n\n{mermaid}" return f"## Architecture\n\n{mermaid}" + # ── New diagram methods ───────────────────────────────────────────────────── + + def generate_class_diagram(self, cluster_id: int) -> str: + """Generate a Mermaid classDiagram for class-like nodes in a cluster. + + Returns a fenced ``classDiagram`` Mermaid block, or ``''`` when fewer + than 2 class-like nodes are found or on any storage error. + """ + if self.storage is None: + return "" + try: + return self._build_class_diagram(cluster_id) + except Exception as exc: + logger.debug("generate_class_diagram cluster_id=%d error: %s", cluster_id, exc) + return "" + + def _build_class_diagram(self, cluster_id: int) -> str: + all_nodes = self.storage.get_nodes_by_cluster(cluster_id) + if not all_nodes: + return "" + + # Split into class-like nodes and potential members. + class_nodes = [ + n for n in all_nodes + if (n.get("symbol_type") or n.get("kind", "")) in _CLASS_LIKE_TYPES + ] + if len(class_nodes) < 2: + return "" + + # Cap at _MAX_CLASS_NODES; prefer architectural nodes when over cap. + class_nodes = _select_class_nodes(class_nodes) + + class_node_ids: set[str] = {n["node_id"] for n in class_nodes} + class_name_to_id: dict[str, str] = { + _node_name(n): n["node_id"] for n in class_nodes + } + + # Collect member nodes (methods, properties, fields) whose parent_symbol + # matches one of the selected class nodes. + class_names: set[str] = set(class_name_to_id) + member_map: dict[str, list[dict]] = {_node_name(n): [] for n in class_nodes} + for n in all_nodes: + if (n.get("symbol_type") or n.get("kind", "")) in _MEMBER_TYPES: + parent = n.get("parent_symbol") + if parent and parent in class_names: + member_map[parent].append(n) + + # Collect inheritance edges within the selected class nodes. + inheritance_edges: list[tuple[str, str]] = [] + seen_inh: set[tuple[str, str]] = set() + for n in class_nodes: + nid = n["node_id"] + try: + raw = self.storage.get_edges_from(nid, rel_types=["inheritance"]) + except Exception: + raw = [] + for e in raw: + tgt_id = e.get("target_id", "") + if tgt_id in class_node_ids: + pair = (nid, tgt_id) + if pair not in seen_inh: + seen_inh.add(pair) + inheritance_edges.append(pair) + + id_to_name: dict[str, str] = {n["node_id"]: _node_name(n) for n in class_nodes} + + return _render_class_diagram(class_nodes, member_map, inheritance_edges, id_to_name) + + def generate_data_model_diagram(self, cluster_id: int) -> str: + """Generate a Mermaid erDiagram for data-model nodes in a cluster. + + Returns a fenced ``erDiagram`` Mermaid block, or ``''`` when fewer than + 2 data-model nodes are found or on any storage error. + """ + if self.storage is None: + return "" + try: + return self._build_data_model_diagram(cluster_id) + except Exception as exc: + logger.debug("generate_data_model_diagram cluster_id=%d error: %s", cluster_id, exc) + return "" + + def _build_data_model_diagram(self, cluster_id: int) -> str: + all_nodes = self.storage.get_nodes_by_cluster(cluster_id) + if not all_nodes: + return "" + + # Filter to struct/class nodes that look like data models. + model_nodes = [ + n for n in all_nodes + if (n.get("symbol_type") or n.get("kind", "")) in {"class", "struct"} + and _is_data_model(n) + ] + if len(model_nodes) < 2: + return "" + + # Cap at _MAX_DATA_MODEL_NODES. + if len(model_nodes) > _MAX_DATA_MODEL_NODES: + arch = [n for n in model_nodes if n.get("is_architectural")] + model_nodes = (arch if arch else model_nodes)[:_MAX_DATA_MODEL_NODES] + + model_node_ids: set[str] = {n["node_id"] for n in model_nodes} + + # Collect composition/references edges between model nodes. + rel_edges: list[tuple[str, str]] = [] + seen_rel: set[tuple[str, str]] = set() + for n in model_nodes: + nid = n["node_id"] + try: + raw = self.storage.get_edges_from(nid, rel_types=["composition", "references"]) + except Exception: + raw = [] + for e in raw: + tgt_id = e.get("target_id", "") + if tgt_id in model_node_ids: + pair = (nid, tgt_id) + if pair not in seen_rel: + seen_rel.add(pair) + rel_edges.append(pair) + + id_to_name: dict[str, str] = {n["node_id"]: _node_name(n) for n in model_nodes} + return _render_er_diagram(model_nodes, rel_edges, id_to_name) + + def generate_cluster_context_diagram( + self, + cluster_id: int, + cluster_title: str, + all_cluster_ids: list[int], + cluster_titles: dict[int, str], + ) -> str: + """Generate a Mermaid graph LR showing the cluster as a subgraph. + + The cluster's own architectural nodes are rendered inside a ``subgraph`` + block. When *all_cluster_ids* and *cluster_titles* are provided, + cross-cluster edges are detected and neighbouring clusters are rendered + as plain external nodes. + + Returns a fenced ``graph LR`` block, or ``''`` on empty cluster / + storage error. + """ + if self.storage is None: + return "" + try: + return self._build_cluster_context_diagram( + cluster_id, cluster_title, all_cluster_ids, cluster_titles + ) + except Exception as exc: + logger.debug( + "generate_cluster_context_diagram cluster_id=%d error: %s", cluster_id, exc + ) + return "" + + def _build_cluster_context_diagram( + self, + cluster_id: int, + cluster_title: str, + all_cluster_ids: list[int], + cluster_titles: dict[int, str], + ) -> str: + nodes = self.storage.get_nodes_by_cluster(cluster_id) + if not nodes: + return "" + + nodes = _select_nodes(nodes) + if not nodes: + return "" + + node_ids: set[str] = {n["node_id"] for n in nodes} + id_to_name: dict[str, str] = {n["node_id"]: _node_name(n) for n in nodes} + + # Collect intra-cluster edges. + intra_edges: list[tuple[str, str]] = [] + seen_intra: set[tuple[str, str]] = set() + + # Build a node_id → external cluster_id lookup if cross-cluster info given. + node_to_ext_cluster: dict[str, int] = {} + if all_cluster_ids and cluster_titles: + for ext_cid in all_cluster_ids: + if ext_cid == cluster_id: + continue + try: + ext_nodes = self.storage.get_nodes_by_cluster(ext_cid) + except Exception: + ext_nodes = [] + for en in ext_nodes: + node_to_ext_cluster[en["node_id"]] = ext_cid + + cross_cluster_edges: list[tuple[str, int]] = [] # (src_node_id, tgt_cluster_id) + seen_cross: set[tuple[str, int]] = set() + + for n in nodes: + nid = n["node_id"] + try: + raw = self.storage.get_edges_from(nid, rel_types=list(_CROSS_CLUSTER_REL_TYPES)) + except Exception: + raw = [] + for e in raw: + tgt_id = e.get("target_id", "") + if tgt_id in node_ids: + pair = (nid, tgt_id) + if pair not in seen_intra: + seen_intra.add(pair) + intra_edges.append(pair) + elif tgt_id in node_to_ext_cluster: + ext_cid = node_to_ext_cluster[tgt_id] + cross_pair = (nid, ext_cid) + if cross_pair not in seen_cross: + seen_cross.add(cross_pair) + cross_cluster_edges.append(cross_pair) + + return _render_cluster_context( + cluster_id=cluster_id, + cluster_title=cluster_title, + nodes=nodes, + id_to_name=id_to_name, + intra_edges=intra_edges, + cross_cluster_edges=cross_cluster_edges, + cluster_titles=cluster_titles, + ) + def _fix_mermaid(self, mermaid: str, reason: str) -> str: """Ask the LLM to fix invalid Mermaid syntax. Returns fixed diagram or ''.""" if self._llm is None: @@ -350,6 +597,165 @@ def _render(id_to_name: dict[str, str], edges: list[tuple[str, str]]) -> str: return "\n".join(lines) +def _select_class_nodes(nodes: list[dict]) -> list[dict]: + """Cap class-like nodes at _MAX_CLASS_NODES; prefer architectural ones.""" + if len(nodes) <= _MAX_CLASS_NODES: + return nodes + arch = [n for n in nodes if n.get("is_architectural")] + candidates = arch if arch else nodes + return candidates[:_MAX_CLASS_NODES] + + +def _is_data_model(node: dict) -> bool: + """Return True when a node's source_text looks like a data-model class.""" + source = node.get("source_text") or "" + return any(pattern in source for pattern in _DATA_MODEL_PATTERNS) + + +def _is_abstract(node: dict) -> bool: + """Heuristic: does the class appear to be abstract?""" + src = node.get("source_text") or "" + return "abstract" in src or "ABC" in src or "@abstractmethod" in src + + +def _method_visibility(name: str) -> str: + """Return Mermaid visibility prefix: + public, - private.""" + if name.startswith("_"): + return "-" + return "+" + + +def _method_signature_line(member: dict) -> str: + """Build a Mermaid member line like ``+process(x int) str``.""" + raw_name = _node_name(member) + # Strip leading path segments (keep only the part after the last dot). + display_name = raw_name.rsplit(".", 1)[-1] + visibility = _method_visibility(display_name) + + sig = (member.get("signature") or "").strip() + if sig: + # Strip the leading 'def ' / 'func ' / 'fn ' keyword if present. + sig = re.sub(r"^(def|func|fn|function)\s+", "", sig) + # Strip leading path prefix from the signature name as well. + sig = re.sub(r"^[A-Za-z0-9_.]+\.", "", sig) + return f"{visibility}{_sanitize_label(sig)}" + + params = (member.get("parameters") or "").strip() + ret = (member.get("return_type") or "").strip() + if ret: + return f"{visibility}{_sanitize_label(display_name)}({_sanitize_label(params)}) {_sanitize_label(ret)}" + return f"{visibility}{_sanitize_label(display_name)}({_sanitize_label(params)})" + + +def _render_class_diagram( + class_nodes: list[dict], + member_map: dict[str, list[dict]], + inheritance_edges: list[tuple[str, str]], + id_to_name: dict[str, str], +) -> str: + """Build a Mermaid classDiagram block.""" + lines: list[str] = ["```mermaid", "classDiagram"] + + for n in class_nodes: + cname = _node_name(n) + sym_type = n.get("symbol_type") or n.get("kind", "class") + lines.append(f" class {_safe_id(cname)} {{") + # Stereotype annotation. + if sym_type == "interface": + lines.append(f" <>") + elif sym_type == "enum": + lines.append(f" <>") + elif _is_abstract(n): + lines.append(f" <>") + # Member lines. + for member in member_map.get(cname, []): + lines.append(f" {_method_signature_line(member)}") + lines.append(" }") + + # Inheritance edges: Child --|> Parent + for src_id, tgt_id in inheritance_edges: + src_name = _safe_id(id_to_name[src_id]) + tgt_name = _safe_id(id_to_name[tgt_id]) + lines.append(f" {src_name} --|> {tgt_name} : implements") + + lines.append("```") + return "\n".join(lines) + + +def _render_er_diagram( + model_nodes: list[dict], + rel_edges: list[tuple[str, str]], + id_to_name: dict[str, str], +) -> str: + """Build a Mermaid erDiagram block.""" + lines: list[str] = ["```mermaid", "erDiagram"] + + for n in model_nodes: + entity_name = _safe_id(_node_name(n)) + lines.append(f" {entity_name} {{") + lines.append(" }") + + for src_id, tgt_id in rel_edges: + src = _safe_id(id_to_name[src_id]) + tgt = _safe_id(id_to_name[tgt_id]) + lines.append(f' {src} ||--o{{ {tgt} : "has"') + + lines.append("```") + return "\n".join(lines) + + +def _render_cluster_context( + cluster_id: int, + cluster_title: str, + nodes: list[dict], + id_to_name: dict[str, str], + intra_edges: list[tuple[str, str]], + cross_cluster_edges: list[tuple[str, int]], + cluster_titles: dict[int, str], +) -> str: + """Build a Mermaid graph LR with a subgraph for the cluster.""" + lines: list[str] = ["```mermaid", "graph LR"] + + safe_title = _sanitize_label(cluster_title) + lines.append(f' subgraph "{safe_title}"') + for n in nodes: + nid = n["node_id"] + name = _node_name(n) + safe = _safe_id(name) + label = _sanitize_label(name) + lines.append(f' {safe}["{label}"]') + lines.append(" end") + + # External cluster nodes (one node per neighbouring cluster). + seen_ext: set[int] = set() + for _src_nid, ext_cid in cross_cluster_edges: + if ext_cid not in seen_ext: + seen_ext.add(ext_cid) + ext_title = cluster_titles.get(ext_cid) or f"cluster_{ext_cid}" + ext_safe = _safe_id(ext_title[:_MAX_LABEL_CHARS]) + ext_label = _sanitize_label(ext_title[:_MAX_LABEL_CHARS]) + lines.append(f' {ext_safe}["{ext_label}"]') + + # Intra-cluster edges. + for src_id, tgt_id in intra_edges: + src = _safe_id(id_to_name[src_id]) + tgt = _safe_id(id_to_name[tgt_id]) + lines.append(f" {src} --> {tgt}") + + # Cross-cluster edges: source node → external cluster node. + for src_nid, ext_cid in cross_cluster_edges: + src_name = id_to_name.get(src_nid) + if src_name is None: + continue + src_safe = _safe_id(src_name) + ext_title = cluster_titles.get(ext_cid) or f"cluster_{ext_cid}" + ext_safe = _safe_id(ext_title[:_MAX_LABEL_CHARS]) + lines.append(f" {src_safe} --> {ext_safe}") + + lines.append("```") + return "\n".join(lines) + + def _render_repo_clusters( cluster_ids: list[int], cluster_titles: dict[int, str], diff --git a/backend/app/core/wiki_content_writer/writer_agent.py b/backend/app/core/wiki_content_writer/writer_agent.py index 6e0758e..5260408 100644 --- a/backend/app/core/wiki_content_writer/writer_agent.py +++ b/backend/app/core/wiki_content_writer/writer_agent.py @@ -833,6 +833,11 @@ async def _generate_one(sp: "SubPageSpec", sl: "EvidenceSlice") -> str: ), ) + # ── 4. Inject cluster diagrams into the chapter index ───────── + chapter_index_md = self._inject_cluster_diagrams( + chapter_index_md, chapter_spec.cluster_id, chapter_spec.chapter_title + ) + logger.info( "[WRITER] generate_chapter done: title=%r subpages=%d index_chars=%d", chapter_spec.chapter_title, @@ -841,6 +846,61 @@ async def _generate_one(sp: "SubPageSpec", sl: "EvidenceSlice") -> str: ) return chapter_index_md, subpage_mds + def _inject_cluster_diagrams( + self, + chapter_index_md: str, + cluster_id: int, + chapter_title: str, + ) -> str: + """Inject Mermaid diagrams into a chapter _index.md after ## Overview. + + Inserts Architecture / Class Structure / Data Model sections before + ``## Key Components`` (or ``## Sub-pages`` when Key Components is + absent). Returns the original markdown unchanged on any error. + """ + storage = self.tools.storage + if storage is None: + return chapter_index_md + + try: + from app.core.wiki_content_writer.diagram_generator import DiagramGenerator # noqa: PLC0415 + _dg = DiagramGenerator(storage, llm_client=self.llm_client) + _diagram_sections: list[str] = [] + + arch_diag = _dg.generate_cluster_context_diagram( + cluster_id, chapter_title, [], {} + ) + if arch_diag: + _diagram_sections.append(f"## Architecture\n\n{arch_diag}") + + class_diag = _dg.generate_class_diagram(cluster_id) + if class_diag: + _diagram_sections.append(f"## Class Structure\n\n{class_diag}") + + data_diag = _dg.generate_data_model_diagram(cluster_id) + if data_diag: + _diagram_sections.append(f"## Data Model\n\n{data_diag}") + + if not _diagram_sections: + return chapter_index_md + + _injected = "\n\n".join(_diagram_sections) + + for _marker in ("## Key Components", "## Sub-pages"): + _pos = chapter_index_md.find(_marker) + if _pos >= 0: + return ( + chapter_index_md[:_pos].rstrip() + + f"\n\n{_injected}\n\n" + + chapter_index_md[_pos:] + ) + + return chapter_index_md.rstrip() + f"\n\n{_injected}" + + except Exception as exc: + logger.debug("[WRITER] _inject_cluster_diagrams failed: %s", exc) + return chapter_index_md + # ── Tool dispatch ───────────────────────────────────────────────────── def _dispatch_tool(self, name: str, args: dict[str, Any]) -> str: diff --git a/backend/tests/unit/wiki_content_writer/test_diagram_generator.py b/backend/tests/unit/wiki_content_writer/test_diagram_generator.py index d9ffd75..2df00d3 100644 --- a/backend/tests/unit/wiki_content_writer/test_diagram_generator.py +++ b/backend/tests/unit/wiki_content_writer/test_diagram_generator.py @@ -71,6 +71,42 @@ def _get_edges_from(node_id, rel_types=None): return storage +def _class_node( + node_id: str, + symbol_name: str, + symbol_type: str = "class", + is_architectural: int = 1, + source_text: str = "", + parent_symbol: str | None = None, + signature: str = "", + parameters: str = "", + return_type: str = "", + macro_cluster: int = 1, + micro_cluster: int = 0, +) -> dict[str, Any]: + """Create a node dict matching the real storage API schema.""" + return { + "node_id": node_id, + "symbol_name": symbol_name, + "symbol_type": symbol_type, + "is_architectural": is_architectural, + "source_text": source_text, + "parent_symbol": parent_symbol, + "signature": signature, + "parameters": parameters, + "return_type": return_type, + "macro_cluster": macro_cluster, + "micro_cluster": micro_cluster, + # Legacy aliases so _node_name() works for both formats. + "name": symbol_name, + } + + +def _storage_edge(target_id: str, rel_type: str = "inheritance") -> dict[str, Any]: + """Edge dict as returned by get_edges_from.""" + return {"target_id": target_id, "rel_type": rel_type} + + # ── Scenario 1: 3 nodes, 2 edges ────────────────────────────────────────────── @@ -290,3 +326,525 @@ def test_node_id_has_no_spaces(self): # A valid Mermaid node id with spaces is quoted: A["My Worker Class"] # If the name appears only inside quotes it's fine assert '["My Worker Class"]' in stripped or "My_Worker_Class" in stripped + + +# ═══════════════════════════════════════════════════════════════════════════════ +# NEW: generate_class_diagram +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestGenerateClassDiagram: + """Unit tests for DiagramGenerator.generate_class_diagram.""" + + def _make_dg(self, nodes, edges_map=None): + def _side_effect(node_id, rel_types=None): + if edges_map: + return edges_map.get(node_id, []) + return [] + + storage = MagicMock() + storage.get_nodes_by_cluster.return_value = nodes + storage.get_edges_from.side_effect = _side_effect + storage.get_node.side_effect = lambda nid: next( + (n for n in nodes if n["node_id"] == nid), None + ) + return DiagramGenerator(storage) + + # ── Happy path: 2 classes → classDiagram emitted ────────────────────── + + def test_two_classes_produces_class_diagram(self): + nodes = [ + _class_node("c1", "Foo", "class"), + _class_node("c2", "Bar", "class"), + ] + dg = self._make_dg(nodes) + result = dg.generate_class_diagram(cluster_id=1) + assert "classDiagram" in result + assert "```mermaid" in result + assert result.strip().endswith("```") + + def test_class_names_appear_in_output(self): + nodes = [ + _class_node("c1", "Foo", "class"), + _class_node("c2", "Bar", "class"), + ] + dg = self._make_dg(nodes) + result = dg.generate_class_diagram(cluster_id=1) + assert "Foo" in result + assert "Bar" in result + + # ── Interface stereotype ─────────────────────────────────────────────── + + def test_interface_node_gets_interface_decorator(self): + nodes = [ + _class_node("c1", "IFoo", "interface"), + _class_node("c2", "Bar", "class"), + ] + dg = self._make_dg(nodes) + result = dg.generate_class_diagram(cluster_id=1) + assert "<>" in result + + def test_enum_node_gets_enumeration_decorator(self): + nodes = [ + _class_node("c1", "Status", "enum"), + _class_node("c2", "Bar", "class"), + ] + dg = self._make_dg(nodes) + result = dg.generate_class_diagram(cluster_id=1) + assert "<>" in result + + # ── Abstract detection ───────────────────────────────────────────────── + + def test_abstract_class_gets_abstract_decorator(self): + nodes = [ + _class_node("c1", "Base", "class", source_text="class Base(ABC):\n @abstractmethod\n def run(self): ..."), + _class_node("c2", "Concrete", "class"), + ] + dg = self._make_dg(nodes) + result = dg.generate_class_diagram(cluster_id=1) + assert "<>" in result + + # ── Fewer than 2 class nodes → empty ────────────────────────────────── + + def test_single_class_node_returns_empty(self): + nodes = [_class_node("c1", "Lonely", "class")] + dg = self._make_dg(nodes) + result = dg.generate_class_diagram(cluster_id=1) + assert result == "" + + def test_no_nodes_returns_empty(self): + storage = MagicMock() + storage.get_nodes_by_cluster.return_value = [] + storage.get_edges_from.return_value = [] + dg = DiagramGenerator(storage) + result = dg.generate_class_diagram(cluster_id=1) + assert result == "" + + def test_none_storage_returns_empty(self): + dg = DiagramGenerator(None) + result = dg.generate_class_diagram(cluster_id=1) + assert result == "" + + def test_only_non_class_nodes_returns_empty(self): + nodes = [ + _class_node("f1", "do_thing", "function"), + _class_node("f2", "helper", "function"), + ] + dg = self._make_dg(nodes) + result = dg.generate_class_diagram(cluster_id=1) + assert result == "" + + # ── Inheritance edges ────────────────────────────────────────────────── + + def test_inheritance_edge_in_cluster_rendered(self): + nodes = [ + _class_node("c1", "Child", "class"), + _class_node("c2", "Parent", "class"), + ] + edges_map = { + "c1": [_storage_edge("c2", "inheritance")], + "c2": [], + } + dg = self._make_dg(nodes, edges_map) + result = dg.generate_class_diagram(cluster_id=1) + # Mermaid classDiagram inheritance: Child --|> Parent + assert "--|>" in result + + def test_inheritance_to_external_node_not_rendered(self): + nodes = [ + _class_node("c1", "Child", "class"), + _class_node("c2", "SiblingInCluster", "class"), + ] + # c1 inherits from external node "ext" not in cluster + edges_map = { + "c1": [_storage_edge("ext_node", "inheritance")], + "c2": [], + } + dg = self._make_dg(nodes, edges_map) + result = dg.generate_class_diagram(cluster_id=1) + assert "--|>" not in result + + # ── Member methods ───────────────────────────────────────────────────── + + def test_public_method_gets_plus_prefix(self): + nodes = [ + _class_node("c1", "Foo", "class"), + _class_node("c2", "Bar", "class"), + _class_node("m1", "process", "method", parent_symbol="Foo", signature="process(x: int) -> str"), + ] + dg = self._make_dg(nodes) + result = dg.generate_class_diagram(cluster_id=1) + assert "+" in result + + def test_private_method_gets_minus_prefix(self): + nodes = [ + _class_node("c1", "Foo", "class"), + _class_node("c2", "Bar", "class"), + _class_node("m1", "_internal", "method", parent_symbol="Foo"), + ] + dg = self._make_dg(nodes) + result = dg.generate_class_diagram(cluster_id=1) + assert "-" in result + + # ── Cap at 8 class nodes ─────────────────────────────────────────────── + + def test_more_than_8_class_nodes_capped(self): + nodes = [ + _class_node(f"c{i}", f"Class{i}", "class", is_architectural=1) + for i in range(12) + ] + dg = self._make_dg(nodes) + result = dg.generate_class_diagram(cluster_id=1) + # At most 8 class names should appear + present = sum(1 for i in range(12) if f"Class{i}" in result) + assert present <= 8 + + def test_architectural_nodes_preferred_when_over_cap(self): + # 4 architectural + 6 non-architectural = 10 total; cap is 8 + nodes = [ + _class_node(f"c{i}", f"Arch{i}", "class", is_architectural=1) + for i in range(4) + ] + [ + _class_node(f"n{i}", f"NonArch{i}", "class", is_architectural=0) + for i in range(6) + ] + dg = self._make_dg(nodes) + result = dg.generate_class_diagram(cluster_id=1) + # All architectural nodes must appear + for i in range(4): + assert f"Arch{i}" in result + # Non-arch nodes beyond the cap should not all appear + # (some may appear but total must be ≤ 8) + present = sum(1 for i in range(4) if f"Arch{i}" in result) + assert present == 4 + + # ── Struct type accepted ─────────────────────────────────────────────── + + def test_struct_type_included_in_class_diagram(self): + nodes = [ + _class_node("s1", "Config", "struct"), + _class_node("s2", "Options", "struct"), + ] + dg = self._make_dg(nodes) + result = dg.generate_class_diagram(cluster_id=1) + assert "classDiagram" in result + assert "Config" in result + + # ── Storage error → graceful empty ──────────────────────────────────── + + def test_storage_error_returns_empty(self): + storage = MagicMock() + storage.get_nodes_by_cluster.side_effect = RuntimeError("db down") + dg = DiagramGenerator(storage) + result = dg.generate_class_diagram(cluster_id=1) + assert result == "" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# NEW: generate_data_model_diagram +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestGenerateDataModelDiagram: + """Unit tests for DiagramGenerator.generate_data_model_diagram.""" + + def _make_dg(self, nodes, edges_map=None): + def _side_effect(node_id, rel_types=None): + if edges_map: + return edges_map.get(node_id, []) + return [] + + storage = MagicMock() + storage.get_nodes_by_cluster.return_value = nodes + storage.get_edges_from.side_effect = _side_effect + return DiagramGenerator(storage) + + # ── Pydantic BaseModel detection ─────────────────────────────────────── + + def test_basemodel_detected_as_data_model(self): + nodes = [ + _class_node("m1", "User", "class", source_text="class User(BaseModel):\n id: int\n email: str"), + _class_node("m2", "Post", "class", source_text="class Post(BaseModel):\n id: int\n title: str"), + ] + dg = self._make_dg(nodes) + result = dg.generate_data_model_diagram(cluster_id=1) + assert "erDiagram" in result + assert "User" in result + assert "Post" in result + + def test_dataclass_detected_as_data_model(self): + nodes = [ + _class_node("m1", "Config", "class", source_text="@dataclass\nclass Config:\n host: str"), + _class_node("m2", "Settings", "class", source_text="@dataclass\nclass Settings:\n port: int"), + ] + dg = self._make_dg(nodes) + result = dg.generate_data_model_diagram(cluster_id=1) + assert "erDiagram" in result + + def test_sqlalchemy_model_detected(self): + nodes = [ + _class_node("m1", "User", "class", source_text="class User(db.Model):\n id = Column(Integer)"), + _class_node("m2", "Post", "class", source_text="class Post(db.Model):\n id = Column(Integer)"), + ] + dg = self._make_dg(nodes) + result = dg.generate_data_model_diagram(cluster_id=1) + assert "erDiagram" in result + + def test_plain_class_not_detected_as_data_model(self): + nodes = [ + _class_node("c1", "Util", "class", source_text="class Util:\n def run(self): ..."), + _class_node("c2", "Helper", "class", source_text="class Helper:\n pass"), + ] + dg = self._make_dg(nodes) + result = dg.generate_data_model_diagram(cluster_id=1) + assert result == "" + + # ── Fewer than 2 data-model nodes → empty ───────────────────────────── + + def test_single_model_returns_empty(self): + nodes = [ + _class_node("m1", "User", "class", source_text="class User(BaseModel):\n id: int"), + ] + dg = self._make_dg(nodes) + result = dg.generate_data_model_diagram(cluster_id=1) + assert result == "" + + def test_no_nodes_returns_empty(self): + storage = MagicMock() + storage.get_nodes_by_cluster.return_value = [] + storage.get_edges_from.return_value = [] + dg = DiagramGenerator(storage) + result = dg.generate_data_model_diagram(cluster_id=1) + assert result == "" + + def test_none_storage_returns_empty(self): + dg = DiagramGenerator(None) + result = dg.generate_data_model_diagram(cluster_id=1) + assert result == "" + + # ── Mermaid erDiagram output format ─────────────────────────────────── + + def test_er_diagram_has_mermaid_fence(self): + nodes = [ + _class_node("m1", "User", "class", source_text="class User(BaseModel):\n id: int"), + _class_node("m2", "Post", "class", source_text="class Post(BaseModel):\n title: str"), + ] + dg = self._make_dg(nodes) + result = dg.generate_data_model_diagram(cluster_id=1) + assert "```mermaid" in result + assert result.strip().endswith("```") + + def test_er_diagram_composition_edge(self): + nodes = [ + _class_node("m1", "User", "class", source_text="class User(BaseModel):\n id: int"), + _class_node("m2", "Post", "class", source_text="class Post(BaseModel):\n user_id: int"), + ] + edges_map = { + "m1": [_storage_edge("m2", "composition")], + "m2": [], + } + dg = self._make_dg(nodes, edges_map) + result = dg.generate_data_model_diagram(cluster_id=1) + # Some relationship marker should appear + assert "User" in result + assert "Post" in result + + # ── Cap at 8 data-model nodes ────────────────────────────────────────── + + def test_more_than_8_model_nodes_capped(self): + nodes = [ + _class_node(f"m{i}", f"Model{i}", "class", source_text=f"class Model{i}(BaseModel):\n id: int") + for i in range(10) + ] + dg = self._make_dg(nodes) + result = dg.generate_data_model_diagram(cluster_id=1) + present = sum(1 for i in range(10) if f"Model{i}" in result) + assert present <= 8 + + # ── Storage error → graceful empty ──────────────────────────────────── + + def test_storage_error_returns_empty(self): + storage = MagicMock() + storage.get_nodes_by_cluster.side_effect = RuntimeError("db down") + dg = DiagramGenerator(storage) + result = dg.generate_data_model_diagram(cluster_id=1) + assert result == "" + + # ── DeclarativeBase detection ────────────────────────────────────────── + + def test_declarative_base_detected(self): + nodes = [ + _class_node("m1", "User", "class", source_text="class User(DeclarativeBase):\n id: int"), + _class_node("m2", "Post", "class", source_text="class Post(DeclarativeBase):\n title: str"), + ] + dg = self._make_dg(nodes) + result = dg.generate_data_model_diagram(cluster_id=1) + assert "erDiagram" in result + + +# ═══════════════════════════════════════════════════════════════════════════════ +# NEW: generate_cluster_context_diagram +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestGenerateClusterContextDiagram: + """Unit tests for DiagramGenerator.generate_cluster_context_diagram.""" + + def _make_dg(self, nodes, edges_map=None): + def _side_effect(node_id, rel_types=None): + if edges_map: + return edges_map.get(node_id, []) + return [] + + storage = MagicMock() + storage.get_nodes_by_cluster.return_value = nodes + storage.get_edges_from.side_effect = _side_effect + return DiagramGenerator(storage) + + # ── Intra-cluster nodes rendered in subgraph ─────────────────────────── + + def test_intra_cluster_nodes_in_subgraph(self): + nodes = [ + _class_node("n1", "AuthService", "class"), + _class_node("n2", "TokenParser", "class"), + ] + dg = self._make_dg(nodes) + result = dg.generate_cluster_context_diagram( + cluster_id=1, + cluster_title="Auth", + all_cluster_ids=[], + cluster_titles={}, + ) + assert "subgraph" in result + assert "AuthService" in result + assert "TokenParser" in result + + def test_diagram_has_mermaid_fence(self): + nodes = [_class_node("n1", "Svc", "class")] + dg = self._make_dg(nodes) + result = dg.generate_cluster_context_diagram( + cluster_id=1, cluster_title="Svc", all_cluster_ids=[], cluster_titles={} + ) + assert "```mermaid" in result + assert result.strip().endswith("```") + + def test_graph_lr_header(self): + nodes = [_class_node("n1", "X", "class")] + dg = self._make_dg(nodes) + result = dg.generate_cluster_context_diagram( + cluster_id=1, cluster_title="X", all_cluster_ids=[], cluster_titles={} + ) + assert "graph LR" in result + + # ── Empty cluster → empty string ────────────────────────────────────── + + def test_empty_cluster_returns_empty(self): + storage = MagicMock() + storage.get_nodes_by_cluster.return_value = [] + storage.get_edges_from.return_value = [] + dg = DiagramGenerator(storage) + result = dg.generate_cluster_context_diagram( + cluster_id=1, cluster_title="Empty", all_cluster_ids=[], cluster_titles={} + ) + assert result == "" + + def test_none_storage_returns_empty(self): + dg = DiagramGenerator(None) + result = dg.generate_cluster_context_diagram( + cluster_id=1, cluster_title="X", all_cluster_ids=[], cluster_titles={} + ) + assert result == "" + + # ── Intra-cluster edges ──────────────────────────────────────────────── + + def test_intra_cluster_edges_rendered(self): + nodes = [ + _class_node("n1", "A", "class"), + _class_node("n2", "B", "class"), + ] + edges_map = { + "n1": [_storage_edge("n2", "calls")], + "n2": [], + } + dg = self._make_dg(nodes, edges_map) + result = dg.generate_cluster_context_diagram( + cluster_id=1, cluster_title="MyCluster", all_cluster_ids=[], cluster_titles={} + ) + assert "-->" in result + + # ── Cross-cluster external nodes ─────────────────────────────────────── + + def test_cross_cluster_edge_creates_external_node(self): + # n1 is in cluster 1; n_ext is in cluster 2 + nodes_c1 = [_class_node("n1", "AuthService", "class")] + nodes_c2 = [_class_node("n_ext", "WikiService", "class")] + + # Storage returns different nodes for different cluster IDs + call_count = {"count": 0} + def _get_nodes_by_cluster(cluster_id): + if cluster_id == 1: + return nodes_c1 + elif cluster_id == 2: + return nodes_c2 + return [] + + edges_map = { + "n1": [_storage_edge("n_ext", "calls")], + } + + storage = MagicMock() + storage.get_nodes_by_cluster.side_effect = _get_nodes_by_cluster + storage.get_edges_from.side_effect = lambda nid, rel_types=None: edges_map.get(nid, []) + + dg = DiagramGenerator(storage) + result = dg.generate_cluster_context_diagram( + cluster_id=1, + cluster_title="Auth Service", + all_cluster_ids=[1, 2], + cluster_titles={1: "Auth Service", 2: "Wiki Service"}, + ) + # The external cluster title should appear as a node + assert "Wiki Service" in result or "Wiki_Service" in result + + def test_cross_cluster_shown_as_single_node_not_individual_members(self): + """External cluster appears as a single labeled node, not individual nodes.""" + nodes_c1 = [_class_node("n1", "AuthService", "class")] + nodes_c2 = [ + _class_node("n_ext1", "WikiService", "class"), + _class_node("n_ext2", "PageRenderer", "class"), + ] + + def _get_nodes_by_cluster(cluster_id): + if cluster_id == 1: + return nodes_c1 + return nodes_c2 + + edges_map = { + "n1": [_storage_edge("n_ext1", "calls"), _storage_edge("n_ext2", "calls")], + } + + storage = MagicMock() + storage.get_nodes_by_cluster.side_effect = _get_nodes_by_cluster + storage.get_edges_from.side_effect = lambda nid, rel_types=None: edges_map.get(nid, []) + + dg = DiagramGenerator(storage) + result = dg.generate_cluster_context_diagram( + cluster_id=1, + cluster_title="Auth", + all_cluster_ids=[1, 2], + cluster_titles={1: "Auth", 2: "Wiki"}, + ) + # WikiService individual nodes should NOT appear — only the cluster label "Wiki" + assert "WikiService" not in result + assert "PageRenderer" not in result + + # ── Storage error → graceful empty ──────────────────────────────────── + + def test_storage_error_returns_empty(self): + storage = MagicMock() + storage.get_nodes_by_cluster.side_effect = RuntimeError("db down") + dg = DiagramGenerator(storage) + result = dg.generate_cluster_context_diagram( + cluster_id=1, cluster_title="X", all_cluster_ids=[], cluster_titles={} + ) + assert result == ""