diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml new file mode 100644 index 0000000..d5d307a --- /dev/null +++ b/.github/workflows/master.yml @@ -0,0 +1,16 @@ +on: [push, pull_request] + +env: + APT_INSTALL: sudo apt install -y --no-install-recommends + + +jobs: + jammy_test: + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v3 + - run: make install_deps_apt + - run: make install + - run: make test + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..378eac2 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e3a329b --- /dev/null +++ b/Makefile @@ -0,0 +1,67 @@ +PROJECT_NAME=hiearch + +APT_INSTALL=sudo apt install -y --no-install-recommends +# DEB_PYTHON_INSTALL_LAYOUT fixes UNKNOWN -> https://github.com/pypa/setuptools/issues/3269 +export DEB_PYTHON_INSTALL_LAYOUT=deb_system +export PYTHONDONTWRITEBYTECODE=1 + +CURRENT_DIR=$(shell pwd) +BUILD_DIR?=${CURRENT_DIR}/build +TEST_NOT= + +.DEFAULT: + @echo "Testing $@..." + ${TEST_NOT} hiearch -o ${BUILD_DIR}/$@ ./test/$@/*.yaml + mkdir -p ./build/$@ + find build/$@/ -iname '*.gv' | sort | xargs --no-run-if-empty -I {} sh -c "sort {} | md5 > ./build/$@/md5.build" + find test/$@/ -iname '*.gv' | sort | xargs --no-run-if-empty -I {} sh -c "sort {} | md5 > ./build/$@/md5.test" + ${TEST_NOT} cmp ./build/$@/md5.build ./build/$@/md5.test + +venv: builddir + python3 -m venv ${BUILD_DIR}/venv + +venv_test: venv + /bin/sh -c ". ${BUILD_DIR}/venv/bin/activate && ${MAKE} reinstall && ${MAKE} test" + +test: + @${MAKE} 01_basic 02_default_view 03_default_view_split 06_multiscope 07_trivial || (echo "Failure!" && false) + @${MAKE} TEST_NOT=! 04_node_cycle 05_style_cycle || (echo "Failure!" && false) + @echo "Success!" + +clean: + rm -Rf .pytest_cache + rm -Rf ${BUILD_DIR} + find ./ -name "__pycache__" | xargs rm -Rf + +builddir: + mkdir -p ${BUILD_DIR} + +install: + pip install --no-cache-dir ./ + #${MAKE} clean + +install_edit: + pip install --editable ./ + ${MAKE} clean + +install_deps: builddir + pip-compile --verbose --output-file ./${BUILD_DIR}/requirements.txt pyproject.toml + pip3 install -r ./${BUILD_DIR}/requirements.txt + +uninstall: clean + pip uninstall -y ${PROJECT_NAME} + +reinstall: + ${MAKE} uninstall + ${MAKE} install + +spell: + hunspell README.md + +install_deps_apt: + ${APT_INSTALL} graphviz + + +.PHONY: test + +# https://packaging.python.org/en/latest/tutorials/packaging-projects/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..605e5fe --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +Introduction +============ + +`hiearch` is a CLI utility that generates diagrams from textual descriptions, +a.k.a., "diagrams as code". Unlike many other generators like `graphviz` it is +designed to support hierarchical decomposition and multiple views, in which +sense it is conceptually similar to . In other words, +each node in a the description is a hierarchy of nodes, that is automatically +expanded, collapsed, or hidden depending on preferences of each particular view +requested by the same description. Currently, `hiearch` uses `graphviz` to +generate individual diagrams corresponding to views, but other backends may be +added in the future. + +The main purpose of `hiearch` is graphical representation of complex systems, +but it is meant to be generic and may find other applications. Why would anyone +need another system diagram generator when there is a multitude of tools that +support UML, C4, etc? I believe that the most important aspects of the system +are its decomposition into components and connections between them, `hiearch` +provides just that, nothing more, so that you can focus on documenting your +system rather than fitting it into a specific design framework. + + +Features +======== + +- `hiearch` does not use a DSL, but rather accepts a set of input `yaml` files + in arbitrary order. The file contents get composed into a single description, + which in turn gets decomposed into views. + +- Description files have flat structure without nesting or inclusion and + contain lists of the following objects: nodes, edges, and views. Hierarchical + relations between nodes are specified using node parameters. + +- Unlike `graphviz`, `hiearch` does not have a concept of a subgraphs: each + node may automatically become a subgraph depending on a view. + +- `hiearch` is also somewhat stricter than `graphviz`: for example, all nodes + must be defined explicitly and cannot be deduced from edge definitions. + +- View is not the same thing as `graphviz` layer + : `graphviz` places all nodes on each + layer and simply makes some of them invisible, which results in awkward + spacing. + +- `hiearch` allows nodes to have multiple parent nodes, which is referenced + here as 'multiscoping'. The idea is, of course, to show parents in different + views, for example, to outline system from logical or hardware point of view. + However, it is possible to visualize all parents in the same diagram, which + may be a bit kinky. + +- `hiearch` supports label templates, which facilitates automatic generation of + URLs, tables, icon inclusions, etc. + + +Examples +======== + + + + + + +
+
+nodes:
+    - id: ["Test 1", test1]
+
+edges:
+    - link: [test1, test1]
+
+views:
+    - id: view1
+      nodes: [test1]
+            
+
+ view1.gv.svg +
+ +https://docs.structurizr.com/dsl/example +https://c4model.com/#DeploymentDiagram +https://docs.structurizr.com/ diff --git a/hiearch/__init__.py b/hiearch/__init__.py new file mode 100644 index 0000000..870b67d --- /dev/null +++ b/hiearch/__init__.py @@ -0,0 +1 @@ +from . import hiearch diff --git a/hiearch/edge.py b/hiearch/edge.py new file mode 100644 index 0000000..52ff99c --- /dev/null +++ b/hiearch/edge.py @@ -0,0 +1,31 @@ +def get_key(edge): + return f'{edge["link"][0]}.{edge["link"][1]}' + + +def parse(yaml_edges, edges, must_exist_nodes): + default = { + 'link': ['', ''], + 'style': None, + 'graphviz': {}, + # overriden + 'id': None, + 'in': None, + 'out': None, + 'scope_in': None, + 'scope_out': None + } + + + for edge in yaml_edges: + key = get_key(edge) + + if key in edges.keys(): + raise RuntimeError(f'Duplicate edge id: {key} | file: {filename}') + + edge['out'] = edge['link'][0] + edge['in'] = edge['link'][1] + must_exist_nodes.add(edge['in']) + must_exist_nodes.add(edge['out']) + + edges[key] = default | edge + diff --git a/hiearch/graphviz.py b/hiearch/graphviz.py new file mode 100644 index 0000000..80eda40 --- /dev/null +++ b/hiearch/graphviz.py @@ -0,0 +1,79 @@ +import os +# https://graphviz.readthedocs.io/en/stable/api.html +import graphviz + +from . import node as hh_node + + +def generate_tree(graph, tree, nodes): + if len(tree) > 0: + for node_key, node_tuple in tree.items(): + node = nodes[node_key] + + if 0 == len(node_tuple['subtree']): + graph.node(name=node_tuple['key_path'], label=hh_node.get_formatted_node_label(node), **node['graphviz']) + else: + with graph.subgraph( + name=node_tuple['key_path'], + graph_attr={'label': hh_node.get_formatted_scope_label(node), 'cluster': 'true'}) as subgraph: + generate_tree(subgraph, node_tuple['subtree'], nodes) + + + +def generate(directory, fmt, view, nodes, edges): + graph = graphviz.Digraph(name=view['id'], directory=directory) + + generate_tree(graph, view['tree'], nodes) + + for edge_set in ['edges', 'custom_edges']: + for edge in view[edge_set].values(): + # adjust edges that connect scopes + if edge['out'] in view['scopes']: + scope = edge['out'] + for new_out in view['scopes'][scope]: + # https://stackoverflow.com/questions/59825/how-to-retrieve-an-element-from-a-set-without-removing-it + break + edge_out = new_out + edge['graphviz']['ltail'] = scope + edge['graphviz']['tailclip'] = 'false' # workaround for bad angle of the arrow head + else: + edge_out = edge['out'] + + if edge['in'] in view['scopes']: + scope = edge['in'] + for new_in in view['scopes'][scope]: + # https://stackoverflow.com/questions/59825/how-to-retrieve-an-element-from-a-set-without-removing-it + break + edge_in = new_in + edge['graphviz']['lhead'] = scope + edge['graphviz']['headclip'] = 'false' # workaround for bad angle of the arrow head + else: + edge_in = edge['in'] + + + tail = '' + head = '' + best_match = -1 + + for tail_candidate in view['node_key_paths'][edge_out]: + for head_candidate in view['node_key_paths'][edge_in]: + current_match = len(os.path.commonprefix([tail_candidate, head_candidate])) + if current_match > best_match: + tail = tail_candidate + head = head_candidate + best_match = current_match + + graph.edge(tail_name=tail, head_name=head, **edge['graphviz']) + + for attr_group in ['graph', 'node', 'edge']: + # https://graphviz.org/docs/nodes/ + # https://graphviz.org/docs/edges/ + # https://graphviz.org/docs/graph/ + if attr_group in view['graphviz']: + graph.attr(attr_group, **view['graphviz'][attr_group]) + graph.graph_attr['compound'] = 'true' + + graph.render(format=fmt) + + + diff --git a/hiearch/hiearch.py b/hiearch/hiearch.py new file mode 100644 index 0000000..89f529e --- /dev/null +++ b/hiearch/hiearch.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 + +import argparse +import yaml + +from . import node as node +from . import edge as edge +from . import view as view +from . import graphviz + + + +def parse(filenames): + nodes = {} + edges = {} + views = {} + must_exist_nodes = set() + must_exist_views = set() + styled_nodes = [] + styled_views = [] + + + for filename in filenames: + print(f'Processing {filename}') + with open(filename, encoding='utf-8') as file: + data = yaml.load(file, Loader=yaml.SafeLoader) + + if 'nodes' in data: + node.parse(data['nodes'], nodes, must_exist_nodes, styled_nodes) + + if 'edges' in data: + edge.parse(data['edges'], edges, must_exist_nodes) + + if 'views' in data: + view.parse(data['views'], views, must_exist_views, must_exist_nodes, styled_views) + + + node.postprocess(nodes, must_exist_nodes, styled_nodes, edges) + view.postprocess(views, must_exist_views, styled_views, nodes, edges) + + + return nodes, edges, views + + +def main(): + parser = argparse.ArgumentParser(prog='hiearch', description='Generates diagrams') + + parser.add_argument('inputs', metavar='', type=str, nargs='+', help='Input files') + parser.add_argument('-o', '--output', required=False, default='hiearch', help='Output directory') + parser.add_argument('-f', '--format', required=False, default='svg', help='Outpout format') + + args = parser.parse_args() + + nodes, edges, views = parse(args.inputs) + + for view in views.values(): + if len(view['nodes']) > 0: + graphviz.generate(args.output, args.format, view, nodes, edges) + + + +if __name__ == "__main__": + main() diff --git a/hiearch/node.py b/hiearch/node.py new file mode 100644 index 0000000..e6ec8e9 --- /dev/null +++ b/hiearch/node.py @@ -0,0 +1,177 @@ +import copy +from collections import defaultdict + +from . import util + + +def gather(prop, node, must_exist_nodes): + if prop in node.keys() and node[prop] is not None: + if isinstance(node[prop], list): + must_exist_nodes = must_exist_nodes.union(set(node[prop])) + else: + must_exist_nodes.add(node[prop]) + return True + return False + + +def postprocess(nodes, must_exist_nodes, styled_nodes, edges): + util.check_key_existence(must_exist_nodes, nodes, 'node') + util.apply_styles(styled_nodes, nodes) + + for node in nodes.values(): + node['out'] = set() + node['in'] = set() + node['child_out'] = set() + node['child_in'] = set() + + original_scope = node['scope'] + if original_scope is not None: + if isinstance(original_scope, list): + node['scope'] = set(original_scope) + + if not original_scope: + raise RuntimeError(f'Duplicate scopes: {key} | scopes: {original_scope} | file: {filename}XXXXX') + if len(original_scope) != len(node['scope']): + raise RuntimeError(f'Duplicate scopes: {key} | scopes: {original_scope} | file: {filename}') + else: + node['scope'] = set([original_scope]) + + for key, edge in edges.items(): + for dir_key in ['in', 'out']: + nodes[edge[dir_key]][dir_key].add(key) + + +def add_branch_to_tree(branch, tree, node_key_paths, scopes, index=0): + if index == len(branch): + return tree + else: + node_key = branch[index] + if index + 1 < len(branch): + if node_key in scopes: + scopes[node_key].add(branch[index + 1]) + else: + scopes[node_key] = set([branch[index + 1]]) + + if len(tree) > 0 and node_key in tree.keys(): + tree[node_key] = { + 'subtree': add_branch_to_tree(branch, tree[node_key]['subtree'], node_key_paths, scopes, index + 1), + 'key_path': tree[node_key]['key_path'] + } + else: + tree[node_key] = { + 'subtree': add_branch_to_tree(branch, {}, node_key_paths, scopes, index + 1), + 'key_path': '.'.join(branch[:index + 1]) + } + + node_key_paths[node_key].add(tree[node_key]['key_path']) + return tree + + +def build_tree(nodes, nodes_view): + rank = defaultdict(lambda: 0) + + branches = defaultdict(lambda: []) + branch = [None] + scope_stack = [set(nodes_view)] + + nonleaf = set() + + while len(scope_stack) > 0: + branch.pop() + updated_branch = False + while scope_stack[-1] is not None and len(scope_stack[-1]) > 0: + scope = scope_stack[-1].pop() + + if scope in branch: + raise RuntimeError(f'Detected cycle in branch: {branch} | {scope}') + if scope in nodes_view: + rank['scope'] += 1 + updated_branch = True + branch.append(scope) + if len(branch) > 1: + nonleaf.add(scope) + + scope_stack.append(copy.deepcopy(nodes[scope]['scope'])) + + if updated_branch: + branches[branch[0]].append(copy.deepcopy(branch)) + + scope_stack.pop() + + + scopes = {} + node_tree = {} + node_key_paths = defaultdict(lambda: set()) + for key, branch_list in branches.items(): + # a node may appear both as a leaf and nonleaf in a view with + # multiscoping, in such cases we should omit the leaf due to + # redundancy + if key not in nonleaf: + branch = branch_list[0] + for branch_merge in branch_list[1:]: + index = 0 + for node in branch_merge: + # if we reached the end of the original branch, or the nodes do not match + # find a place where the node must be inserted + if index == len(branch) or node != branch[index]: + while index < len(branch) and rank[node] > rank[branch[index]]: + index += 1 + if rank[node] == rank[branch[index]] and node > branch[index]: + index += 1 + branch.insert(index, node) + index += 1 + + branch.reverse() + node_tree = add_branch_to_tree(branch, node_tree, node_key_paths, scopes) + + + return node_tree, node_key_paths, scopes + + +def parse(yaml_nodes, nodes, must_exist_nodes, styled_nodes): + default = { + 'id': ['', ''], + 'scope': None, + 'style': None, + 'graphviz': {}, + 'node_label_format': '{label}', + 'scope_label_format': '{label}', + 'tags': ['default'], + # overriden + 'label': '', + 'in': [], + 'out': [], + 'child_in': [], + 'child_out': [], + } + + + for node in yaml_nodes: + node['label'] = node['id'][0] + node['id'] = node['id'][1] + key = node['id'] + if key in nodes.keys(): + raise RuntimeError(f'Duplicate node id: {key} | file: {filename}') + + gather('scope', node, must_exist_nodes) + if gather('style', node, must_exist_nodes): + styled_nodes.append(node) + nodes[key] = node + else: + nodes[key] = default | node + + +def get_formatted_node_label(node): + return node['node_label_format'].format(label=node['label'], id=node['id'], scope=node['scope'], style=node['style']) + + +def get_formatted_scope_label(node): + return node['scope_label_format'].format(label=node['label'], id=node['id'], scope=node['scope'], style=node['style']) + + +def get_nodes_by_tag(nodes, tag): + selection = [] + for node in nodes.values(): + if tag in node['tags']: + selection.append(node['id']) + return set(selection) diff --git a/hiearch/util.py b/hiearch/util.py new file mode 100644 index 0000000..9142a12 --- /dev/null +++ b/hiearch/util.py @@ -0,0 +1,29 @@ +import copy + + +def apply_styles(styled_entities, entities): + size = len(styled_entities) + while size > 0: + index = 0 + size_copy = copy.deepcopy(size) + while index < size: + father_entity = entities[styled_entities[index]['style']] + + if father_entity['style'] is None: + key = styled_entities[index]['id'] + entities[key] = father_entity | styled_entities[index] + entities[key]['style'] = None + styled_entities[index], styled_entities[size - 1] = styled_entities[size - 1], styled_entities[index] + size -= 1 + else: + branch = [] + index += 1 + if size_copy == size: + raise RuntimeError(f'Style cycle detected: {styled_entities}') + + +def check_key_existence(keys, dictionary, data_type): + for key in keys: + if key not in dictionary.keys(): + raise RuntimeError(f'Missing {data_type} id: {key}') + diff --git a/hiearch/view.py b/hiearch/view.py new file mode 100644 index 0000000..743e1c9 --- /dev/null +++ b/hiearch/view.py @@ -0,0 +1,143 @@ +import copy + +from . import node as hh_node +from . import edge as hh_edge +from . import util + + +class Neighbours(): + DIRECT = 'direct' + EXPLICIT = 'explicit' + PARENT = 'parent' + + types = [DIRECT, EXPLICIT, PARENT] + + + +default = { + 'id': 'default', + 'nodes': None, + 'neighbours': Neighbours.DIRECT, # explicit/direct/parent + 'graphviz': {}, + 'style': None, + 'tags': [], + # overriden + 'edges': [], + 'custom_edges': [], + 'tree': {}, +} + + +opposite = { + 'in': 'out', + 'out': 'in', +} + + +def postprocess(views, must_exist_views, styled_views, nodes, edges): + util.check_key_existence(must_exist_views, views, 'view') + util.apply_styles(styled_views, views) + + + if 0 == len(views): + views['default'] = default + views['default']['tags'] = ['default'] + + empty_views_counter = 0 + for view in views.values(): + if view['nodes'] is None: + view['nodes'] = set() + else: + num_nodes = len(view['nodes']) + view['nodes'] = set(view['nodes']) # set converted to list by | operator in apply_styles() + if len(view['nodes']) != num_nodes: + raise RuntimeError(f'Duplicate node ids in view: {view["id"]} | file: {filename} | nodes: [{view["nodes"]}] != [{views[key]["nodes"]}]') + + for tag in view['tags']: + view['nodes'] = view['nodes'].union(hh_node.get_nodes_by_tag(nodes, tag)) + + if 0 == len(view['nodes']): + empty_views_counter += 1 + continue + + + view['edges'] = {} + view['custom_edges'] = {} + add_nodes = set() + + if Neighbours.EXPLICIT == view['neighbours']: + for node_key in view['nodes']: + for dir_key in ['in', 'out']: + for edge_key in nodes[node_key][dir_key]: + if edges[edge_key][opposite[dir_key]] in view['nodes']: + view['edges'][edge_key] = copy.deepcopy(edges[edge_key]) + + elif Neighbours.DIRECT == view['neighbours']: + for node_key in view['nodes']: + for dir_key in ['in', 'out']: + for edge_key in nodes[node_key][dir_key]: + view['edges'][edge_key] = copy.deepcopy(edges[edge_key]) + + for edge_key in view['edges']: + for dir_key in ['in', 'out']: + add_nodes.add(edges[edge_key][dir_key]) + + elif Neighbours.PARENT == view['neighbours']: + add_nodes = set() + + for node_key in view['nodes']: + for dir_key in ['in', 'out']: + opp_dir_key = opposite[dir_key] + for edge_key in nodes[node_key][dir_key]: + nodes_to_explore = set([edges[edge_key][opp_dir_key]]) + + while len(nodes_to_explore) > 0: + scope_node_key = nodes_to_explore.pop() + + # go up the tree until reached an explicitly requested or a root node + while scope_node_key not in view['nodes'] and nodes[scope_node_key]['scope'] is not None: + scope_copy = copy.deepcopy(nodes[scope_node_key]['scope']) + scope_node_key = scope_copy.pop() + nodes_to_explore = nodes_to_explore.union(scope_copy) + + if scope_node_key not in add_nodes: # same node can be reached in multiple ways + add_nodes.add(scope_node_key) + if scope_node_key == edges[edge_key][opp_dir_key]: + view['edges'][edge_key] = copy.deepcopy(edges[edge_key]) + else: + # generate edge with a parent + new_edge = copy.deepcopy(edges[edge_key]) + new_edge[opp_dir_key] = scope_node_key + view['custom_edges'][hh_edge.get_key(new_edge)] = new_edge + + else: + raise RuntimeError(f'Unsupported neighbours type: {view["neighbours"]}, must be one of [{Neighbours.types}].') + + if len(add_nodes) > 0: + view['nodes'] = view['nodes'].union(add_nodes) + + + view['tree'], view['node_key_paths'], view['scopes'] = hh_node.build_tree(nodes, view['nodes']) + + + if empty_views_counter == len(views): + raise RuntimeError(f'All views are empty: {views.keys()}') + + +def parse(yaml_views, views, must_exist_views, must_exist_nodes, styled_views): + for view in yaml_views: + key = view['id'] + + if key in views.keys(): + raise RuntimeError(f'Duplicate view id: {key} | file: {filename}') + + if 'style' in view: + must_exist_views.add(view['style']) + styled_views.append(view) + + + views[key] = default | view + + if 'nodes' in view: + for node in view['nodes']: + must_exist_nodes.add(node) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cdf363c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "hiearch" +version = "0.0.1" +dependencies = [ + "graphviz", + "pyyaml" +] + +classifiers = [ + "Programming Language :: Python :: 3", +] + +[project.scripts] +hiearch = "hiearch.hiearch:main" + diff --git a/test/01_basic/01_basic.yaml b/test/01_basic/01_basic.yaml new file mode 100644 index 0000000..02846db --- /dev/null +++ b/test/01_basic/01_basic.yaml @@ -0,0 +1,40 @@ +nodes: + - id: ["Test 1", test1] + + - id: ["Test 2", test2] + style: test1 + + - id: ["Test 3", test3] + style: test1 + scope: test2 + +edges: + - link: [test1, test2] + - link: [test1, test3] + +views: + - id: style + nodes: [] + graphviz: + graph: + rankdir: LR + compound: "true" + node: + fontsize: "18" + fontname: times + edge: + decorate: "true" + fontsize: "14" + + - id: explicit + nodes: [test1] + neighbours: explicit + + - id: direct + nodes: [test1] + neighbours: direct + style: style + + - id: parent + nodes: [test1] + neighbours: parent diff --git a/test/01_basic/direct.gv b/test/01_basic/direct.gv new file mode 100644 index 0000000..895b732 --- /dev/null +++ b/test/01_basic/direct.gv @@ -0,0 +1,13 @@ +digraph direct { + graph [compound=true] + subgraph test2 { + graph [cluster=true label="Test 2"] + "test2.test3" [label="Test 3"] + } + test1 [label="Test 1"] + test1 -> "test2.test3" [headclip=false lhead=test2] + test1 -> "test2.test3" + graph [compound=true rankdir=LR] + node [fontname=times fontsize=18] + edge [decorate=true fontsize=14] +} diff --git a/test/01_basic/explicit.gv b/test/01_basic/explicit.gv new file mode 100644 index 0000000..0e126fc --- /dev/null +++ b/test/01_basic/explicit.gv @@ -0,0 +1,4 @@ +digraph explicit { + graph [compound=true] + test1 [label="Test 1"] +} diff --git a/test/01_basic/parent.gv b/test/01_basic/parent.gv new file mode 100644 index 0000000..0cfe640 --- /dev/null +++ b/test/01_basic/parent.gv @@ -0,0 +1,6 @@ +digraph parent { + graph [compound=true] + test2 [label="Test 2"] + test1 [label="Test 1"] + test1 -> test2 +} diff --git a/test/02_default_view/02_default_view.yaml b/test/02_default_view/02_default_view.yaml new file mode 100644 index 0000000..22b1969 --- /dev/null +++ b/test/02_default_view/02_default_view.yaml @@ -0,0 +1,13 @@ +nodes: + - id: ["Test 1", test1] + + - id: ["Test 2", test2] + style: test1 + + - id: ["Test 3", test3] + style: test1 + scope: test2 + +edges: + - link: [test1, test2] + - link: [test1, test3] diff --git a/test/02_default_view/default.gv b/test/02_default_view/default.gv new file mode 100644 index 0000000..00ef4af --- /dev/null +++ b/test/02_default_view/default.gv @@ -0,0 +1,10 @@ +digraph default { + graph [compound=true] + subgraph test2 { + graph [cluster=true label="Test 2"] + "test2.test3" [label="Test 3"] + } + test1 [label="Test 1"] + test1 -> "test2.test3" [headclip=false lhead=test2] + test1 -> "test2.test3" +} diff --git a/test/03_default_view_split/03_default_view_split1.yaml b/test/03_default_view_split/03_default_view_split1.yaml new file mode 100644 index 0000000..d4a1ccb --- /dev/null +++ b/test/03_default_view_split/03_default_view_split1.yaml @@ -0,0 +1,9 @@ +nodes: + - id: ["Test 1", test1] + + - id: ["Test 2", test2] + style: test1 + + - id: ["Test 3", test3] + style: test1 + scope: test2 diff --git a/test/03_default_view_split/03_default_view_split2.yaml b/test/03_default_view_split/03_default_view_split2.yaml new file mode 100644 index 0000000..16b1ed3 --- /dev/null +++ b/test/03_default_view_split/03_default_view_split2.yaml @@ -0,0 +1,3 @@ +edges: + - link: [test1, test2] + - link: [test1, test3] diff --git a/test/03_default_view_split/default.gv b/test/03_default_view_split/default.gv new file mode 100644 index 0000000..3c57fac --- /dev/null +++ b/test/03_default_view_split/default.gv @@ -0,0 +1,10 @@ +digraph default { + graph [compound=true] + subgraph test2 { + graph [cluster=true label="Test 2"] + "test2.test3" [label="Test 3"] + } + test1 [label="Test 1"] + test1 -> "test2.test3" + test1 -> "test2.test3" [headclip=false lhead=test2] +} diff --git a/test/04_node_cycle/04_node_cycle.yaml b/test/04_node_cycle/04_node_cycle.yaml new file mode 100644 index 0000000..9591c77 --- /dev/null +++ b/test/04_node_cycle/04_node_cycle.yaml @@ -0,0 +1,9 @@ +nodes: + - id: ["Test 1", test1] + + - id: ["Test 2", test2] + scope: test3 + + - id: ["Test 3", test3] + scope: test2 + diff --git a/test/05_style_cycle/05_style_cycle.yaml b/test/05_style_cycle/05_style_cycle.yaml new file mode 100644 index 0000000..80ffb4c --- /dev/null +++ b/test/05_style_cycle/05_style_cycle.yaml @@ -0,0 +1,9 @@ +nodes: + - id: ["Test 1", test1] + + - id: ["Test 2", test2] + style: test3 + + - id: ["Test 3", test3] + style: test2 + diff --git a/test/06_multiscope/06_multiscope.yaml b/test/06_multiscope/06_multiscope.yaml new file mode 100644 index 0000000..9879d7a --- /dev/null +++ b/test/06_multiscope/06_multiscope.yaml @@ -0,0 +1,10 @@ +nodes: + - id: ["Test 1", test1] + - id: ["Test 2", test2] + - id: ["Test 3", test3] + scope: [test1, test2] + - id: ["Test 4", test4] + scope: [test2] + - id: ["Test 5", test5] + scope: [test1] + diff --git a/test/06_multiscope/default.gv b/test/06_multiscope/default.gv new file mode 100644 index 0000000..e884297 --- /dev/null +++ b/test/06_multiscope/default.gv @@ -0,0 +1,15 @@ +digraph default { + graph [compound=true] + subgraph test2 { + graph [cluster=true label="Test 2"] + subgraph "test2.test1" { + graph [cluster=true label="Test 1"] + "test2.test1.test3" [label="Test 3"] + } + "test2.test4" [label="Test 4"] + } + subgraph test1 { + graph [cluster=true label="Test 1"] + "test1.test5" [label="Test 5"] + } +} diff --git a/test/07_trivial/input.yaml b/test/07_trivial/input.yaml new file mode 100644 index 0000000..5d5c78f --- /dev/null +++ b/test/07_trivial/input.yaml @@ -0,0 +1,9 @@ +nodes: + - id: ["Test 1", test1] + +edges: + - link: [test1, test1] + +views: + - id: view1 + nodes: [test1] diff --git a/test/07_trivial/view1.gv b/test/07_trivial/view1.gv new file mode 100644 index 0000000..d38122c --- /dev/null +++ b/test/07_trivial/view1.gv @@ -0,0 +1,5 @@ +digraph view1 { + graph [compound=true] + test1 [label="Test 1"] + test1 -> test1 +} diff --git a/test/07_trivial/view1.gv.svg b/test/07_trivial/view1.gv.svg new file mode 100644 index 0000000..4bc2e59 --- /dev/null +++ b/test/07_trivial/view1.gv.svg @@ -0,0 +1,25 @@ + + + + + + +view1 + + + +test1 + +Test 1 + + + +test1->test1 + + + + +