Coverage for tests/test_diagram.py: 100%

375 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-29 22:39 +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 

12 

13import importlib.metadata 

14import os.path 

15import tempfile 

16import unittest 

17from typing import Annotated, Any 

18from unittest import mock 

19 

20import pydantic 

21from click.testing import CliRunner 

22 

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) 

33 

34TESTDIR = os.path.abspath(os.path.dirname(__file__)) 

35 

36 

37class Child(pydantic.BaseModel): 

38 """A leaf model with a single scalar field.""" 

39 

40 x: int 

41 

42 

43class Other(pydantic.BaseModel): 

44 """A second leaf model, used for union members.""" 

45 

46 y: int 

47 

48 

49class Parent(pydantic.BaseModel): 

50 """A model with a scalar field and a nested model field.""" 

51 

52 name: str 

53 child: Child 

54 

55 

56class Containers(pydantic.BaseModel): 

57 """A model exercising optional, list, dict, and union fields.""" 

58 

59 maybe: Child | None 

60 many: list[Child] 

61 mapping: dict[str, Child] 

62 either: Child | Other 

63 open_union: Child | Any 

64 

65 

66class OptionalContainers(pydantic.BaseModel): 

67 """A model with optional containers of nested models.""" 

68 

69 maybe_many: list[Child] | None 

70 maybe_mapping: dict[str, Child] | None 

71 

72 

73class TreeNode(pydantic.BaseModel): 

74 """A self-referential model, used to test the cycle guard.""" 

75 

76 children: list[TreeNode] = [] 

77 

78 

79type AliasUnion = Child | Other 

80 

81 

82class AliasHolder(pydantic.BaseModel): 

83 """A model whose field is a PEP 695 type alias union.""" 

84 

85 thing: AliasUnion 

86 

87 

88class Scalars(pydantic.BaseModel): 

89 """A model with only scalar fields, used to test type strings.""" 

90 

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 

97 

98 

99class Leaf(pydantic.BaseModel): 

100 """The deepest model in the collapse-policy fixture.""" 

101 

102 v: int 

103 

104 

105class Middle(pydantic.BaseModel): 

106 """The middle model in the collapse-policy fixture.""" 

107 

108 leaf: Leaf 

109 

110 

111class Top(pydantic.BaseModel): 

112 """The root model in the collapse-policy fixture.""" 

113 

114 middle: Middle 

115 

116 

117class BuildGraphTestCase(unittest.TestCase): 

118 """The introspection walk turns a model class into a node/edge graph.""" 

119 

120 def test_scalar_and_nested_model(self) -> None: 

121 graph = build_graph(Parent) 

122 

123 root = graph.nodes[graph.root] 

124 self.assertEqual(root.label, "Parent") 

125 

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) 

131 

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") 

138 

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} 

142 

143 # No container field is mistaken for a scalar attribute. 

144 self.assertFalse(graph.nodes[graph.root].attributes) 

145 

146 self.assertEqual(refs["maybe"].cardinality, "optional") 

147 self.assertEqual({graph.nodes[t].label for t in refs["maybe"].targets}, {"Child"}) 

148 

149 self.assertEqual(refs["many"].cardinality, "list") 

150 self.assertEqual({graph.nodes[t].label for t in refs["many"].targets}, {"Child"}) 

151 

152 self.assertEqual(refs["mapping"].cardinality, "dict") 

153 self.assertEqual({graph.nodes[t].label for t in refs["mapping"].targets}, {"Child"}) 

154 

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) 

158 

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) 

162 

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} 

168 

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) 

172 

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) 

176 

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") 

187 

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"}) 

194 

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") 

202 

203 

204class RealModelTestCase(unittest.TestCase): 

205 """The walk handles the real serialization models.""" 

206 

207 def test_visit_image(self) -> None: 

208 from lsst.images import VisitImageSerializationModel 

209 from lsst.images.serialization._asdf_utils import ArrayReferenceModel 

210 

211 graph = build_graph(VisitImageSerializationModel[ArrayReferenceModel]) 

212 root = graph.nodes[graph.root] 

213 refs = {r.name: r for r in root.references} 

214 

215 for expected in ("image", "mask", "variance", "sky_projection", "psf"): 

216 self.assertIn(expected, refs) 

217 

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) 

222 

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) 

228 

229 def test_cell_coadd(self) -> None: 

230 from lsst.images.cells import CellCoaddSerializationModel 

231 from lsst.images.serialization._asdf_utils import ArrayReferenceModel 

232 

233 graph = build_graph(CellCoaddSerializationModel[ArrayReferenceModel]) 

234 refs = {r.name: r for r in graph.nodes[graph.root].references} 

235 

236 self.assertEqual(refs["noise_realizations"].cardinality, "list") 

237 self.assertEqual(refs["mask_fractions"].cardinality, "dict") 

238 

239 

240class PolicyTestCase(unittest.TestCase): 

241 """The collapse policy controls how far the walk recurses.""" 

242 

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) 

252 

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) 

259 

260 def test_make_policy_expand_leaves_clears_defaults(self) -> None: 

261 self.assertEqual(make_policy(expand_leaves=True).leaves, frozenset()) 

262 

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"]) 

269 

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) 

275 

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) 

281 

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 

285 

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) 

295 

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 

299 

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) 

306 

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()}) 

312 

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) 

317 

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 

321 

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()}) 

326 

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 

330 

331 self.assertIn("ArrayReferenceModel", DEFAULT_LEAF_TYPES) 

332 model = VisitImageSerializationModel[ArrayReferenceModel] 

333 

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) 

337 

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)) 

343 

344 

345class Dog(pydantic.BaseModel): 

346 """A concrete union member for the instance-mode fixture.""" 

347 

348 bark: str 

349 

350 

351class Cat(pydantic.BaseModel): 

352 """A second concrete union member for the instance-mode fixture.""" 

353 

354 meow: str 

355 

356 

357class Owner(pydantic.BaseModel): 

358 """A model with union, list-of-union, and optional fields.""" 

359 

360 pet: Dog | Cat 

361 pets: list[Dog | Cat] 

362 missing: Dog | None 

363 

364 

365class Kennel(pydantic.BaseModel): 

366 """A model with a dict of union models, for empty-container fallback.""" 

367 

368 occupants: dict[str, Dog | Cat] 

369 

370 

371class RepeatedWrapper(pydantic.BaseModel): 

372 """A repeated model whose nested union can vary by instance.""" 

373 

374 thing: Dog | Cat 

375 

376 

377class WrapperCollection(pydantic.BaseModel): 

378 """A model that contains multiple instances of the same wrapper type.""" 

379 

380 wrappers: list[RepeatedWrapper] 

381 

382 

383class InstanceGraphTestCase(unittest.TestCase): 

384 """Instance mode collapses unions to the concrete values present.""" 

385 

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} 

390 

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") 

394 

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") 

398 

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") 

403 

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"}) 

408 

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} 

419 

420 self.assertIn("thing", refs) 

421 self.assertEqual({graph.nodes[t].label for t in refs["thing"].targets}, {"Dog", "Cat"}) 

422 

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()}) 

430 

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) 

437 

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) 

447 

448 

449class EmitterTestCase(unittest.TestCase): 

450 """The three output formats render the graph faithfully.""" 

451 

452 def setUp(self) -> None: 

453 self.graph = build_graph(Parent) 

454 

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) 

463 

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) 

472 

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)) 

482 

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 

488 

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) 

497 

498 def test_unknown_format_raises(self) -> None: 

499 with self.assertRaises(ValueError): 

500 render(self.graph, "svg") 

501 

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)) 

505 

506 

507class DiagramCliTestCase(unittest.TestCase): 

508 """The ``lsst-images-admin diagram`` subcommand.""" 

509 

510 def setUp(self) -> None: 

511 self.runner = CliRunner() 

512 self.fixture = os.path.join(TESTDIR, "data", "schema_v1", "visit_image.json") 

513 

514 def invoke(self, *args: str): 

515 return self.runner.invoke(main, ["diagram", *args]) 

516 

517 def test_registered_in_group(self) -> None: 

518 result = self.runner.invoke(main, ["--help"]) 

519 self.assertIn("diagram", result.output) 

520 

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) 

526 

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) 

540 

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) 

548 

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) 

553 

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())) 

559 

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")) 

563 

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) 

568 

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) 

572 

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) 

578 

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()) 

586 

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) 

596 

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) 

601 

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) 

606 

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) 

611 

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) 

617 

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())) 

622 

623 

624if __name__ == "__main__": 

625 unittest.main()