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

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 

15from pathlib import Path 

16from typing import Annotated, Any 

17from unittest import mock 

18 

19import pydantic 

20import pytest 

21from click.testing import CliRunner 

22 

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 

36 

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

38 

39 

40class Child(pydantic.BaseModel): 

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

42 

43 x: int 

44 

45 

46class Other(pydantic.BaseModel): 

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

48 

49 y: int 

50 

51 

52class Parent(pydantic.BaseModel): 

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

54 

55 name: str 

56 child: Child 

57 

58 

59class Containers(pydantic.BaseModel): 

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

61 

62 maybe: Child | None 

63 many: list[Child] 

64 mapping: dict[str, Child] 

65 either: Child | Other 

66 open_union: Child | Any 

67 

68 

69class OptionalContainers(pydantic.BaseModel): 

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

71 

72 maybe_many: list[Child] | None 

73 maybe_mapping: dict[str, Child] | None 

74 

75 

76class TreeNode(pydantic.BaseModel): 

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

78 

79 children: list[TreeNode] = [] 

80 

81 

82type AliasUnion = Child | Other 

83 

84 

85class AliasHolder(pydantic.BaseModel): 

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

87 

88 thing: AliasUnion 

89 

90 

91class Scalars(pydantic.BaseModel): 

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

93 

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 

100 

101 

102class Leaf(pydantic.BaseModel): 

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

104 

105 v: int 

106 

107 

108class Middle(pydantic.BaseModel): 

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

110 

111 leaf: Leaf 

112 

113 

114class Top(pydantic.BaseModel): 

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

116 

117 middle: Middle 

118 

119 

120class Dog(pydantic.BaseModel): 

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

122 

123 bark: str 

124 

125 

126class Cat(pydantic.BaseModel): 

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

128 

129 meow: str 

130 

131 

132class Owner(pydantic.BaseModel): 

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

134 

135 pet: Dog | Cat 

136 pets: list[Dog | Cat] 

137 missing: Dog | None 

138 

139 

140class Kennel(pydantic.BaseModel): 

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

142 

143 occupants: dict[str, Dog | Cat] 

144 

145 

146class RepeatedWrapper(pydantic.BaseModel): 

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

148 

149 thing: Dog | Cat 

150 

151 

152class WrapperCollection(pydantic.BaseModel): 

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

154 

155 wrappers: list[RepeatedWrapper] 

156 

157 

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

161 

162 

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) 

168 

169 root = graph.nodes[graph.root] 

170 assert root.label == "Parent" 

171 

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 

176 

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" 

182 

183 

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} 

190 

191 assert not graph.nodes[graph.root].attributes 

192 

193 assert refs["maybe"].cardinality == "optional" 

194 assert {graph.nodes[t].label for t in refs["maybe"].targets} == {"Child"} 

195 

196 assert refs["many"].cardinality == "list" 

197 assert {graph.nodes[t].label for t in refs["many"].targets} == {"Child"} 

198 

199 assert refs["mapping"].cardinality == "dict" 

200 assert {graph.nodes[t].label for t in refs["mapping"].targets} == {"Child"} 

201 

202 assert {graph.nodes[t].label for t in refs["either"].targets} == {"Child", "Other"} 

203 assert not refs["either"].has_other 

204 

205 assert {graph.nodes[t].label for t in refs["open_union"].targets} == {"Child"} 

206 assert refs["open_union"].has_other 

207 

208 

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} 

217 

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 

221 

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 

225 

226 

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" 

239 

240 

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

247 

248 

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" 

258 

259 

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} 

267 

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

269 assert expected in refs 

270 

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 

274 

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 

278 

279 

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} 

286 

287 assert refs["noise_realizations"].cardinality == "list" 

288 assert refs["mask_fractions"].cardinality == "dict" 

289 

290 

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 

302 

303 

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 

312 

313 

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

319 

320 

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

329 

330 

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 

339 

340 

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 

348 

349 

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 

363 

364 

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 

375 

376 

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

384 

385 

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 

393 

394 

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

402 

403 

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) 

417 

418 

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} 

426 

427 assert {graph.nodes[t].label for t in refs["pet"].targets} == {"Dog"} 

428 assert refs["pet"].cardinality == "one" 

429 

430 assert {graph.nodes[t].label for t in refs["pets"].targets} == {"Dog", "Cat"} 

431 assert refs["pets"].cardinality == "list" 

432 

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" 

436 

437 

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

445 

446 

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} 

460 

461 assert "thing" in refs 

462 assert {graph.nodes[t].label for t in refs["thing"].targets} == {"Dog", "Cat"} 

463 

464 

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

473 

474 

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 

482 

483 

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 

495 

496 

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 

509 

510 

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 

523 

524 

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) 

536 

537 

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 

547 

548 

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

554 

555 

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) 

562 

563 

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 

571 

572 

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 

580 

581 

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 

597 

598 

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 

609 

610 

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 

619 

620 

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

633 

634 

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 

641 

642 

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 

651 

652 

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 

662 

663 

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

672 

673 

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 

685 

686 

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 

695 

696 

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 

705 

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 

709 

710 

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 

718 

719 

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