diff --git a/src/datajoint/diagram.py b/src/datajoint/diagram.py index 20cd34b58..d1dde45a5 100644 --- a/src/datajoint/diagram.py +++ b/src/datajoint/diagram.py @@ -47,6 +47,107 @@ logger = logging.getLogger(__name__.split(".")[0]) +# Structural node attributes per tier — shape, sizing, and whether the box has +# rounded corners. These are theme-independent; only the colors change with the +# theme. `_scale` matches the historical 1.2 scaling factor for fonts and boxes. +_scale = 1.2 +_TIER_STRUCTURE = { + None: dict(shape="circle", fontsize=round(_scale * 8), size=0.4 * _scale, fixed=False, rounded=False), + Manual: dict(shape="box", fontsize=round(_scale * 10), size=0.4 * _scale, fixed=False, rounded=True), + Lookup: dict(shape="box", fontsize=round(_scale * 10), size=0.4 * _scale, fixed=False, rounded=True), + Computed: dict(shape="ellipse", fontsize=round(_scale * 10), size=0.4 * _scale, fixed=False, rounded=False), + Imported: dict(shape="ellipse", fontsize=round(_scale * 10), size=0.4 * _scale, fixed=False, rounded=False), + # A part inherits its master's tier and so has no tier-shape of its own; + # historically it was drawn boxless. It nonetheless gets a neutral subtle box + # (not a tier shape) so that on the platform each part is a clickable target + # that opens the table. Keep the box for that reason. + Part: dict(shape="box", fontsize=round(_scale * 8), size=0.1 * _scale, fixed=False, rounded=True), + "collapsed": dict(shape="box3d", fontsize=round(_scale * 10), size=0.5 * _scale, fixed=False, rounded=False), +} + +# Color themes (#1532). Each tier gets a (fill, stroke, text) triple. Edge colors +# share a single alpha so a renamed (amber) edge sits at the same visual density +# as ordinary edges, differing only in hue. +_DIAGRAM_THEMES = { + "light": dict( + bg=None, + palette={ + None: ("#FFFDE7", "#C9BC5B", "#6B6420"), + Manual: ("#E7F3EC", "#2F7D5B", "#1B5138"), + Lookup: ("#F2F4F7", "#A9B1BD", "#495261"), + Computed: ("#FBEAEC", "#B23A48", "#7C2430"), + Imported: ("#E2ECFA", "#2A5FA5", "#123A6D"), + Part: ("#FFFFFF", "#9AA6B8", "#46536B"), + "collapsed": ("#EDEEF0", "#808890", "#404040"), + }, + edge="#3A424F", + edge_renamed="#C77D3A", + edge_alpha="9E", + schema_cluster=("gray", "gray"), + entity_fill="#F3F5F8", + ), + "dark": dict( + bg="#161A21", + palette={ + None: ("#3A3620", "#C9BC5B", "#EBE3A0"), + Manual: ("#16281F", "#4FA97F", "#BCE6CF"), + Lookup: ("#242832", "#8A93A1", "#C9CFD9"), + Computed: ("#331A1F", "#D0687A", "#F3C2CB"), + Imported: ("#152538", "#5E92D6", "#C3DAF6"), + Part: ("#1E232C", "#7B879B", "#C4CCDB"), + "collapsed": ("#242730", "#8890A0", "#C7CDD6"), + }, + edge="#AEB6C2", + edge_renamed="#D68C4A", + edge_alpha="C0", + schema_cluster=("#606875", "#8A93A1"), + entity_fill="#2A313D", + ), +} + + +def _adaptive_style_block() -> str: + """ + Build a ``" + + class Diagram(nx.MultiDiGraph): # noqa: C901 """ Schema diagram as a directed acyclic graph (DAG). @@ -92,7 +193,7 @@ class Diagram(nx.MultiDiGraph): # noqa: C901 Layout direction is controlled via ``dj.config.display.diagram_direction`` (default ``"TB"``). Use ``dj.config.override()`` to change temporarily:: - with dj.config.override(display_diagram_direction="LR"): + with dj.config.override(display__diagram_direction="LR"): dj.Diagram(schema).draw() """ @@ -558,7 +659,7 @@ def __getitem__(self, key): >>> trace["my_schema.Session"].to_dicts() # string index → FreeTable """ # Non-trace diagrams: defer to networkx adjacency lookup so existing - # `diagram[node_name]` patterns (used in diagram algebra, ERD tests) + # `diagram[node_name]` patterns (used in diagram algebra, diagram tests) # keep working. if getattr(self, "_mode", None) != "trace": return super().__getitem__(key) @@ -1095,15 +1196,17 @@ def _make_graph(self) -> nx.MultiDiGraph: nx.MultiDiGraph Graph with nodes relabeled to class names. """ - # mark "distinguished" tables, i.e. those that introduce new primary key - # attributes + # Mark tables that introduce a new schema dimension, i.e. that add a + # primary-key attribute of their own beyond what they inherit through + # foreign keys. These are drawn with an underlined label. ("Schema + # dimension" / "axis" is the documented term for such a table.) # Filter nodes_to_show to only include nodes that exist in the graph valid_nodes = self.nodes_to_show.intersection(set(self.nodes())) for name in valid_nodes: foreign_attributes = set( attr for p in self.in_edges(name, data=True) for attr in p[2]["attr_map"] if p[2]["primary"] ) - self.nodes[name]["distinguished"] = ( + self.nodes[name]["introduces_dimension"] = ( "primary_key" in self.nodes[name] and foreign_attributes < self.nodes[name]["primary_key"] ) # construct subgraph and rename nodes to class names. A MultiDiGraph is @@ -1339,7 +1442,7 @@ def _encapsulate_node_names(graph: nx.MultiDiGraph) -> None: copy=False, ) - def make_dot(self): + def make_dot(self, theme=None): """ Generate a pydot graph object. @@ -1410,72 +1513,23 @@ def make_dot(self): if data.get("collapsed") and data.get("schema_name"): schema_map[node] = data["schema_name"] - scale = 1.2 # scaling factor for fonts and boxes - label_props = { # http://matplotlib.org/examples/color/named_colors.html - None: dict( - shape="circle", - color="#FFFF0040", - fontcolor="yellow", - fontsize=round(scale * 8), - size=0.4 * scale, - fixed=False, - ), - Manual: dict( - shape="box", - color="#00FF0030", - fontcolor="darkgreen", - fontsize=round(scale * 10), - size=0.4 * scale, - fixed=False, - ), - Lookup: dict( - shape="plaintext", - color="#00000020", - fontcolor="black", - fontsize=round(scale * 8), - size=0.4 * scale, - fixed=False, - ), - Computed: dict( - shape="ellipse", - color="#FF000020", - fontcolor="#7F0000A0", - fontsize=round(scale * 10), - size=0.4 * scale, - fixed=False, - ), - Imported: dict( - shape="ellipse", - color="#00007F40", - fontcolor="#00007FA0", - fontsize=round(scale * 10), - size=0.4 * scale, - fixed=False, - ), - Part: dict( - shape="plaintext", - color="#00000000", - fontcolor="black", - fontsize=round(scale * 8), - size=0.1 * scale, - fixed=False, - ), - "collapsed": dict( - shape="box3d", - color="#80808060", - fontcolor="#404040", - fontsize=round(scale * 10), - size=0.5 * scale, - fixed=False, - ), - } - # Build node_props, handling collapsed nodes specially + # Select the color theme (#1532). Structure (shape/size/rounded) is + # theme-independent; only the fill/stroke/text colors change. `theme` + # (an explicit argument) overrides the configured default; "auto" is + # rendered in light colors here and made adaptive at the SVG layer, so + # it maps to the light palette. + theme_name = theme or self._connection._config.display.diagram_theme + theme = _DIAGRAM_THEMES.get(theme_name if theme_name != "auto" else "light", _DIAGRAM_THEMES["light"]) + palette = theme["palette"] + + # Build node_props by merging the structural attributes for each tier + # with the theme's (fill, stroke, text) colors. Collapsed nodes use the + # "collapsed" entry. node_props = {} for node, d in graph.nodes(data=True): - if d.get("collapsed"): - node_props[node] = label_props["collapsed"] - else: - node_props[node] = label_props[d["node_type"]] + tier = "collapsed" if d.get("collapsed") else d["node_type"] + fill, stroke, text = palette[tier] + node_props[node] = dict(_TIER_STRUCTURE[tier], fill=fill, stroke=stroke, fontcolor=text) # A renamed (aliased) FK is drawn as a distinctly-colored edge (there # is no longer an intermediate "alias" node); describe the column @@ -1494,6 +1548,44 @@ def make_dot(self): self._encapsulate_edge_attributes(graph) dot = nx.drawing.nx_pydot.to_pydot(graph) dot.set_rankdir(direction) + if theme["bg"]: + dot.set_bgcolor(theme["bg"]) + + # Master↔part grouping (#1532): map each part to its master, and record + # which parts depend on a sibling part so an intra-group chain can + # descend rather than share a rank. Nodes may be named either by class + # ("Master.Part") when a context resolves them, or by raw table name + # ("schema.master__part") otherwise, so the master is found by trying + # both suffix conventions against the actual node keys. Everything here + # is keyed by the stripped node name (matching the loops below). + key_by_stripped = {k.strip('"'): k for k in graph.nodes()} + + def _master_of(part_stripped): + candidates = set() + if "." in part_stripped: + candidates.add(part_stripped.rsplit(".", 1)[0]) # class: Master.Part -> Master + if "__" in part_stripped: + candidates.add(part_stripped.rsplit("__", 1)[0]) # table: ...master__part -> ...master + for candidate in candidates: + if candidate in key_by_stripped: + return candidate + return None + + part_master = {} + for gname, gdata in graph.nodes(data=True): + if gdata.get("node_type") is Part: + part_stripped = gname.strip('"') + master_stripped = _master_of(part_stripped) + if master_stripped is not None: + part_master[part_stripped] = master_stripped + part_names = set(part_master) + depends_on_sibling = set() + for part_stripped, master_stripped in part_master.items(): + for pred in graph.predecessors(key_by_stripped[part_stripped]): + pred_stripped = pred.strip('"') + if pred_stripped in part_names and part_master.get(pred_stripped) == master_stripped: + depends_on_sibling.add(part_stripped) + for node in dot.get_nodes(): node.set_shape("circle") name = node.get_name().strip('"') @@ -1501,10 +1593,11 @@ def make_dot(self): node.set_fontsize(props["fontsize"]) node.set_fontcolor(props["fontcolor"]) node.set_shape(props["shape"]) - node.set_fontname("arial") + node.set_fontname("Helvetica") node.set_fixedsize("shape" if props["fixed"] else False) node.set_width(props["size"]) node.set_height(props["size"]) + node.set_margin("0.11,0.06") # generous label padding (inches) # Handle collapsed nodes specially node_data = graph.nodes.get(f'"{name}"', {}) @@ -1525,14 +1618,20 @@ def make_dot(self): node.set_tooltip(" ".join(description)) # Strip module prefix from label if it matches the cluster label display_name = name - schema_name = schema_map.get(name) - if schema_name and "." in name: - cluster_label = cluster_labels.get(schema_name) - if cluster_label and name.startswith(cluster_label + "."): - display_name = name[len(cluster_label) + 1 :] - node.set_label("<" + display_name + ">" if node.get("distinguished") == "True" else display_name) - node.set_color(props["color"]) - node.set_style("filled") + if name in part_names: + # The entity cluster carries master membership, so a part + # shows only its own name (`Scan`, not `Acquisition.Scan`). + display_name = name.rsplit(".", 1)[-1] + else: + schema_name = schema_map.get(name) + if schema_name and "." in name: + cluster_label = cluster_labels.get(schema_name) + if cluster_label and name.startswith(cluster_label + "."): + display_name = name[len(cluster_label) + 1 :] + node.set_label("<" + display_name + ">" if node.get("introduces_dimension") == "True" else display_name) + node.set_fillcolor(props["fill"]) + node.set_color(props["stroke"]) + node.set_style("rounded,filled" if props.get("rounded") else "filled") for edge in dot.get_edges(): # see https://graphviz.org/doc/info/attrs.html @@ -1540,19 +1639,28 @@ def make_dot(self): # pydot edge — to_pydot stringifies the edge data, so booleans arrive # as "True"/"False". This is parallel-edge-safe: each FK between the # same pair of tables is its own pydot edge. - src = edge.get_source() - dest = edge.get_destination() primary = str(edge.get("primary")) == "True" multi = str(edge.get("multi")) == "True" aliased = str(edge.get("aliased")) == "True" - # Renamed FK → distinct color; others → the usual translucent black. - edge.set_color("#FF8800" if aliased else "#00000040") + # Renamed FK → a distinct, desaturated amber consistent with the + # modernized palette (#1532); others → a translucent slate. Both + # share the theme's edge alpha so the amber sits at the same visual + # density as ordinary edges, differing only in hue. + base = theme["edge_renamed"] if aliased else theme["edge"] + edge.set_color(base + theme["edge_alpha"]) edge.set_style("solid" if primary else "dashed") - dest_node_type = graph.nodes[dest].get("node_type") - master_part = dest_node_type is Part and dest.startswith(src + ".") - edge.set_weight(3 if master_part else 1) - edge.set_arrowhead("none") + # Line weight encodes cardinality, and only cardinality. `multi` is + # True when the child has primary-key attributes beyond those this + # foreign key contributes — whether newly declared or inherited from + # another foreign key — i.e. a one-to-many dependency, drawn thin. + # When the foreign key constitutes the child's *entire* primary key + # the dependency is 1:1, drawn thick. Master-part is NOT a weight: a + # part almost always adds a key attribute, so its edge is thin under + # this same rule. penwidth is the visible thickness; the layout + # `weight` hint follows the same predicate so the two never diverge. edge.set_penwidth(0.75 if multi else 2) + edge.set_weight(1 if multi else 3) + edge.set_arrowhead("none") # Group nodes into schema clusters (always on) if schema_map: @@ -1568,27 +1676,87 @@ def make_dot(self): schemas[schema_name] = [] schemas[schema_name].append(node) - # Create clusters for each schema - # Use Python module name if 1:1 mapping, otherwise database schema name + # Create clusters for each schema. Within a schema, a master and its + # parts are enclosed together in a nested, unlabeled entity cluster + # (#1532); master and its parts share a rank so horizontal reads as + # derivation and vertical as containment, except a part that depends + # on a sibling part, which is left off the rank so the intra-group + # chain descends. for schema_name, nodes in schemas.items(): label = cluster_labels.get(schema_name, schema_name) + sc_color, sc_fontcolor = theme["schema_cluster"] cluster = pydot.Cluster( f"cluster_{schema_name}", label=label, - style="dashed", - color="gray", - fontcolor="gray", + labelloc="t", + labeljust="r", # schema name in the top-right corner of the cluster + style="rounded,dashed", + color=sc_color, + fontcolor=sc_fontcolor, + fontname="Helvetica", # schema label in a sans font, not Graphviz's Times default ) - for node in nodes: - cluster.add_node(node) + node_by_name = {n.get_name().strip('"'): n for n in nodes} + # masters in this schema that have at least one part present + masters_here = {} + for pn in part_names: + mn = part_master[pn] + if pn in node_by_name and mn in node_by_name: + masters_here.setdefault(mn, []).append(pn) + + grouped = set() + for master_name, parts in masters_here.items(): + # Subtle rounded shaded background (no dashed frame) — the + # entity grouping should read quietly, not compete with the + # schema box. + entity = pydot.Cluster( + "cluster_entity_" + master_name.replace(".", "_"), + label="", + style="rounded,filled", + fillcolor=theme["entity_fill"], + color=theme["entity_fill"], + ) + entity.add_node(node_by_name[master_name]) + grouped.add(master_name) + same_rank = [node_by_name[master_name].get_name()] + for pn in parts: + entity.add_node(node_by_name[pn]) + grouped.add(pn) + if pn not in depends_on_sibling: + same_rank.append(node_by_name[pn].get_name()) + if len(same_rank) > 1: + rank = pydot.Subgraph(rank="same") + for nm in same_rank: + rank.add_node(pydot.Node(nm)) + entity.add_subgraph(rank) + cluster.add_subgraph(entity) + + for name, node in node_by_name.items(): + if name not in grouped: + cluster.add_node(node) dot.add_subgraph(cluster) return dot + def svg_string(self) -> str: + """ + Render the diagram to an SVG string, honoring the color theme. + + For ``theme="auto"`` the diagram is rendered in light colors and a + ``prefers-color-scheme`` style block is injected so a single image + adapts to the viewer's light or dark mode. Other themes render directly. + """ + theme_name = self._connection._config.display.diagram_theme + if theme_name == "auto": + svg = self.make_dot(theme="light").create_svg().decode() + # Insert the adaptive style block right after the opening . + insert_at = svg.find(">", svg.find("]*>([^<]+)", svg) + assert "C" in texts, "part B.C should display as 'C' (master prefix dropped)" + assert "B.C" not in texts, "the part label must not include the master prefix" + + def test_decorator(schema_simp): assert issubclass(A, dj.Lookup) assert not issubclass(A, dj.Part) @@ -24,10 +38,10 @@ def test_dependencies(schema_simp): assert set(deps.descendants(L.full_table_name)).issubset(cls.full_table_name for cls in (L, D, E, E.F, E.G, E.H, E.M, G)) -def test_erd(schema_simp): +def test_diagram(schema_simp): assert dj.diagram.diagram_active, "Failed to import networkx and pydot" - erd = dj.Diagram(schema_simp, context=LOCALS_SIMPLE) - graph = erd._make_graph() + diagram = dj.Diagram(schema_simp, context=LOCALS_SIMPLE) + graph = diagram._make_graph() assert set(cls.__name__ for cls in (A, B, D, E, L)).issubset(graph.nodes()) @@ -46,21 +60,21 @@ def test_diagram_algebra(schema_simp): def test_repr_svg(schema_adv): - erd = dj.Diagram(schema_adv, context=dict()) - svg = erd._repr_svg_() + diagram = dj.Diagram(schema_adv, context=dict()) + svg = diagram._repr_svg_() assert svg.startswith("") def test_make_image(schema_simp): - erd = dj.Diagram(schema_simp, context=dict()) - img = erd.make_image() + diagram = dj.Diagram(schema_simp, context=dict()) + img = diagram.make_image() assert img.ndim == 3 and img.shape[2] in (3, 4) def test_part_table_parsing(schema_simp): # https://github.com/datajoint/datajoint-python/issues/882 - erd = dj.Diagram(schema_simp, context=LOCALS_SIMPLE) - graph = erd._make_graph() + diagram = dj.Diagram(schema_simp, context=LOCALS_SIMPLE) + graph = diagram._make_graph() assert "OutfitLaunch" in graph.nodes() assert "OutfitLaunch.OutfitPiece" in graph.nodes() diff --git a/tests/integration/test_diagram_edge_weight.py b/tests/integration/test_diagram_edge_weight.py new file mode 100644 index 000000000..e787b270e --- /dev/null +++ b/tests/integration/test_diagram_edge_weight.py @@ -0,0 +1,125 @@ +""" +Guards the diagram edge-weight (cardinality) rule (#1532). + +Line weight encodes cardinality only, and it is binary: +- **thick** (penwidth 2): the foreign key constitutes the child's *entire* + primary key -> a 1:1 dependency. +- **thin** (penwidth 0.75): the child has primary-key attributes beyond those + the foreign key contributes (newly declared, or inherited from another foreign + key) -> a one-to-many dependency. + +Master-part is NOT a weight: a part almost always adds a key attribute, so its +edge is thin under this same rule. This test pins that, since the historical +documentation inverted it ("thick = master-part"). +""" + +import time + +import pytest + +import datajoint as dj + +THICK = 2.0 +THIN = 0.75 + + +@pytest.fixture(scope="function") +def schema_by_backend(connection_by_backend, db_creds_by_backend): + backend = db_creds_by_backend["backend"] + test_id = str(int(time.time() * 1000))[-8:] + schema_name = f"djtest_edgewt_{backend}_{test_id}"[:64] + if connection_by_backend.is_connected: + try: + connection_by_backend.query( + f"DROP DATABASE IF EXISTS {connection_by_backend.adapter.quote_identifier(schema_name)}" + ) + except Exception: + pass + schema = dj.Schema(schema_name, connection=connection_by_backend) + yield schema + if connection_by_backend.is_connected: + try: + connection_by_backend.query( + f"DROP DATABASE IF EXISTS {connection_by_backend.adapter.quote_identifier(schema_name)}" + ) + except Exception: + pass + + +def _penwidth_by_dest(dot): + """Map each edge's destination-node tail -> penwidth (float).""" + out = {} + for edge in dot.get_edges(): + dest = edge.get_destination().strip('"').lower() + try: + pw = float(edge.get_penwidth()) + except (TypeError, ValueError): + pw = None + out.setdefault(dest, []).append((edge.get_source().strip('"').lower(), pw)) + return out + + +def _penwidth_for(edges_by_dest, dest_name): + matches = edges_by_dest.get(dest_name, []) + assert matches, f"no edge found into node {dest_name!r}; nodes: {list(edges_by_dest)}" + return matches + + +def test_edge_weight_encodes_cardinality(schema_by_backend): + if not dj.diagram.diagram_active: + pytest.skip("networkx/pydot not available") + + @schema_by_backend + class Parent(dj.Manual): + definition = """ + parent_id : int32 + """ + + class Part(dj.Part): + definition = """ + -> master + part_id : int32 + """ + + @schema_by_backend + class OneToOne(dj.Manual): + definition = """ + -> Parent + """ + + @schema_by_backend + class OneToMany(dj.Manual): + definition = """ + -> Parent + sub_id : int32 + """ + + @schema_by_backend + class RenamedOneToOne(dj.Manual): + # A renamed foreign key can still be 1:1: the renamed column is + # RenamedOneToOne's entire primary key, so the dependency is 1:1 -> thick. + # The rule must compare child columns to the child PK, not parent-PK + # names to child-PK names (which renaming would break). + definition = """ + -> Parent.proj(alt_parent_id='parent_id') + """ + + dot = dj.Diagram(schema_by_backend).make_dot() + edges = _penwidth_by_dest(dot) + + # 1:1 — the FK is OneToOne's entire primary key -> thick. + assert all( + pw == THICK for _, pw in _penwidth_for(edges, "onetoone") + ), f"1:1 dependency must be thick ({THICK}); edges={edges}" + # multi-valued — OneToMany adds `sub_id` -> thin. + assert all( + pw == THIN for _, pw in _penwidth_for(edges, "onetomany") + ), f"multi-valued dependency must be thin ({THIN}); edges={edges}" + # master -> part — the part adds `part_id` -> thin (NOT thick). + assert all( + pw == THIN for _, pw in _penwidth_for(edges, "parent.part") + ), f"master-part edge must be thin ({THIN}); it is not a 1:1 dependency; edges={edges}" + # renamed FK that is the child's whole primary key — still 1:1 -> thick. + assert all( + pw == THICK for _, pw in _penwidth_for(edges, "renamedonetoone") + ), f"a renamed 1:1 foreign key must be thick ({THICK}); the rule must be rename-safe; edges={edges}" diff --git a/tests/integration/test_diagram_style.py b/tests/integration/test_diagram_style.py new file mode 100644 index 000000000..984de4ed6 --- /dev/null +++ b/tests/integration/test_diagram_style.py @@ -0,0 +1,111 @@ +""" +Style-contract (visual-regression) guard for the modernized dj.Diagram (#1532). + +Rather than diff exact SVG geometry against a checked-in reference — which drifts +with the Graphviz version — this renders a fixed schema per theme and asserts the +style invariants the restyle controls: each tier's fill/stroke palette, the +thick/thin edge weights, rounded boxes, entity clusters, the dark background, and +the adaptive `prefers-color-scheme` block. A palette, weight, or theme regression +fails here; a layout tweak does not. +""" + +import time + +import pytest + +import datajoint as dj + + +@pytest.fixture(scope="function") +def schema_by_backend(connection_by_backend, db_creds_by_backend): + backend = db_creds_by_backend["backend"] + test_id = str(int(time.time() * 1000))[-8:] + schema_name = f"djtest_style_{backend}_{test_id}"[:64] + if connection_by_backend.is_connected: + try: + connection_by_backend.query( + f"DROP DATABASE IF EXISTS {connection_by_backend.adapter.quote_identifier(schema_name)}" + ) + except Exception: + pass + schema = dj.Schema(schema_name, connection=connection_by_backend) + yield schema + if connection_by_backend.is_connected: + try: + connection_by_backend.query( + f"DROP DATABASE IF EXISTS {connection_by_backend.adapter.quote_identifier(schema_name)}" + ) + except Exception: + pass + + +def _build(schema): + @schema + class Subject(dj.Manual): + definition = "subject_id : int32" + + @schema + class Params(dj.Lookup): + definition = "param_id : int32" + + @schema + class Session(dj.Manual): + definition = "-> Subject\nsession_id : int32" + + class Note(dj.Part): + definition = "-> master\nnote_id : int32" + + @schema + class Scan(dj.Imported): + definition = "-> Session" # 1:1 -> thick edge + + @schema + class Analysis(dj.Computed): + definition = "-> Scan\n-> Params" # composite -> thin edge + + # Return a context so the diagram resolves nodes to class names (the normal + # rendering path — users have their classes in scope). + return dict(Subject=Subject, Params=Params, Session=Session, Scan=Scan, Analysis=Analysis) + + +def _svg(schema, context, theme): + with dj.config.override(display__diagram_theme=theme): + return dj.Diagram(schema, context=context).svg_string().lower() + + +def test_light_theme_style(schema_by_backend): + if not dj.diagram.diagram_active: + pytest.skip("networkx/pydot not available") + ctx = _build(schema_by_backend) + svg = _svg(schema_by_backend, ctx, "light") + # tier fills + for fill in ("#e7f3ec", "#f2f4f7", "#e2ecfa", "#fbeaec", "#ffffff"): + assert fill in svg, f"light tier fill {fill} missing" + # a couple tier strokes + for stroke in ("#2f7d5b", "#b23a48"): + assert stroke in svg, f"light tier stroke {stroke} missing" + # thick (1:1) and thin (multi) edge weights both present + assert 'stroke-width="2"' in svg, "thick (1:1) edge missing" + assert 'stroke-width="0.75"' in svg, "thin (multi-valued) edge missing" + assert "cluster_entity_" in svg, "entity cluster missing" + assert "161a21" not in svg, "light theme must not use the dark background" + + +def test_dark_theme_style(schema_by_backend): + if not dj.diagram.diagram_active: + pytest.skip("networkx/pydot not available") + ctx = _build(schema_by_backend) + svg = _svg(schema_by_backend, ctx, "dark") + assert "#161a21" in svg, "dark background missing" + for fill in ("#16281f", "#152538", "#331a1f"): + assert fill in svg, f"dark tier fill {fill} missing" + + +def test_auto_theme_is_adaptive(schema_by_backend): + if not dj.diagram.diagram_active: + pytest.skip("networkx/pydot not available") + ctx = _build(schema_by_backend) + svg = _svg(schema_by_backend, ctx, "auto") + assert "@media (prefers-color-scheme: dark)" in svg, "auto theme must inject the adaptive media block" + # base render is light; the media block maps a light color to its dark counterpart + assert "#e7f3ec" in svg and "#16281f" in svg, "auto theme must carry both light base and dark override colors"