Coverage for tests/test_diagram.py: 100%
375 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-02 08:55 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-02 08:55 +0000
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
15import tempfile
16import unittest
17from typing import Annotated, Any
18from unittest import mock
20import pydantic
21from click.testing import CliRunner
23from lsst.images.cli import main
24from lsst.images.diagram import (
25 DEFAULT_LEAF_TYPES,
26 Policy,
27 build_graph,
28 build_instance_graph,
29 graph_from_file,
30 make_policy,
31 render,
32)
34TESTDIR = os.path.abspath(os.path.dirname(__file__))
37class Child(pydantic.BaseModel):
38 """A leaf model with a single scalar field."""
40 x: int
43class Other(pydantic.BaseModel):
44 """A second leaf model, used for union members."""
46 y: int
49class Parent(pydantic.BaseModel):
50 """A model with a scalar field and a nested model field."""
52 name: str
53 child: Child
56class Containers(pydantic.BaseModel):
57 """A model exercising optional, list, dict, and union fields."""
59 maybe: Child | None
60 many: list[Child]
61 mapping: dict[str, Child]
62 either: Child | Other
63 open_union: Child | Any
66class OptionalContainers(pydantic.BaseModel):
67 """A model with optional containers of nested models."""
69 maybe_many: list[Child] | None
70 maybe_mapping: dict[str, Child] | None
73class TreeNode(pydantic.BaseModel):
74 """A self-referential model, used to test the cycle guard."""
76 children: list[TreeNode] = []
79type AliasUnion = Child | Other
82class AliasHolder(pydantic.BaseModel):
83 """A model whose field is a PEP 695 type alias union."""
85 thing: AliasUnion
88class Scalars(pydantic.BaseModel):
89 """A model with only scalar fields, used to test type strings."""
91 mapping: dict[str, int]
92 sequence: list[str]
93 optional: int | None
94 plain: str
95 annotated: Annotated[int, "metadata-with-unstable-repr"]
96 annotated_optional: Annotated[float, object()] | None
99class Leaf(pydantic.BaseModel):
100 """The deepest model in the collapse-policy fixture."""
102 v: int
105class Middle(pydantic.BaseModel):
106 """The middle model in the collapse-policy fixture."""
108 leaf: Leaf
111class Top(pydantic.BaseModel):
112 """The root model in the collapse-policy fixture."""
114 middle: Middle
117class BuildGraphTestCase(unittest.TestCase):
118 """The introspection walk turns a model class into a node/edge graph."""
120 def test_scalar_and_nested_model(self) -> None:
121 graph = build_graph(Parent)
123 root = graph.nodes[graph.root]
124 self.assertEqual(root.label, "Parent")
126 # The scalar field is an attribute on the node, not an edge.
127 attrs = {a.name: a.type_str for a in root.attributes}
128 self.assertIn("name", attrs)
129 self.assertIn("str", attrs["name"])
130 self.assertNotIn("child", attrs)
132 # The model-valued field is a composition reference to the child node.
133 refs = {r.name: r for r in root.references}
134 self.assertIn("child", refs)
135 self.assertEqual(refs["child"].cardinality, "one")
136 (child_key,) = refs["child"].targets
137 self.assertEqual(graph.nodes[child_key].label, "Child")
139 def test_container_and_union_cardinalities(self) -> None:
140 graph = build_graph(Containers)
141 refs = {r.name: r for r in graph.nodes[graph.root].references}
143 # No container field is mistaken for a scalar attribute.
144 self.assertFalse(graph.nodes[graph.root].attributes)
146 self.assertEqual(refs["maybe"].cardinality, "optional")
147 self.assertEqual({graph.nodes[t].label for t in refs["maybe"].targets}, {"Child"})
149 self.assertEqual(refs["many"].cardinality, "list")
150 self.assertEqual({graph.nodes[t].label for t in refs["many"].targets}, {"Child"})
152 self.assertEqual(refs["mapping"].cardinality, "dict")
153 self.assertEqual({graph.nodes[t].label for t in refs["mapping"].targets}, {"Child"})
155 # A union of two models points at both, with no "other" marker.
156 self.assertEqual({graph.nodes[t].label for t in refs["either"].targets}, {"Child", "Other"})
157 self.assertFalse(refs["either"].has_other)
159 # A union mixing a model with Any keeps the edge and flags "other".
160 self.assertEqual({graph.nodes[t].label for t in refs["open_union"].targets}, {"Child"})
161 self.assertTrue(refs["open_union"].has_other)
163 def test_optional_containers_are_model_references(self) -> None:
164 graph = build_graph(OptionalContainers)
165 root = graph.nodes[graph.root]
166 refs = {r.name: r for r in root.references}
167 attrs = {a.name for a in root.attributes}
169 self.assertIn("maybe_many", refs)
170 self.assertEqual({graph.nodes[t].label for t in refs["maybe_many"].targets}, {"Child"})
171 self.assertNotIn("maybe_many", attrs)
173 self.assertIn("maybe_mapping", refs)
174 self.assertEqual({graph.nodes[t].label for t in refs["maybe_mapping"].targets}, {"Child"})
175 self.assertNotIn("maybe_mapping", attrs)
177 def test_scalar_type_strings_are_readable(self) -> None:
178 graph = build_graph(Scalars)
179 attrs = {a.name: a.type_str for a in graph.nodes[graph.root].attributes}
180 self.assertEqual(attrs["mapping"], "dict[str, int]")
181 self.assertEqual(attrs["sequence"], "list[str]")
182 self.assertEqual(attrs["optional"], "int | None")
183 self.assertEqual(attrs["plain"], "str")
184 # Annotated metadata (which can have an unstable repr) is stripped.
185 self.assertEqual(attrs["annotated"], "int")
186 self.assertEqual(attrs["annotated_optional"], "float | None")
188 def test_type_alias_union_is_resolved(self) -> None:
189 # A PEP 695 ``type X = A | B`` alias expands to its member models.
190 graph = build_graph(AliasHolder)
191 (ref,) = graph.nodes[graph.root].references
192 self.assertEqual(ref.name, "thing")
193 self.assertEqual({graph.nodes[t].label for t in ref.targets}, {"Child", "Other"})
195 def test_self_referential_model_does_not_loop(self) -> None:
196 graph = build_graph(TreeNode)
197 # A single node that references itself, rather than infinite recursion.
198 self.assertEqual(len(graph.nodes), 1)
199 (ref,) = graph.nodes[graph.root].references
200 self.assertEqual(ref.targets, [graph.root])
201 self.assertEqual(ref.cardinality, "list")
204class RealModelTestCase(unittest.TestCase):
205 """The walk handles the real serialization models."""
207 def test_visit_image(self) -> None:
208 from lsst.images import VisitImageSerializationModel
209 from lsst.images.serialization._asdf_utils import ArrayReferenceModel
211 graph = build_graph(VisitImageSerializationModel[ArrayReferenceModel])
212 root = graph.nodes[graph.root]
213 refs = {r.name: r for r in root.references}
215 for expected in ("image", "mask", "variance", "sky_projection", "psf"):
216 self.assertIn(expected, refs)
218 # The PSF field is the Piff | PSFEx | Gaussian | Any union.
219 psf_labels = {graph.nodes[t].label for t in refs["psf"].targets}
220 self.assertGreaterEqual(len(psf_labels), 3)
221 self.assertTrue(refs["psf"].has_other)
223 # photometric_scaling is a TypeAliasType over a discriminated union of
224 # field models; it must expand to those members, not show as a scalar.
225 self.assertIn("photometric_scaling", refs)
226 field_labels = {graph.nodes[t].label for t in refs["photometric_scaling"].targets}
227 self.assertIn("SumFieldSerializationModel", field_labels)
229 def test_cell_coadd(self) -> None:
230 from lsst.images.cells import CellCoaddSerializationModel
231 from lsst.images.serialization._asdf_utils import ArrayReferenceModel
233 graph = build_graph(CellCoaddSerializationModel[ArrayReferenceModel])
234 refs = {r.name: r for r in graph.nodes[graph.root].references}
236 self.assertEqual(refs["noise_realizations"].cardinality, "list")
237 self.assertEqual(refs["mask_fractions"].cardinality, "dict")
240class PolicyTestCase(unittest.TestCase):
241 """The collapse policy controls how far the walk recurses."""
243 def test_collapsed_type_is_a_leaf(self) -> None:
244 graph = build_graph(Top, policy=Policy(leaves=frozenset({"Middle"})))
245 # Middle is present (so its edge has a target) but not expanded.
246 labels = {n.label for n in graph.nodes.values()}
247 self.assertEqual(labels, {"Top", "Middle"})
248 middle = next(n for n in graph.nodes.values() if n.label == "Middle")
249 self.assertTrue(middle.collapsed)
250 self.assertFalse(middle.references)
251 self.assertFalse(middle.attributes)
253 def test_make_policy_expand_and_collapse(self) -> None:
254 policy = make_policy(expand=["ArrayReferenceModel"], collapse=["Middle"])
255 self.assertNotIn("ArrayReferenceModel", policy.leaves)
256 self.assertIn("Middle", policy.leaves)
257 # Other defaults remain collapsed.
258 self.assertIn("TableModel", policy.leaves)
260 def test_make_policy_expand_leaves_clears_defaults(self) -> None:
261 self.assertEqual(make_policy(expand_leaves=True).leaves, frozenset())
263 def test_include_attributes_false_elides_scalars(self) -> None:
264 # The scalar field is dropped, but the model edge is kept.
265 graph = build_graph(Parent, policy=make_policy(include_attributes=False))
266 root = graph.nodes[graph.root]
267 self.assertFalse(root.attributes)
268 self.assertEqual([r.name for r in root.references], ["child"])
270 def test_all_scalar_model_becomes_leaf_without_attributes(self) -> None:
271 graph = build_graph(Scalars, policy=make_policy(include_attributes=False))
272 root = graph.nodes[graph.root]
273 self.assertFalse(root.attributes)
274 self.assertFalse(root.references)
276 def test_instance_attributes_can_be_elided(self) -> None:
277 owner = Owner(pet=Dog(bark="woof"), pets=[Dog(bark="a")], missing=None)
278 graph = build_instance_graph(owner, policy=make_policy(include_attributes=False))
279 # The absent optional "missing" is not shown as a None attribute.
280 self.assertFalse(graph.nodes[graph.root].attributes)
282 def test_public_names_relabel_real_model(self) -> None:
283 from lsst.images import VisitImageSerializationModel
284 from lsst.images.serialization._asdf_utils import ArrayReferenceModel
286 graph = build_graph(
287 VisitImageSerializationModel[ArrayReferenceModel], policy=make_policy(public_names=True)
288 )
289 labels = {n.label for n in graph.nodes.values()}
290 self.assertIn("VisitImage", labels)
291 self.assertIn("SkyProjection", labels)
292 self.assertNotIn("VisitImageSerializationModel[ArrayReferenceModel]", labels)
293 # PUBLIC_TYPE is the builtin dict here, so the model name is kept.
294 self.assertIn("ApertureCorrectionMapSerializationModel", labels)
296 def test_public_names_collapse_matches_either_name(self) -> None:
297 from lsst.images import VisitImageSerializationModel
298 from lsst.images.serialization._asdf_utils import ArrayReferenceModel
300 graph = build_graph(
301 VisitImageSerializationModel[ArrayReferenceModel],
302 policy=make_policy(public_names=True, collapse=["Image"]),
303 )
304 image = next(n for n in graph.nodes.values() if n.label == "Image")
305 self.assertTrue(image.collapsed)
307 def test_hide_fields_drops_named_fields(self) -> None:
308 graph = build_graph(Parent, policy=make_policy(hide_fields=["child"]))
309 # The named field is gone, and the sub-tree reached only via it too.
310 self.assertFalse(graph.nodes[graph.root].references)
311 self.assertNotIn("Child", {n.label for n in graph.nodes.values()})
313 def test_hide_type_removes_nodes_and_edges(self) -> None:
314 graph = build_graph(Parent, policy=make_policy(hide_types=["Child"]))
315 self.assertNotIn("Child", {n.label for n in graph.nodes.values()})
316 self.assertFalse(graph.nodes[graph.root].references)
318 def test_hide_type_matches_public_name(self) -> None:
319 from lsst.images import VisitImageSerializationModel
320 from lsst.images.serialization._asdf_utils import ArrayReferenceModel
322 model = VisitImageSerializationModel[ArrayReferenceModel]
323 # "Image" is the public name of ImageSerializationModel.
324 graph = build_graph(model, policy=make_policy(public_names=True, hide_types=["Image"]))
325 self.assertNotIn("Image", {n.label for n in graph.nodes.values()})
327 def test_default_collapses_asdf_helpers_in_real_model(self) -> None:
328 from lsst.images import VisitImageSerializationModel
329 from lsst.images.serialization._asdf_utils import ArrayReferenceModel
331 self.assertIn("ArrayReferenceModel", DEFAULT_LEAF_TYPES)
332 model = VisitImageSerializationModel[ArrayReferenceModel]
334 default_graph = build_graph(model)
335 array_node = next(n for n in default_graph.nodes.values() if n.label == "ArrayReferenceModel")
336 self.assertTrue(array_node.collapsed)
338 expanded_graph = build_graph(model, policy=make_policy(expand_leaves=True))
339 expanded_array = next(n for n in expanded_graph.nodes.values() if n.label == "ArrayReferenceModel")
340 self.assertFalse(expanded_array.collapsed)
341 # Expanding the leaves reveals strictly more of the model.
342 self.assertGreater(len(expanded_graph.nodes), len(default_graph.nodes))
345class Dog(pydantic.BaseModel):
346 """A concrete union member for the instance-mode fixture."""
348 bark: str
351class Cat(pydantic.BaseModel):
352 """A second concrete union member for the instance-mode fixture."""
354 meow: str
357class Owner(pydantic.BaseModel):
358 """A model with union, list-of-union, and optional fields."""
360 pet: Dog | Cat
361 pets: list[Dog | Cat]
362 missing: Dog | None
365class Kennel(pydantic.BaseModel):
366 """A model with a dict of union models, for empty-container fallback."""
368 occupants: dict[str, Dog | Cat]
371class RepeatedWrapper(pydantic.BaseModel):
372 """A repeated model whose nested union can vary by instance."""
374 thing: Dog | Cat
377class WrapperCollection(pydantic.BaseModel):
378 """A model that contains multiple instances of the same wrapper type."""
380 wrappers: list[RepeatedWrapper]
383class InstanceGraphTestCase(unittest.TestCase):
384 """Instance mode collapses unions to the concrete values present."""
386 def test_union_collapses_to_concrete_member(self) -> None:
387 owner = Owner(pet=Dog(bark="woof"), pets=[Dog(bark="a"), Cat(meow="m")], missing=None)
388 graph = build_instance_graph(owner)
389 refs = {r.name: r for r in graph.nodes[graph.root].references}
391 # A single-valued union shows only the concrete type present.
392 self.assertEqual({graph.nodes[t].label for t in refs["pet"].targets}, {"Dog"})
393 self.assertEqual(refs["pet"].cardinality, "one")
395 # A list shows every distinct concrete element type present.
396 self.assertEqual({graph.nodes[t].label for t in refs["pets"].targets}, {"Dog", "Cat"})
397 self.assertEqual(refs["pets"].cardinality, "list")
399 # An absent optional is just a None attribute, not an edge.
400 self.assertNotIn("missing", refs)
401 attrs = {a.name: a.type_str for a in graph.nodes[graph.root].attributes}
402 self.assertEqual(attrs["missing"], "None")
404 def test_populated_container_shows_only_present_types(self) -> None:
405 graph = build_instance_graph(Kennel(occupants={"x": Dog(bark="woof")}))
406 ref = {r.name: r for r in graph.nodes[graph.root].references}["occupants"]
407 self.assertEqual({graph.nodes[t].label for t in ref.targets}, {"Dog"})
409 def test_repeated_instances_merge_nested_concrete_types(self) -> None:
410 root = WrapperCollection(
411 wrappers=[
412 RepeatedWrapper(thing=Dog(bark="woof")),
413 RepeatedWrapper(thing=Cat(meow="m")),
414 ]
415 )
416 graph = build_instance_graph(root)
417 wrapper = next(n for n in graph.nodes.values() if n.label == "RepeatedWrapper")
418 refs = {r.name: r for r in wrapper.references}
420 self.assertIn("thing", refs)
421 self.assertEqual({graph.nodes[t].label for t in refs["thing"].targets}, {"Dog", "Cat"})
423 def test_hide_type_instance_mode(self) -> None:
424 graph = build_instance_graph(
425 Kennel(occupants={"x": Dog(bark="woof")}),
426 policy=make_policy(hide_types=["Dog"], include_attributes=False),
427 )
428 self.assertFalse(graph.nodes[graph.root].references)
429 self.assertNotIn("Dog", {n.label for n in graph.nodes.values()})
431 def test_empty_container_yields_no_edge(self) -> None:
432 # Instance mode reports only what the file holds; an empty container is
433 # not expanded into its declared element types.
434 graph = build_instance_graph(Kennel(occupants={}), policy=make_policy(include_attributes=False))
435 self.assertFalse(graph.nodes[graph.root].references)
436 self.assertFalse(graph.nodes[graph.root].attributes)
438 def test_graph_from_file_collapses_psf(self) -> None:
439 path = os.path.join(TESTDIR, "data", "schema_v1", "visit_image.json")
440 graph = graph_from_file(path)
441 self.assertTrue(graph.nodes[graph.root].label.startswith("VisitImageSerializationModel"))
442 refs = {r.name: r for r in graph.nodes[graph.root].references}
443 # The fixture's PSF is a single concrete type, not the abstract union.
444 psf_labels = {graph.nodes[t].label for t in refs["psf"].targets}
445 self.assertEqual(psf_labels, {"GaussianPSFSerializationModel"})
446 self.assertFalse(refs["psf"].has_other)
449class EmitterTestCase(unittest.TestCase):
450 """The three output formats render the graph faithfully."""
452 def setUp(self) -> None:
453 self.graph = build_graph(Parent)
455 def test_dot(self) -> None:
456 dot = render(self.graph, "dot")
457 self.assertTrue(dot.startswith("digraph"))
458 self.assertIn("Parent", dot)
459 self.assertIn("Child", dot)
460 self.assertIn("name", dot) # scalar attribute
461 self.assertIn("->", dot) # composition edge
462 self.assertIn("child", dot) # edge label (field name)
464 def test_mermaid(self) -> None:
465 mermaid = render(self.graph, "mermaid")
466 self.assertTrue(mermaid.lstrip().startswith("classDiagram"))
467 self.assertIn("Parent", mermaid)
468 self.assertIn("Child", mermaid)
469 self.assertIn("name", mermaid)
470 self.assertIn("-->", mermaid)
471 self.assertIn("child", mermaid)
473 def test_tree(self) -> None:
474 tree = render(self.graph, "tree")
475 lines = tree.splitlines()
476 self.assertEqual(lines[0], "Parent")
477 # The scalar attribute and the nested model both appear as children.
478 self.assertTrue(any("name" in line for line in lines))
479 self.assertTrue(any("child" in line and "Child" in line for line in lines))
480 # tree(1)-style branch glyphs.
481 self.assertTrue(any(line.startswith(("├──", "└──")) for line in lines))
483 def test_mermaid_real_model_is_bracket_safe(self) -> None:
484 # Square brackets (from generic parameters / typing) break mermaid's
485 # class-diagram parser, so they must not survive into the output.
486 from lsst.images import VisitImageSerializationModel
487 from lsst.images.serialization._asdf_utils import ArrayReferenceModel
489 mermaid = render(build_graph(VisitImageSerializationModel[ArrayReferenceModel]), "mermaid")
490 # The only legitimate brackets are the ``class X["label"]`` declaration
491 # tokens; none may leak into label text, member lines, or edge labels.
492 stripped = mermaid.replace('["', "").replace('"]', "")
493 self.assertNotIn("[", stripped)
494 self.assertNotIn("]", stripped)
495 # The generic parameter is still shown, in mermaid's ~ generic style.
496 self.assertIn("VisitImageSerializationModel~ArrayReferenceModel~", mermaid)
498 def test_unknown_format_raises(self) -> None:
499 with self.assertRaises(ValueError):
500 render(self.graph, "svg")
502 def test_deterministic(self) -> None:
503 for fmt in ("dot", "mermaid", "tree"):
504 self.assertEqual(render(build_graph(Parent), fmt), render(build_graph(Parent), fmt))
507class DiagramCliTestCase(unittest.TestCase):
508 """The ``lsst-images-admin diagram`` subcommand."""
510 def setUp(self) -> None:
511 self.runner = CliRunner()
512 self.fixture = os.path.join(TESTDIR, "data", "schema_v1", "visit_image.json")
514 def invoke(self, *args: str):
515 return self.runner.invoke(main, ["diagram", *args])
517 def test_registered_in_group(self) -> None:
518 result = self.runner.invoke(main, ["--help"])
519 self.assertIn("diagram", result.output)
521 def test_list(self) -> None:
522 result = self.invoke("--list")
523 self.assertEqual(result.exit_code, 0, result.output)
524 self.assertIn("visit_image", result.output)
525 self.assertIn("cell_coadd", result.output)
527 def test_list_includes_entry_point_schemas(self) -> None:
528 # A third-party schema known only through an entry point is listed by
529 # name without its provider module being imported.
530 fake = importlib.metadata.EntryPoint(
531 name="third_party_schema", value="some.module:Model", group="lsst.images.schemas"
532 )
533 with mock.patch(
534 "lsst.images.cli._diagram.importlib.metadata.entry_points", return_value=[fake]
535 ) as entry_points:
536 result = self.invoke("--list")
537 self.assertEqual(result.exit_code, 0, result.output)
538 entry_points.assert_called_once_with(group="lsst.images.schemas")
539 self.assertIn("third_party_schema", result.output)
541 def test_model_default_mermaid(self) -> None:
542 result = self.invoke("visit-image")
543 self.assertEqual(result.exit_code, 0, result.output)
544 self.assertIn("classDiagram", result.output)
545 # Public class names are used by default.
546 self.assertIn("VisitImage", result.output)
547 self.assertNotIn("VisitImageSerializationModel", result.output)
549 def test_serialization_names_flag(self) -> None:
550 result = self.invoke("visit-image", "--serialization-names")
551 self.assertEqual(result.exit_code, 0, result.output)
552 self.assertIn("VisitImageSerializationModel", result.output)
554 def test_model_tree_and_dot_formats(self) -> None:
555 tree = self.invoke("cell-coadd", "--format", "tree")
556 self.assertEqual(tree.exit_code, 0, tree.output)
557 self.assertIn("CellCoadd", tree.output)
558 self.assertTrue(any(line.startswith(("├──", "└──")) for line in tree.output.splitlines()))
560 dot = self.invoke("cell-coadd", "--format", "dot")
561 self.assertEqual(dot.exit_code, 0, dot.output)
562 self.assertTrue(dot.output.lstrip().startswith("digraph"))
564 def test_unknown_model_errors(self) -> None:
565 result = self.invoke("not-a-model")
566 self.assertNotEqual(result.exit_code, 0)
567 self.assertIn("Unknown model", result.output)
569 def test_model_and_file_are_mutually_exclusive(self) -> None:
570 self.assertNotEqual(self.invoke("visit-image", "--from-file", self.fixture).exit_code, 0)
571 self.assertNotEqual(self.invoke().exit_code, 0)
573 def test_from_file_collapses_psf(self) -> None:
574 result = self.invoke("--from-file", self.fixture, "--format", "tree")
575 self.assertEqual(result.exit_code, 0, result.output)
576 # Public name of the concrete PSF type stored in the file.
577 self.assertIn("GaussianPointSpreadFunction", result.output)
579 def test_output_to_file(self) -> None:
580 with tempfile.TemporaryDirectory() as tmp:
581 out = os.path.join(tmp, "diagram.mmd")
582 result = self.invoke("visit-image", "-o", out)
583 self.assertEqual(result.exit_code, 0, result.output)
584 with open(out) as f:
585 self.assertIn("classDiagram", f.read())
587 def test_scalars_hidden_by_default(self) -> None:
588 result = self.invoke("visit-image", "--format", "tree")
589 self.assertEqual(result.exit_code, 0, result.output)
590 # Scalar fields (incl. schema bookkeeping) are gone by default...
591 self.assertNotIn("schema_version", result.output)
592 self.assertNotIn("can_see_sky", result.output)
593 # ...but all-scalar models still appear as leaf nodes.
594 self.assertIn("ObservationInfo", result.output)
595 self.assertIn("ObservationSummaryStats", result.output)
597 def test_attributes_flag_shows_scalars(self) -> None:
598 result = self.invoke("visit-image", "--format", "tree", "--attributes")
599 self.assertEqual(result.exit_code, 0, result.output)
600 self.assertIn("schema_version", result.output)
602 def test_hide_field_clips_edges(self) -> None:
603 full = self.invoke("cell-coadd", "--format", "tree").output
604 self.assertIn("ArrayReferenceQuantityModel", full)
605 self.assertIn("data (one of)", full)
607 clipped = self.invoke("cell-coadd", "--format", "tree", "--hide-field", "data").output
608 # The named edges, and the type only reachable through them, are gone.
609 self.assertNotIn("data (one of)", clipped)
610 self.assertNotIn("ArrayReferenceQuantityModel", clipped)
612 def test_hide_type_removes_type(self) -> None:
613 full = self.invoke("cell-coadd", "--format", "tree").output
614 self.assertIn("TableModel", full)
615 clipped = self.invoke("cell-coadd", "--format", "tree", "--hide-type", "TableModel").output
616 self.assertNotIn("TableModel", clipped)
618 def test_expand_leaves_changes_output(self) -> None:
619 default = self.invoke("visit-image", "--format", "tree", "--attributes").output
620 expanded = self.invoke("visit-image", "--format", "tree", "--attributes", "--expand-leaves").output
621 self.assertGreater(len(expanded.splitlines()), len(default.splitlines()))
624if __name__ == "__main__":
625 unittest.main()