diff --git a/README.md b/README.md index 4080460..eb455a4 100644 --- a/README.md +++ b/README.md @@ -346,6 +346,7 @@ The first sync after the merge creates `codeboarding/baseline`, catching up from | `depth_cap` | both | `2` | Positive integer maximum analysis depth, including full-analysis fallbacks. Changing it rebuilds incompatible state. | | `github_token` | both | `${{ github.token }}` | Token for comments and sync delivery. | | `codeboarding_analysis_location` | both | `codeboarding_branch` | Where the synced branch's analysis is stored: `codeboarding_branch` (on `codeboarding/baseline`, for the default branch; the code is never written) or `in_place` (committed as `.codeboarding/` on the synced branch). Sync saves it there and review reads it from there. | +| `comment_detail` | review | `top` | What the PR comment's diagram draws. `top` is every top-level component. `specific` is only the most specific components that changed, flat, joined by the relations that changed, with an unchanged component only where a changed relation ends at it. It falls back to `top` when nothing changed or the diagram is too large for GitHub. Rendering only: changing it neither rebuilds nor invalidates any analysis. | | `warmstart_retention_days` | review | `1` | Days to keep the reusable analysis. Only the next run reads it. | | `review_guide_url` | review | `https://jxivqwx4or6oip3krgdmdaiunm0iaybf.lambda-url.us-east-1.on.aws/` | Experimental. Production service base URL for the pull request's review guide. The run sends it this analysis and stores `review_guide.json` in the review artifact. Set to `''` to disable. Never fails the run. | diff --git a/action.yml b/action.yml index 00cb335..e4987d6 100644 --- a/action.yml +++ b/action.yml @@ -143,6 +143,13 @@ inputs: description: 'Positive integer analysis depth cap, used for both review and sync, including full fallbacks.' required: false default: '2' + comment_detail: + description: >- + What the pull request comment's diagram draws. top: every top-level component (default). + specific: only the most specific components that changed, flat, joined by the relations + that changed. Rendering only: it does not change the analysis or its reuse. + required: false + default: 'top' review_guide_url: description: >- Experimental. The CodeBoarding service that writes a pull request's review guide (main @@ -263,6 +270,7 @@ runs: PULL_BASE_REPO: ${{ github.event.pull_request.base.repo.full_name }} REPOSITORY: ${{ github.repository }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + COMMENT_DETAIL: ${{ inputs.comment_detail }} run: "$GITHUB_ACTION_PATH/scripts/action/resolve-github-event.sh" - name: Acknowledge review command @@ -671,6 +679,7 @@ runs: ACTION_PATH: ${{ github.action_path }} BASE_ANALYSIS_PATH: ${{ steps.review_baseline.outputs.base_analysis_path }} HEAD_ANALYSIS_PATH: ${{ steps.review_analyze.outputs.analysis_path }} + COMMENT_DETAIL: ${{ inputs.comment_detail }} run: "$GITHUB_ACTION_PATH/scripts/action/render-review.sh" - name: Build review artifact @@ -939,6 +948,6 @@ runs: message: | ### CodeBoarding review · failed - ${{ steps.review_baseline.outputs.failure_reason || steps.review_analyze.outputs.failure_reason }} + ${{ steps.guard.outputs.failure_reason || steps.review_baseline.outputs.failure_reason || steps.review_analyze.outputs.failure_reason }} See the [workflow logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}). diff --git a/scripts/action/render-review.sh b/scripts/action/render-review.sh index d6a1d3c..72dd7cb 100755 --- a/scripts/action/render-review.sh +++ b/scripts/action/render-review.sh @@ -13,7 +13,7 @@ if [ -z "$HEAD_ANALYSIS" ] || [ ! -f "$HEAD_ANALYSIS" ]; then fi DIAGRAM_OUT="${RUNNER_TEMP}/diagram.md" META="${RUNNER_TEMP}/diagram_meta.json" -DIFF="$(python3 "$ACTION_PATH/scripts/diff_to_mermaid.py" --base "$BASE_ANALYSIS" --head "$HEAD_ANALYSIS" --out "$DIAGRAM_OUT" --direction LR --render-depth 1)" +DIFF="$(python3 "$ACTION_PATH/scripts/diff_to_mermaid.py" --base "$BASE_ANALYSIS" --head "$HEAD_ANALYSIS" --out "$DIAGRAM_OUT" --direction LR --render-depth 1 --detail "${COMMENT_DETAIL:-top}")" printf '%s' "$DIFF" > "$META" read -r N_CHANGED TRUNCATED RENDERED EMPTY ANALYSED_FILES_CHANGED < <(jq -r '[.n_changed, .truncated, .rendered, .empty, (.analysed_files_changed // "unknown")] | @tsv' "$META") diff --git a/scripts/action/resolve-github-event.sh b/scripts/action/resolve-github-event.sh index 4f968ba..06ce4df 100755 --- a/scripts/action/resolve-github-event.sh +++ b/scripts/action/resolve-github-event.sh @@ -138,3 +138,9 @@ is_fork=false echo "comment_id=$comment_id" echo "is_fork=$is_fork" } >> "$GITHUB_OUTPUT" +# Last, so the failure comment has its pull request. Checked here, in seconds, rather than +# after the analysis it would otherwise be paid for. +case "${COMMENT_DETAIL:-top}" in + top|specific) ;; + *) fail "comment_detail must be top or specific." ;; +esac diff --git a/scripts/diff_to_mermaid.py b/scripts/diff_to_mermaid.py index 4d8283d..c33a3b0 100644 --- a/scripts/diff_to_mermaid.py +++ b/scripts/diff_to_mermaid.py @@ -64,8 +64,13 @@ def _relation_dict(relation) -> dict: def _project_analysis(data: dict, root_analysis, sub_analyses: dict, id_to_name: dict, projector) -> dict: - """Attach Core's global-relation projection to every nested JSON level.""" + """Attach Core's global-relation projection to every nested JSON level. + + The unprojected relations are kept too: a level only holds edges with both ends inside it, + so an edge between two parents' children survives nowhere else. + """ global_relations = list(root_analysis.components_relations) + data["leaf_relations"] = [_relation_dict(relation) for relation in global_relations] def project_level(container: dict, analysis) -> None: level_ids = {component.component_id for component in analysis.components} @@ -215,6 +220,27 @@ def _owns_analysis_change( return bool(owned_methods & changed_methods or owned_files & changed_files) +def _mark_change_owners( + components: list, + base_components: list, + changed_methods: set[str], + changed_files: set[str], +) -> None: + """Note, at every level, which components hold code that changed.""" + base_by_name = {_comp_name(component): component for component in base_components} + for component in components: + base_component = base_by_name.get(_comp_name(component)) + if base_component is None: + continue + component["owns_change"] = _owns_analysis_change(base_component, component, changed_methods, changed_files) + _mark_change_owners( + component.get("components") or [], + base_component.get("components") or [], + changed_methods, + changed_files, + ) + + def _rel_key(r: dict) -> tuple: # Name is the stable join across two independent analyses; component ids are # positional and can be reshuffled on a full re-run, so prefer names. @@ -332,19 +358,9 @@ def _diff_components(base_components: list, current_components: list) -> list: def build_diff(base: dict, head: dict) -> dict: changed_methods, changed_files = _analysis_changes(base, head) components = _diff_components(base.get("components") or [], head.get("components") or []) - base_by_name = {_comp_name(component): component for component in base.get("components") or []} + _mark_change_owners(components, base.get("components") or [], changed_methods, changed_files) for component in components: - base_component = base_by_name.get(_comp_name(component)) - if ( - component.get("diff_status") == "unchanged" - and base_component is not None - and _owns_analysis_change( - base_component, - component, - changed_methods, - changed_files, - ) - ): + if component.get("diff_status") == "unchanged" and component.get("owns_change"): component["diff_status"] = "modified" return { "components": components, @@ -352,6 +368,7 @@ def build_diff(base: dict, head: dict) -> dict: base.get("components_relations") or [], head.get("components_relations") or [], ), + "leaf_relations": _diff_relations(base.get("leaf_relations") or [], head.get("leaf_relations") or []), } @@ -492,6 +509,53 @@ def touches(r: dict, side_id: str, side_name: str) -> bool: return kept, rels +def _flat(comp: dict, status: str) -> dict: + return {"component_id": _comp_id(comp), "name": _comp_name(comp), "diff_status": status} + + +def _deepest_changed(components: list, force: str | None = None) -> list: + """The changed components with nothing changed below them, as one flat list. + + Status is read before ``display_status``, which only says something below a component + changed. A body-only edit moves no method between components, so nested components carry + it as ``owns_change`` alone. + """ + deepest: list = [] + for comp in components: + status = force or comp.get("diff_status", "unchanged") + child_force = status if status in ("added", "deleted") else None + below = _deepest_changed(comp.get("components") or [], child_force) + if below: + deepest.extend(below) + elif status in CHANGED: + deepest.append(_flat(comp, status)) + elif comp.get("owns_change"): + deepest.append(_flat(comp, "modified")) + return deepest + + +def _specific_view(diff: dict) -> dict: + """The deepest changed components joined by every changed relation, as a one-level diff.""" + components = _deepest_changed(diff.get("components") or []) + relations = [r for r in diff.get("leaf_relations") or [] if r.get("diff_status") in CHANGED] + + known: dict = {} + + def index(comps: list) -> None: + for comp in comps: + known[_comp_name(comp)] = comp + index(comp.get("components") or []) + + index(diff.get("components") or []) + drawn = {_comp_name(c) for c in components} + for rel in relations: # an unchanged component is drawn only for a changed edge that ends at it + for name in (rel.get("src_name"), rel.get("dst_name")): + if name in known and name not in drawn: + drawn.add(name) + components.append(_flat(known[name], "unchanged")) + return {"components": components, "components_relations": relations} + + def _init_directive(font_size, node_padding, node_spacing, rank_spacing) -> str | None: """Build a Mermaid ``%%{init}%%`` directive to enlarge nodes / spacing. @@ -661,6 +725,22 @@ def emit_level(comps, rels, indent, force, level): return text, meta +def render_specific(diff: dict, **options) -> tuple: + """``render_mermaid`` of the specific view, or of ``diff`` when that cannot be drawn. + + The counts describe the change, not the picture, so they stay the whole diff's. + """ + top_mermaid, top_meta = render_mermaid(diff, **options) + view = _specific_view(diff) + if not view["components"]: # nothing changed + return top_mermaid, top_meta + mermaid, meta = render_mermaid(view, **options) + if mermaid is None: + print("::warning::The specific diagram is too large to draw; drawing the top level.", file=sys.stderr) + return top_mermaid, {**top_meta, "truncated": True} + return mermaid, {**meta, "n_changed": top_meta["n_changed"], "changed": top_meta["changed"]} + + # --------------------------------------------------------------------------- # # cli # --------------------------------------------------------------------------- # @@ -680,6 +760,12 @@ def main() -> int: default=1, help="Component levels to draw: 1=top-level flat, 2=+one nesting level, ...", ) + p.add_argument( + "--detail", + default="top", + choices=["top", "specific"], + help="top=the --render-depth levels, specific=only the deepest changed components, flat", + ) p.add_argument( "--font-size", type=int, default=None, help="Node label font size in px (bigger label ⇒ bigger node)" ) @@ -690,7 +776,8 @@ def main() -> int: base, head = load_analysis(args.base), load_analysis(args.head) diff = build_diff(base, head) - mermaid, meta = render_mermaid( + render = render_specific if args.detail == "specific" else render_mermaid + mermaid, meta = render( diff, direction=args.direction, changed_only=args.changed_only, diff --git a/tests/test_action_inputs.py b/tests/test_action_inputs.py index 5fc96e7..aec4e2d 100644 --- a/tests/test_action_inputs.py +++ b/tests/test_action_inputs.py @@ -71,6 +71,19 @@ def test_depth_is_wired_to_state_identity_and_both_analysis_modes(self) -> None: block = ACTION[start : ACTION.index("\n run:", start)] self.assertIn("DEPTH_CAP: ${{ inputs.depth_cap }}", block) + def test_comment_detail_reaches_rendering_and_nothing_else(self) -> None: + """It changes one picture. Reaching a cache key or the engine would rebuild analyses for it.""" + self.assertIn("default: 'top'", self.inputs["comment_detail"]) + self.assertNotIn("comment_depth", self.inputs, "a free number can select nested subgraphs") + self.assertEqual(ACTION.count("inputs.comment_detail"), 2) + for identifier in ("id: guard", "id: review_render"): + start = ACTION.index(identifier) + block = ACTION[start : ACTION.index("\n run:", start)] + self.assertIn("COMMENT_DETAIL: ${{ inputs.comment_detail }}", block) + sources = [*(ROOT / "scripts").rglob("*.sh"), *(ROOT / "scripts").rglob("*.py")] + readers = {p.name for p in sources if "COMMENT_DETAIL" in p.read_text(encoding="utf-8")} + self.assertEqual(readers, {"render-review.sh", "resolve-github-event.sh"}) + def test_default_workflow_reviews_drafts_on_open_and_new_commits(self) -> None: self.assertNotIn("github.event.pull_request.draft", DOGFOOD) start = DOGFOOD.index("types:") diff --git a/tests/test_comment_detail.py b/tests/test_comment_detail.py new file mode 100644 index 0000000..6b9942c --- /dev/null +++ b/tests/test_comment_detail.py @@ -0,0 +1,215 @@ +"""comment_detail: which view the PR comment's diagram draws, and what a bad value does. + +The view itself is in test_diff_to_mermaid.py. These run the action's scripts, because the +input is only worth having if it reaches the renderer, and only safe if a bad value stops the +run before the analysis is paid for. +""" + +import json +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +RENDER_REVIEW = ROOT / "scripts" / "action" / "render-review.sh" +GUARD = ROOT / "scripts" / "action" / "resolve-github-event.sh" + + +def write_core(root: Path) -> Path: + """Just enough of Core for diff_to_mermaid.py: nesting and root relations pass through.""" + core = root / "core" + (core / "diagram_analysis").mkdir(parents=True) + (core / "output_generators").mkdir(parents=True) + (core / "output_generators" / "rendering.py").write_text( + "def project_relations_to_level(*args): return []\n", encoding="utf-8" + ) + (core / "diagram_analysis" / "analysis_json.py").write_text( + "from types import SimpleNamespace\n" + "def parse_unified_analysis(data):\n" + " relations = [SimpleNamespace(**r) for r in data['components_relations']]\n" + " return SimpleNamespace(components=[], components_relations=relations), {}\n" + "def build_id_to_name_map(*args): return {}\n", + encoding="utf-8", + ) + return core + + +def component(name: str, *children: dict, files: dict | None = None) -> dict: + return { + "component_id": name, + "name": name, + "file_methods": [{"file_path": path, "methods": methods} for path, methods in (files or {}).items()], + "components": list(children), + "components_relations": [], + } + + +def relation(src: str, dst: str) -> dict: + return {"relation": "calls", "src_name": src, "dst_name": dst, "src_id": src, "dst_id": dst} + + +class CommentDetailRenderTests(unittest.TestCase): + def setUp(self) -> None: + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.root = Path(tmp.name) + self.core = write_core(self.root) + + def _render(self, base: dict, head: dict, **env: str) -> tuple[subprocess.CompletedProcess, str, dict[str, str]]: + paths = {} + for name, analysis in (("base", base), ("head", head)): + paths[name] = self.root / f"{name}.json" + paths[name].write_text(json.dumps(analysis), encoding="utf-8") + github_output = self.root / "github-output" + github_output.write_text("", encoding="utf-8") + result = subprocess.run( + [str(RENDER_REVIEW)], + env={ + "PATH": os.environ["PATH"], + "PYTHONPATH": str(self.core), + "ACTION_PATH": str(ROOT), + "BASE_ANALYSIS_PATH": str(paths["base"]), + "HEAD_ANALYSIS_PATH": str(paths["head"]), + "GITHUB_OUTPUT": str(github_output), + "RUNNER_TEMP": str(self.root), + **env, + }, + capture_output=True, + text=True, + check=False, + ) + outputs = dict(line.split("=", 1) for line in github_output.read_text(encoding="utf-8").splitlines()) + return result, (self.root / "diagram.md").read_text(encoding="utf-8"), outputs + + @staticmethod + def _a_nested_change() -> tuple[dict, dict]: + base = { + "components": [ + component("Parent", component("Alpha", files={"a.py": ["f"]}), component("Quiet")), + component("Other", component("Beta")), + ], + "components_relations": [], + } + head = { + "components": [ + component("Parent", component("Alpha", files={"a.py": ["f", "g"]}), component("Quiet")), + component("Other", component("Beta")), + ], + "components_relations": [relation("Alpha", "Beta")], + } + return base, head + + def test_the_default_draws_the_top_level_as_it_always_has(self) -> None: + base, head = self._a_nested_change() + result, diagram, _ = self._render(base, head) + self.assertEqual(result.returncode, 0, result.stderr or result.stdout) + self.assertIn('["Parent"]', diagram) + self.assertNotIn("Alpha", diagram) + + def test_top_named_explicitly_is_the_default(self) -> None: + base, head = self._a_nested_change() + _, default, _ = self._render(base, head) + _, top, _ = self._render(base, head, COMMENT_DETAIL="top") + self.assertEqual(top, default) + + def test_specific_draws_only_what_changed_flat(self) -> None: + base, head = self._a_nested_change() + _, _, top_outputs = self._render(base, head) + result, diagram, outputs = self._render(base, head, COMMENT_DETAIL="specific") + self.assertEqual(result.returncode, 0, result.stderr or result.stdout) + self.assertIn('["Alpha"]', diagram) + self.assertIn('["Beta"]', diagram, "the far end of a changed relation") + self.assertIn("n_Alpha -- ", diagram) + for absent in ("subgraph", "Parent", "Other", "Quiet"): + self.assertNotIn(absent, diagram) + self.assertEqual(outputs["n_changed"], top_outputs["n_changed"], "the count describes the change") + + def test_a_diagram_too_large_to_be_specific_falls_back_to_the_top_level(self) -> None: + """Everything here is added, so the specific view is every child and cannot be drawn.""" + empty = {"components": [], "components_relations": []} + name = "child-{}-of-a-component-with-a-long-enough-name" + head = { + "components": [ + component(f"Parent{p}", *[component(f"{p}-{name.format(c)}") for c in range(40)]) for p in range(60) + ], + "components_relations": [], + } + result, diagram, outputs = self._render(empty, head, COMMENT_DETAIL="specific") + self.assertEqual(result.returncode, 0, result.stderr or result.stdout) + self.assertIn("::warning::The specific diagram is too large to draw", result.stderr) + self.assertIn('["Parent0"]', diagram) + self.assertNotIn("child-", diagram) + self.assertEqual(outputs["truncated"], "true") + + def test_with_nothing_changed_specific_shows_the_top_level_without_a_warning(self) -> None: + base, _ = self._a_nested_change() + result, diagram, outputs = self._render(base, base, COMMENT_DETAIL="specific") + self.assertEqual(result.returncode, 0, result.stderr or result.stdout) + self.assertNotIn("::warning::", result.stderr) + self.assertIn('["Parent"]', diagram) + self.assertEqual(outputs["truncated"], "false") + + +class CommentDetailGuardTests(unittest.TestCase): + def _guard(self, root: Path, **env: str) -> tuple[subprocess.CompletedProcess, str]: + gh = root / "bin" / "gh" + gh.parent.mkdir() + gh.write_text("#!/bin/sh\nprintf 'merge-base\\t0'\n", encoding="utf-8") + gh.chmod(0o755) + output = root / "github-output" + result = subprocess.run( + [str(GUARD)], + env={ + "PATH": f"{gh.parent}{os.pathsep}{os.environ['PATH']}", + "GITHUB_OUTPUT": str(output), + "MODE": "review", + "EVENT": "pull_request", + "EVENT_PR_NUMBER": "42", + "PULL_BASE_SHA": "base-sha", + "PULL_HEAD_SHA": "head-sha", + "PULL_BASE_REPO": "owner/repo", + "PULL_HEAD_REPO": "owner/repo", + **env, + }, + capture_output=True, + text=True, + check=False, + ) + return result, output.read_text(encoding="utf-8") + + def test_anything_else_stops_the_run_with_the_reason(self) -> None: + for value in ("2", "1", "full", "TOP", "specific ", "top,specific"): + with self.subTest(value=value), tempfile.TemporaryDirectory() as tmp: + result, outputs = self._guard(Path(tmp), COMMENT_DETAIL=value) + self.assertEqual(result.returncode, 1, result.stdout) + self.assertIn("::error::comment_detail must be top or specific.", result.stdout) + self.assertIn("failure_reason=comment_detail must be top or specific.", outputs) + self.assertIn("pr_number=42", outputs, "the failure comment needs its pull request") + + def test_the_two_views_and_the_default_pass(self) -> None: + for env in ({}, {"COMMENT_DETAIL": ""}, {"COMMENT_DETAIL": "top"}, {"COMMENT_DETAIL": "specific"}): + with self.subTest(env=env), tempfile.TemporaryDirectory() as tmp: + result, outputs = self._guard(Path(tmp), **env) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertNotIn("failure_reason=", outputs) + + def test_sync_does_not_read_it(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + result, _ = self._guard( + Path(tmp), + MODE="sync", + EVENT="push", + REF_NAME="main", + REF_TYPE="branch", + HEAD_AUTHOR_EMAIL="dev@example.com", + DEFAULT_BRANCH="main", + REPOSITORY="owner/repo", + COMMENT_DETAIL="nonsense", + ) + self.assertEqual(result.returncode, 0, result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_diff_to_mermaid.py b/tests/test_diff_to_mermaid.py index 0624787..5916315 100644 --- a/tests/test_diff_to_mermaid.py +++ b/tests/test_diff_to_mermaid.py @@ -226,6 +226,11 @@ def projector(relations, level_ids, _id_to_name): nested_relations = projected["components"][0]["components_relations"] self.assertEqual(nested_relations[0]["src_id"], "1.1") self.assertEqual(nested_relations[0]["dst_id"], "1.2") + self.assertEqual( + [r["src_id"] for r in projected["leaf_relations"]], + [global_relation.src_id, sibling_relation.src_id], + "an edge between two parents' children survives only unprojected", + ) def test_added_modified_deleted_unchanged(self): base = {"components": [comp("A", {"a.py": ["f"]}), comp("B"), comp("D")], "components_relations": []} @@ -531,5 +536,89 @@ def test_no_edge_labels(self): self.assertNotIn(' -- "', text) +class TestSpecificView(unittest.TestCase): + """The deepest changed components, flat, joined by the relations that changed.""" + + def _diff(self, base_leaf=(), head_leaf=()): + def tree(alpha_methods): + return [ + comp( + "One", subs=[comp("Alpha", {"a.py": alpha_methods}, cid="1.1"), comp("Quiet", cid="1.2")], cid="1" + ), + comp("Two", subs=[comp("Beta", {"b.py": ["b"]}, cid="2.1"), comp("Gamma", cid="2.2")], cid="2"), + ] + + base = {"components": tree(["f"]), "components_relations": [], "leaf_relations": list(base_leaf)} + head = {"components": tree(["f", "g"]), "components_relations": [], "leaf_relations": list(head_leaf)} + return dm.build_diff(base, head) + + @staticmethod + def _names(diff): + return {c["name"]: c["diff_status"] for c in dm._specific_view(diff)["components"]} + + def test_the_deepest_changed_component_stands_in_for_its_parent(self): + self.assertEqual(self._names(self._diff()), {"Alpha": "modified"}) + + def test_a_body_only_edit_lands_on_the_nested_component_that_owns_it(self): + """Nothing moved between components, so only the file's hash says anything changed.""" + method = "a.py|f" + + def analysis(digest): + return { + "components": [comp("One", {"a.py": ["f"]}, subs=[comp("Owner", {"a.py": ["f"]}), comp("Bystander")])], + "components_relations": [], + "methods_index": {method: {"file_path": "a.py", "content_hash": digest}}, + } + + diff = dm.build_diff(analysis("h1"), analysis("h2")) + self.assertEqual(self._names(diff), {"Owner": "modified"}) + self.assertEqual(dm.render_mermaid(diff)[1]["n_changed"], 1, "owning a change adds nothing to the count") + + def test_a_parent_with_no_changed_children_is_drawn_itself(self): + base = {"components": [comp("One", {"a.py": ["f"]}, subs=[comp("Child")])], "components_relations": []} + head = {"components": [comp("One", {"a.py": ["f", "g"]}, subs=[comp("Child")])], "components_relations": []} + self.assertEqual(self._names(dm.build_diff(base, head)), {"One": "modified"}) + + def test_an_added_parent_is_drawn_as_its_added_children(self): + base = {"components": [], "components_relations": []} + head = {"components": [comp("New", subs=[comp("Kid"), comp("Sibling")])], "components_relations": []} + self.assertEqual(self._names(dm.build_diff(base, head)), {"Kid": "added", "Sibling": "added"}) + + def test_a_relation_across_parents_is_drawn(self): + edge = {**rel("Alpha", "Beta"), "src_id": "1.1", "dst_id": "2.1"} + text, meta = dm.render_specific(self._diff(head_leaf=[edge])) + self.assertRegex(text, r'n_Alpha -- "calls" --> n_Beta') + self.assertNotIn("subgraph", text) + self.assertEqual(meta["n_edges"], 1) + + def test_an_unchanged_component_is_drawn_only_for_a_changed_relation(self): + changed = rel("Alpha", "Beta") + steady = rel("Alpha", "Gamma") + names = self._names(self._diff(base_leaf=[steady], head_leaf=[steady, changed])) + self.assertEqual(names, {"Alpha": "modified", "Beta": "unchanged"}) + + def test_a_neighbour_is_not_coloured(self): + text, _ = dm.render_specific(self._diff(head_leaf=[rel("Alpha", "Beta")])) + self.assertRegex(text, r"class n_Alpha modified;") + self.assertNotIn("class n_Alpha,n_Beta", text) + + def test_a_relation_that_did_not_change_is_not_drawn(self): + steady = rel("Alpha", "Gamma") + text, meta = dm.render_specific(self._diff(base_leaf=[steady], head_leaf=[steady])) + self.assertEqual(meta["n_edges"], 0) + self.assertNotIn("Gamma", text) + + def test_a_changed_relation_alone_is_enough_to_draw(self): + tree = [comp("One", subs=[comp("A")]), comp("Two", subs=[comp("B")])] + base = {"components": tree, "components_relations": [], "leaf_relations": []} + head = {"components": tree, "components_relations": [], "leaf_relations": [rel("A", "B")]} + self.assertEqual(self._names(dm.build_diff(base, head)), {"A": "unchanged", "B": "unchanged"}) + + def test_the_top_view_ignores_the_unprojected_relations(self): + with_edge = self._diff(head_leaf=[rel("Alpha", "Beta")]) + without = self._diff() + self.assertEqual(dm.render_mermaid(with_edge), dm.render_mermaid(without)) + + if __name__ == "__main__": unittest.main()