Coverage for tests/test_diagram.py: 100%
373 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-16 15:24 -0700
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-16 15:24 -0700
1# This file is part of lsst-images.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# Use of this source code is governed by a 3-clause BSD-style
10# license that can be found in the LICENSE file.
11from __future__ import annotations
13import importlib.metadata
14import os.path
15from pathlib import Path
16from typing import Annotated, Any
17from unittest import mock
19import pydantic
20import pytest
21from click.testing import CliRunner
23from lsst.images import VisitImageSerializationModel
24from lsst.images.cells import CellCoaddSerializationModel
25from lsst.images.cli import main
26from lsst.images.diagram import (
27 DEFAULT_LEAF_TYPES,
28 Policy,
29 build_graph,
30 build_instance_graph,
31 graph_from_file,
32 make_policy,
33 render,
34)
35from lsst.images.serialization import JsonRef
37TESTDIR = os.path.abspath(os.path.dirname(__file__))
40class Child(pydantic.BaseModel):
41 """A leaf model with a single scalar field."""
43 x: int
46class Other(pydantic.BaseModel):
47 """A second leaf model, used for union members."""
49 y: int
52class Parent(pydantic.BaseModel):
53 """A model with a scalar field and a nested model field."""
55 name: str
56 child: Child
59class Containers(pydantic.BaseModel):
60 """A model exercising optional, list, dict, and union fields."""
62 maybe: Child | None
63 many: list[Child]
64 mapping: dict[str, Child]
65 either: Child | Other
66 open_union: Child | Any
69class OptionalContainers(pydantic.BaseModel):
70 """A model with optional containers of nested models."""
72 maybe_many: list[Child] | None
73 maybe_mapping: dict[str, Child] | None
76class TreeNode(pydantic.BaseModel):
77 """A self-referential model, used to test the cycle guard."""
79 children: list[TreeNode] = []
82type AliasUnion = Child | Other
85class AliasHolder(pydantic.BaseModel):
86 """A model whose field is a PEP 695 type alias union."""
88 thing: AliasUnion
91class Scalars(pydantic.BaseModel):
92 """A model with only scalar fields, used to test type strings."""
94 mapping: dict[str, int]
95 sequence: list[str]
96 optional: int | None
97 plain: str
98 annotated: Annotated[int, "metadata-with-unstable-repr"]
99 annotated_optional: Annotated[float, object()] | None
102class Leaf(pydantic.BaseModel):
103 """The deepest model in the collapse-policy fixture."""
105 v: int
108class Middle(pydantic.BaseModel):
109 """The middle model in the collapse-policy fixture."""
111 leaf: Leaf
114class Top(pydantic.BaseModel):
115 """The root model in the collapse-policy fixture."""
117 middle: Middle
120class Dog(pydantic.BaseModel):
121 """A concrete union member for the instance-mode fixture."""
123 bark: str
126class Cat(pydantic.BaseModel):
127 """A second concrete union member for the instance-mode fixture."""
129 meow: str
132class Owner(pydantic.BaseModel):
133 """A model with union, list-of-union, and optional fields."""
135 pet: Dog | Cat
136 pets: list[Dog | Cat]
137 missing: Dog | None
140class Kennel(pydantic.BaseModel):
141 """A model with a dict of union models, for empty-container fallback."""
143 occupants: dict[str, Dog | Cat]
146class RepeatedWrapper(pydantic.BaseModel):
147 """A repeated model whose nested union can vary by instance."""
149 thing: Dog | Cat
152class WrapperCollection(pydantic.BaseModel):
153 """A model that contains multiple instances of the same wrapper type."""
155 wrappers: list[RepeatedWrapper]
158def _invoke(runner: CliRunner, *args: str):
159 """Return the Click result of invoking the diagram subcommand with args."""
160 return runner.invoke(main, ["diagram", *args])
163def test_scalar_and_nested_model() -> None:
164 """Test that a scalar field is an attribute and a nested model is an
165 edge.
166 """
167 graph = build_graph(Parent)
169 root = graph.nodes[graph.root]
170 assert root.label == "Parent"
172 attrs = {a.name: a.type_str for a in root.attributes}
173 assert "name" in attrs
174 assert "str" in attrs["name"]
175 assert "child" not in attrs
177 refs = {r.name: r for r in root.references}
178 assert "child" in refs
179 assert refs["child"].cardinality == "one"
180 (child_key,) = refs["child"].targets
181 assert graph.nodes[child_key].label == "Child"
184def test_container_and_union_cardinalities() -> None:
185 """Test that optional, list, dict, and union fields map to the correct
186 cardinalities.
187 """
188 graph = build_graph(Containers)
189 refs = {r.name: r for r in graph.nodes[graph.root].references}
191 assert not graph.nodes[graph.root].attributes
193 assert refs["maybe"].cardinality == "optional"
194 assert {graph.nodes[t].label for t in refs["maybe"].targets} == {"Child"}
196 assert refs["many"].cardinality == "list"
197 assert {graph.nodes[t].label for t in refs["many"].targets} == {"Child"}
199 assert refs["mapping"].cardinality == "dict"
200 assert {graph.nodes[t].label for t in refs["mapping"].targets} == {"Child"}
202 assert {graph.nodes[t].label for t in refs["either"].targets} == {"Child", "Other"}
203 assert not refs["either"].has_other
205 assert {graph.nodes[t].label for t in refs["open_union"].targets} == {"Child"}
206 assert refs["open_union"].has_other
209def test_optional_containers_are_model_references() -> None:
210 """Test that optional list and dict container fields produce edges, not
211 scalar attributes.
212 """
213 graph = build_graph(OptionalContainers)
214 root = graph.nodes[graph.root]
215 refs = {r.name: r for r in root.references}
216 attrs = {a.name for a in root.attributes}
218 assert "maybe_many" in refs
219 assert {graph.nodes[t].label for t in refs["maybe_many"].targets} == {"Child"}
220 assert "maybe_many" not in attrs
222 assert "maybe_mapping" in refs
223 assert {graph.nodes[t].label for t in refs["maybe_mapping"].targets} == {"Child"}
224 assert "maybe_mapping" not in attrs
227def test_scalar_type_strings_are_readable() -> None:
228 """Test that scalar field type strings are human-readable and Annotated
229 metadata is stripped.
230 """
231 graph = build_graph(Scalars)
232 attrs = {a.name: a.type_str for a in graph.nodes[graph.root].attributes}
233 assert attrs["mapping"] == "dict[str, int]"
234 assert attrs["sequence"] == "list[str]"
235 assert attrs["optional"] == "int | None"
236 assert attrs["plain"] == "str"
237 assert attrs["annotated"] == "int"
238 assert attrs["annotated_optional"] == "float | None"
241def test_type_alias_union_is_resolved() -> None:
242 """Test that a type alias union expands to its member models."""
243 graph = build_graph(AliasHolder)
244 (ref,) = graph.nodes[graph.root].references
245 assert ref.name == "thing"
246 assert {graph.nodes[t].label for t in ref.targets} == {"Child", "Other"}
249def test_self_referential_model_does_not_loop() -> None:
250 """Test that a self-referential model produces a single self-loop node, not
251 infinite recursion.
252 """
253 graph = build_graph(TreeNode)
254 assert len(graph.nodes) == 1
255 (ref,) = graph.nodes[graph.root].references
256 assert ref.targets == [graph.root]
257 assert ref.cardinality == "list"
260def test_visit_image_real_model() -> None:
261 """Test that build_graph handles the real VisitImageSerializationModel
262 correctly.
263 """
264 graph = build_graph(VisitImageSerializationModel[JsonRef])
265 root = graph.nodes[graph.root]
266 refs = {r.name: r for r in root.references}
268 for expected in ("image", "mask", "variance", "sky_projection", "psf"):
269 assert expected in refs
271 psf_labels = {graph.nodes[t].label for t in refs["psf"].targets}
272 assert len(psf_labels) >= 3
273 assert refs["psf"].has_other
275 assert "photometric_scaling" in refs
276 field_labels = {graph.nodes[t].label for t in refs["photometric_scaling"].targets}
277 assert "SumFieldSerializationModel" in field_labels
280def test_cell_coadd_real_model() -> None:
281 """Test that build_graph handles the real CellCoaddSerializationModel
282 correctly.
283 """
284 graph = build_graph(CellCoaddSerializationModel[JsonRef])
285 refs = {r.name: r for r in graph.nodes[graph.root].references}
287 assert refs["noise_realizations"].cardinality == "list"
288 assert refs["mask_fractions"].cardinality == "dict"
291def test_collapsed_type_is_a_leaf() -> None:
292 """Test that a type listed as a leaf in the policy is present but not
293 expanded.
294 """
295 graph = build_graph(Top, policy=Policy(leaves=frozenset({"Middle"})))
296 labels = {n.label for n in graph.nodes.values()}
297 assert labels == {"Top", "Middle"}
298 middle = next(n for n in graph.nodes.values() if n.label == "Middle")
299 assert middle.collapsed
300 assert not middle.references
301 assert not middle.attributes
304def test_make_policy_expand_and_collapse() -> None:
305 """Test that make_policy correctly populates expand and collapse
306 entries.
307 """
308 policy = make_policy(expand=["ArrayReferenceModel"], collapse=["Middle"])
309 assert "ArrayReferenceModel" not in policy.leaves
310 assert "Middle" in policy.leaves
311 assert "TableModel" in policy.leaves
314def test_make_policy_expand_leaves_clears_defaults() -> None:
315 """Test that make_policy with expand_leaves=True produces an empty leaves
316 set.
317 """
318 assert make_policy(expand_leaves=True).leaves == frozenset()
321def test_include_attributes_false_elides_scalars() -> None:
322 """Test that include_attributes=False drops scalar fields but keeps model
323 edges.
324 """
325 graph = build_graph(Parent, policy=make_policy(include_attributes=False))
326 root = graph.nodes[graph.root]
327 assert not root.attributes
328 assert [r.name for r in root.references] == ["child"]
331def test_all_scalar_model_becomes_leaf_without_attributes() -> None:
332 """Test that a model with only scalar fields has no attributes or
333 references when include_attributes=False.
334 """
335 graph = build_graph(Scalars, policy=make_policy(include_attributes=False))
336 root = graph.nodes[graph.root]
337 assert not root.attributes
338 assert not root.references
341def test_instance_attributes_can_be_elided() -> None:
342 """Test that include_attributes=False removes None attributes from
343 instance graphs.
344 """
345 owner = Owner(pet=Dog(bark="woof"), pets=[Dog(bark="a")], missing=None)
346 graph = build_instance_graph(owner, policy=make_policy(include_attributes=False))
347 assert not graph.nodes[graph.root].attributes
350def test_public_names_relabel_real_model() -> None:
351 """Test that public_names=True replaces serialization model names with
352 their PUBLIC_TYPE labels.
353 """
354 graph = build_graph(
355 VisitImageSerializationModel[JsonRef],
356 policy=make_policy(public_names=True),
357 )
358 labels = {n.label for n in graph.nodes.values()}
359 assert "VisitImage" in labels
360 assert "SkyProjection" in labels
361 assert "VisitImageSerializationModel[JsonRef]" not in labels
362 assert "ApertureCorrectionMapSerializationModel" in labels
365def test_public_names_collapse_matches_either_name() -> None:
366 """Test that a collapse rule matches a model regardless of whether public
367 or serialization name is used.
368 """
369 graph = build_graph(
370 VisitImageSerializationModel[JsonRef],
371 policy=make_policy(public_names=True, collapse=["Image"]),
372 )
373 image = next(n for n in graph.nodes.values() if n.label == "Image")
374 assert image.collapsed
377def test_hide_fields_drops_named_fields() -> None:
378 """Test that hide_fields removes the named edge and any type only
379 reachable through it.
380 """
381 graph = build_graph(Parent, policy=make_policy(hide_fields=["child"]))
382 assert not graph.nodes[graph.root].references
383 assert "Child" not in {n.label for n in graph.nodes.values()}
386def test_hide_type_removes_nodes_and_edges() -> None:
387 """Test that hide_types removes the named type node and any edge pointing
388 to it.
389 """
390 graph = build_graph(Parent, policy=make_policy(hide_types=["Child"]))
391 assert "Child" not in {n.label for n in graph.nodes.values()}
392 assert not graph.nodes[graph.root].references
395def test_hide_type_matches_public_name() -> None:
396 """Test that hide_types matches a node by its public name when
397 public_names=True.
398 """
399 model = VisitImageSerializationModel[JsonRef]
400 graph = build_graph(model, policy=make_policy(public_names=True, hide_types=["Image"]))
401 assert "Image" not in {n.label for n in graph.nodes.values()}
404def test_default_collapses_asdf_helpers_in_real_model() -> None:
405 """Test that DEFAULT_LEAF_TYPES are collapsed by default and expanding them
406 reveals more nodes.
407 """
408 assert "ArrayReferenceModel" in DEFAULT_LEAF_TYPES
409 model = VisitImageSerializationModel[JsonRef]
410 default_graph = build_graph(model)
411 array_node = next(n for n in default_graph.nodes.values() if n.label == "ArrayReferenceModel")
412 assert array_node.collapsed
413 expanded_graph = build_graph(model, policy=make_policy(expand_leaves=True))
414 expanded_array = next(n for n in expanded_graph.nodes.values() if n.label == "ArrayReferenceModel")
415 assert not expanded_array.collapsed
416 assert len(expanded_graph.nodes) > len(default_graph.nodes)
419def test_union_collapses_to_concrete_member() -> None:
420 """Test that instance mode shows only the concrete union member present in
421 each field.
422 """
423 owner = Owner(pet=Dog(bark="woof"), pets=[Dog(bark="a"), Cat(meow="m")], missing=None)
424 graph = build_instance_graph(owner)
425 refs = {r.name: r for r in graph.nodes[graph.root].references}
427 assert {graph.nodes[t].label for t in refs["pet"].targets} == {"Dog"}
428 assert refs["pet"].cardinality == "one"
430 assert {graph.nodes[t].label for t in refs["pets"].targets} == {"Dog", "Cat"}
431 assert refs["pets"].cardinality == "list"
433 assert "missing" not in refs
434 attrs = {a.name: a.type_str for a in graph.nodes[graph.root].attributes}
435 assert attrs["missing"] == "None"
438def test_populated_container_shows_only_present_types() -> None:
439 """Test that instance mode shows only the concrete types present in a
440 populated container.
441 """
442 graph = build_instance_graph(Kennel(occupants={"x": Dog(bark="woof")}))
443 ref = {r.name: r for r in graph.nodes[graph.root].references}["occupants"]
444 assert {graph.nodes[t].label for t in ref.targets} == {"Dog"}
447def test_repeated_instances_merge_nested_concrete_types() -> None:
448 """Test that instance mode merges concrete types across multiple instances
449 of the same wrapper.
450 """
451 root = WrapperCollection(
452 wrappers=[
453 RepeatedWrapper(thing=Dog(bark="woof")),
454 RepeatedWrapper(thing=Cat(meow="m")),
455 ]
456 )
457 graph = build_instance_graph(root)
458 wrapper = next(n for n in graph.nodes.values() if n.label == "RepeatedWrapper")
459 refs = {r.name: r for r in wrapper.references}
461 assert "thing" in refs
462 assert {graph.nodes[t].label for t in refs["thing"].targets} == {"Dog", "Cat"}
465def test_hide_type_instance_mode() -> None:
466 """Test that hide_types removes the named type from an instance graph."""
467 graph = build_instance_graph(
468 Kennel(occupants={"x": Dog(bark="woof")}),
469 policy=make_policy(hide_types=["Dog"], include_attributes=False),
470 )
471 assert not graph.nodes[graph.root].references
472 assert "Dog" not in {n.label for n in graph.nodes.values()}
475def test_empty_container_yields_no_edge() -> None:
476 """Test that instance mode does not expand an empty container into its
477 declared element types.
478 """
479 graph = build_instance_graph(Kennel(occupants={}), policy=make_policy(include_attributes=False))
480 assert not graph.nodes[graph.root].references
481 assert not graph.nodes[graph.root].attributes
484def test_graph_from_file_collapses_psf() -> None:
485 """Test that graph_from_file collapses a union PSF field to the concrete
486 type stored in the file.
487 """
488 path = os.path.join(TESTDIR, "data", "schema_v1", "visit_image.json")
489 graph = graph_from_file(path)
490 assert graph.nodes[graph.root].label.startswith("VisitImageSerializationModel")
491 refs = {r.name: r for r in graph.nodes[graph.root].references}
492 psf_labels = {graph.nodes[t].label for t in refs["psf"].targets}
493 assert psf_labels == {"GaussianPSFSerializationModel"}
494 assert not refs["psf"].has_other
497def test_dot_format() -> None:
498 """Test that the dot emitter produces digraph output containing expected
499 labels.
500 """
501 graph = build_graph(Parent)
502 dot = render(graph, "dot")
503 assert dot.startswith("digraph")
504 assert "Parent" in dot
505 assert "Child" in dot
506 assert "name" in dot
507 assert "->" in dot
508 assert "child" in dot
511def test_mermaid_format() -> None:
512 """Test that the mermaid emitter produces a classDiagram block with
513 expected labels and arrows.
514 """
515 graph = build_graph(Parent)
516 mermaid = render(graph, "mermaid")
517 assert mermaid.lstrip().startswith("classDiagram")
518 assert "Parent" in mermaid
519 assert "Child" in mermaid
520 assert "name" in mermaid
521 assert "-->" in mermaid
522 assert "child" in mermaid
525def test_tree_format() -> None:
526 """Test that the tree emitter produces a tree-style text representation
527 with branch glyphs.
528 """
529 graph = build_graph(Parent)
530 tree = render(graph, "tree")
531 lines = tree.splitlines()
532 assert lines[0] == "Parent"
533 assert any("name" in line for line in lines)
534 assert any("child" in line and "Child" in line for line in lines)
535 assert any(line.startswith(("├──", "└──")) for line in lines)
538def test_mermaid_real_model_is_bracket_safe() -> None:
539 """Test that the mermaid emitter escapes square brackets that would break
540 the parser.
541 """
542 mermaid = render(build_graph(VisitImageSerializationModel[JsonRef]), "mermaid")
543 stripped = mermaid.replace('["', "").replace('"]', "")
544 assert "[" not in stripped
545 assert "]" not in stripped
546 assert "VisitImageSerializationModel~JsonRef~" in mermaid
549def test_unknown_format_raises() -> None:
550 """Test that render raises ValueError for an unrecognised format string."""
551 graph = build_graph(Parent)
552 with pytest.raises(ValueError):
553 render(graph, "svg")
556def test_render_is_deterministic() -> None:
557 """Test that repeated render calls for the same graph and format produce
558 identical output.
559 """
560 for fmt in ("dot", "mermaid", "tree"):
561 assert render(build_graph(Parent), fmt) == render(build_graph(Parent), fmt)
564def test_diagram_registered_in_group() -> None:
565 """Test that the diagram subcommand is listed in the top-level help
566 output.
567 """
568 runner = CliRunner()
569 result = runner.invoke(main, ["--help"])
570 assert "diagram" in result.output
573def test_diagram_list() -> None:
574 """Test that --list outputs known schema names."""
575 runner = CliRunner()
576 result = _invoke(runner, "--list")
577 assert result.exit_code == 0, result.output
578 assert "visit_image" in result.output
579 assert "cell_coadd" in result.output
582def test_diagram_list_includes_entry_point_schemas() -> None:
583 """Test that --list shows third-party schemas registered via entry
584 points.
585 """
586 runner = CliRunner()
587 fake = importlib.metadata.EntryPoint(
588 name="third_party_schema", value="some.module:Model", group="lsst.images.schemas"
589 )
590 with mock.patch(
591 "lsst.images.cli._diagram.importlib.metadata.entry_points", return_value=[fake]
592 ) as entry_points:
593 result = _invoke(runner, "--list")
594 assert result.exit_code == 0, result.output
595 entry_points.assert_called_once_with(group="lsst.images.schemas")
596 assert "third_party_schema" in result.output
599def test_diagram_model_default_mermaid() -> None:
600 """Test that the diagram subcommand renders mermaid output with public
601 class names by default.
602 """
603 runner = CliRunner()
604 result = _invoke(runner, "visit-image")
605 assert result.exit_code == 0, result.output
606 assert "classDiagram" in result.output
607 assert "VisitImage" in result.output
608 assert "VisitImageSerializationModel" not in result.output
611def test_diagram_serialization_names_flag() -> None:
612 """Test that --serialization-names switches output to serialization model
613 names.
614 """
615 runner = CliRunner()
616 result = _invoke(runner, "visit-image", "--serialization-names")
617 assert result.exit_code == 0, result.output
618 assert "VisitImageSerializationModel" in result.output
621def test_diagram_model_tree_and_dot_formats() -> None:
622 """Test that --format tree and --format dot produce valid output for the
623 cell-coadd model.
624 """
625 runner = CliRunner()
626 tree = _invoke(runner, "cell-coadd", "--format", "tree")
627 assert tree.exit_code == 0, tree.output
628 assert "CellCoadd" in tree.output
629 assert any(line.startswith(("├──", "└──")) for line in tree.output.splitlines())
630 dot = _invoke(runner, "cell-coadd", "--format", "dot")
631 assert dot.exit_code == 0, dot.output
632 assert dot.output.lstrip().startswith("digraph")
635def test_diagram_unknown_model_errors() -> None:
636 """Test that an unrecognised model name causes a non-zero exit code."""
637 runner = CliRunner()
638 result = _invoke(runner, "not-a-model")
639 assert result.exit_code != 0
640 assert "Unknown model" in result.output
643def test_diagram_model_and_file_are_mutually_exclusive() -> None:
644 """Test that passing both a model name and --from-file produces a non-zero
645 exit code.
646 """
647 runner = CliRunner()
648 fixture = os.path.join(TESTDIR, "data", "schema_v1", "visit_image.json")
649 assert _invoke(runner, "visit-image", "--from-file", fixture).exit_code != 0
650 assert _invoke(runner).exit_code != 0
653def test_diagram_from_file_collapses_psf() -> None:
654 """Test that --from-file collapses the PSF to the concrete type stored in
655 the JSON file.
656 """
657 runner = CliRunner()
658 fixture = os.path.join(TESTDIR, "data", "schema_v1", "visit_image.json")
659 result = _invoke(runner, "--from-file", fixture, "--format", "tree")
660 assert result.exit_code == 0, result.output
661 assert "GaussianPointSpreadFunction" in result.output
664def test_diagram_output_to_file(tmp_path: Path) -> None:
665 """Test that -o writes the diagram to the specified file."""
666 runner = CliRunner()
667 out = str(tmp_path / "diagram.mmd")
668 result = _invoke(runner, "visit-image", "-o", out)
669 assert result.exit_code == 0, result.output
670 with open(out) as f:
671 assert "classDiagram" in f.read()
674def test_diagram_scalars_hidden_by_default() -> None:
675 """Test that scalar fields are hidden by default but all-scalar leaf models
676 still appear.
677 """
678 runner = CliRunner()
679 result = _invoke(runner, "visit-image", "--format", "tree")
680 assert result.exit_code == 0, result.output
681 assert "schema_version" not in result.output
682 assert "can_see_sky" not in result.output
683 assert "ObservationInfo" in result.output
684 assert "ObservationSummaryStats" in result.output
687def test_diagram_attributes_flag_shows_scalars() -> None:
688 """Test that --attributes causes scalar fields to appear in the tree
689 output.
690 """
691 runner = CliRunner()
692 result = _invoke(runner, "visit-image", "--format", "tree", "--attributes")
693 assert result.exit_code == 0, result.output
694 assert "schema_version" in result.output
697def test_diagram_hide_field_clips_edges() -> None:
698 """Test that --hide-field removes the named edge and any type only
699 reachable through it.
700 """
701 runner = CliRunner()
702 full = _invoke(runner, "cell-coadd", "--format", "tree").output
703 assert "ArrayReferenceQuantityModel" in full
704 assert "data (one of)" in full
706 clipped = _invoke(runner, "cell-coadd", "--format", "tree", "--hide-field", "data").output
707 assert "data (one of)" not in clipped
708 assert "ArrayReferenceQuantityModel" not in clipped
711def test_diagram_hide_type_removes_type() -> None:
712 """Test that --hide-type removes the named type from the tree output."""
713 runner = CliRunner()
714 full = _invoke(runner, "cell-coadd", "--format", "tree").output
715 assert "TableModel" in full
716 clipped = _invoke(runner, "cell-coadd", "--format", "tree", "--hide-type", "TableModel").output
717 assert "TableModel" not in clipped
720def test_diagram_expand_leaves_changes_output() -> None:
721 """Test that --expand-leaves produces more output lines than the default
722 collapsed view.
723 """
724 runner = CliRunner()
725 default = _invoke(runner, "visit-image", "--format", "tree", "--attributes").output
726 expanded = _invoke(runner, "visit-image", "--format", "tree", "--attributes", "--expand-leaves").output
727 assert len(expanded.splitlines()) > len(default.splitlines())