Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-10-20 08:42:20

0001 // This file is part of the ACTS project.
0002 //
0003 // Copyright (C) 2016 CERN for the benefit of the ACTS project
0004 //
0005 // This Source Code Form is subject to the terms of the Mozilla Public
0006 // License, v. 2.0. If a copy of the MPL was not distributed with this
0007 // file, You can obtain one at https://mozilla.org/MPL/2.0/.
0008 
0009 #include <boost/test/unit_test.hpp>
0010 
0011 #include "Acts/Definitions/Units.hpp"
0012 #include "Acts/Material/GridSurfaceMaterial.hpp"
0013 #include "Acts/Material/HomogeneousSurfaceMaterial.hpp"
0014 #include "Acts/Material/HomogeneousVolumeMaterial.hpp"
0015 #include "Acts/Material/ProtoSurfaceMaterial.hpp"
0016 #include "ActsPlugins/Json/TrackingGeometryMaterialJsonConverter.hpp"
0017 #include "ActsPlugins/Json/detail/JsonIo.hpp"
0018 #include "ActsTests/CommonHelpers/TemporaryDirectory.hpp"
0019 
0020 #include <algorithm>
0021 #include <array>
0022 #include <bit>
0023 #include <cmath>
0024 #include <cstdint>
0025 #include <cstdlib>
0026 #include <filesystem>
0027 #include <fstream>
0028 #include <limits>
0029 #include <string>
0030 
0031 using namespace Acts;
0032 using Converter = TrackingGeometryMaterialJsonConverter;
0033 
0034 namespace ActsTests {
0035 namespace {
0036 std::filesystem::path examples() {
0037   const auto& master = boost::unit_test::framework::master_test_suite();
0038   BOOST_REQUIRE_EQUAL(master.argc, 2);
0039   return master.argv[1];
0040 }
0041 nlohmann::json fixture(const char* name) {
0042   return detail::readJsonFile(examples() / name);
0043 }
0044 
0045 class CustomMaterial final : public ISurfaceMaterial {
0046  public:
0047   using ISurfaceMaterial::materialSlab;
0048   explicit CustomMaterial(double thickness)
0049       : m_slab(MaterialSlab::Vacuum(static_cast<float>(thickness))) {}
0050   CustomMaterial& scale(double factor) override {
0051     m_slab.scaleThickness(static_cast<float>(factor));
0052     return *this;
0053   }
0054   const MaterialSlab& materialSlab(
0055       const Vector2& /*localPosition*/) const override {
0056     return m_slab;
0057   }
0058   std::vector<AxisDirection> localAxisDirections() const override { return {}; }
0059   std::ostream& toStream(std::ostream& out) const override {
0060     return out << "custom";
0061   }
0062 
0063  private:
0064   MaterialSlab m_slab;
0065 };
0066 nlohmann::json encodeCustom(const CustomMaterial& m,
0067                             Converter::EncodeContext& context) {
0068   return {{"kind", "application-custom"},
0069           {"thickness",
0070            m.materialSlab(Vector2::Zero()).thickness() / context.lengthUnit()}};
0071 }
0072 std::unique_ptr<const ISurfaceMaterial> decodeCustom(
0073     const nlohmann::json& j, const Converter::DecodeContext& context) {
0074   return std::make_unique<CustomMaterial>(j.at("thickness").get<double>() *
0075                                           context.lengthUnit());
0076 }
0077 }  // namespace
0078 
0079 BOOST_AUTO_TEST_SUITE(JsonSuite)
0080 BOOST_AUTO_TEST_CASE(MaterialDocumentExamples) {
0081   Converter converter;
0082   for (const auto* name : {"minimal.json", "surfaces.json", "templates.json"}) {
0083     BOOST_TEST_CONTEXT(name) {
0084       const auto decoded = converter.fromFile(examples() / name);
0085       const auto encoded = converter.toJson(decoded);
0086       BOOST_CHECK(encoded == converter.toJson(converter.fromJson(encoded)));
0087       // Optional export for offline schema validation of actual codec output.
0088       if (const char* output =
0089               std::getenv("ACTS_MATERIAL_DOCUMENT_OUTPUT_DIR")) {
0090         std::filesystem::create_directories(output);
0091         std::ofstream(std::filesystem::path(output) / name)
0092             << encoded.dump(4) << '\n';
0093       }
0094     }
0095   }
0096   const auto surfaces = converter.fromJson(fixture("surfaces.json"));
0097   BOOST_REQUIRE(surfaces.description());
0098   BOOST_CHECK_EQUAL(*surfaces.description(), fixture("surfaces.json")
0099                                                  .at("header")
0100                                                  .at("description")
0101                                                  .get<std::string>());
0102   const auto* a = dynamic_cast<const GridSurfaceMaterial*>(
0103       surfaces.keyedSurfaces.at("barrel/support-a").material.get());
0104   const auto* b = dynamic_cast<const GridSurfaceMaterial*>(
0105       surfaces.keyedSurfaces.at("barrel/support-b").material.get());
0106   BOOST_REQUIRE(a != nullptr);
0107   BOOST_REQUIRE(b != nullptr);
0108   BOOST_CHECK(
0109       std::get<GridSurfaceMaterial::GloballyIndexed>(a->storage()).material ==
0110       std::get<GridSurfaceMaterial::GloballyIndexed>(b->storage()).material);
0111 }
0112 
0113 BOOST_AUTO_TEST_CASE(MaterialDocumentUnits) {
0114   Converter converter;
0115   auto document = fixture("minimal.json");
0116   document["header"]["units"] = {{"length", "cm"},
0117                                  {"angle", "deg"},
0118                                  {"energy", "MeV"},
0119                                  {"material_amount", "mmol"}};
0120   const auto originalSlab = document["surfaces"][0]["material"]["slab"];
0121   const auto encoded = converter.toJson(converter.fromJson(document));
0122   BOOST_CHECK(encoded["header"]["units"] ==
0123               fixture("minimal.json")["header"]["units"]);
0124   const auto& slab = encoded["surfaces"][0]["material"]["slab"];
0125   BOOST_CHECK_CLOSE_FRACTION(slab["thickness"].get<double>(),
0126                              originalSlab["thickness"].get<double>() * 10.,
0127                              1e-6);
0128   for (const auto* field :
0129        {"radiation_length", "interaction_length", "molar_density",
0130         "molar_electron_density", "mean_excitation_energy"}) {
0131     const std::string name(field);
0132     const double factor = name.ends_with("length")    ? 10.
0133                           : name.ends_with("density") ? 1e-6
0134                                                       : 1e-3;
0135     BOOST_CHECK_CLOSE_FRACTION(
0136         slab["material"][field].get<double>(),
0137         originalSlab["material"][field].get<double>() * factor, 1e-6);
0138   }
0139 
0140   // Exercise binned, direct, indexed and shared storage with noncanonical
0141   // units.
0142   auto surfaces = fixture("surfaces.json");
0143   const auto baseline = converter.toJson(converter.fromJson(surfaces));
0144   surfaces["header"]["units"] = document["header"]["units"];
0145   const auto rescaled = converter.toJson(converter.fromJson(surfaces));
0146   auto checkSlabs = [&](auto&& self, const nlohmann::json& before,
0147                         const nlohmann::json& after) -> void {
0148     if (before.is_object() && before.contains("thickness")) {
0149       BOOST_CHECK_CLOSE_FRACTION(after.at("thickness").get<double>(),
0150                                  before.at("thickness").get<double>() * 10.,
0151                                  1e-6);
0152       if (before.at("material").at("kind") == "material") {
0153         for (const auto* field : {"molar_density", "molar_electron_density"}) {
0154           BOOST_CHECK_CLOSE_FRACTION(
0155               after.at("material").at(field).template get<double>(),
0156               before.at("material").at(field).template get<double>() * 1e-6,
0157               1e-6);
0158         }
0159       }
0160     } else if (before.is_object()) {
0161       for (const auto& [key, value] : before.items()) {
0162         self(self, value, after.at(key));
0163       }
0164     } else if (before.is_array()) {
0165       for (std::size_t i = 0; i < before.size(); ++i) {
0166         self(self, before.at(i), after.at(i));
0167       }
0168     }
0169   };
0170   checkSlabs(checkSlabs, baseline, rescaled);
0171 
0172   auto templates = fixture("templates.json");
0173   templates["header"]["units"] = document["header"]["units"];
0174   auto& protoAxes = templates["surfaces"][1]["material"]["axes"];
0175   protoAxes[0] = {{"kind", "equidistant"},
0176                   {"bins", 2},
0177                   {"direction", "phi"},
0178                   {"range", {-180., 180.}}};
0179   auto& binning = templates["surfaces"][0]["material"]["binning"];
0180   binning["transform"] = {
0181       {"rotation", {{1., 0., 0.}, {0., 1., 0.}, {0., 0., 1.}}},
0182       {"translation", {1., 2., 3.}}};
0183   const auto output = converter.toJson(converter.fromJson(templates));
0184   const auto& axes = output["surfaces"].back()["material"]["axes"];
0185   BOOST_CHECK_CLOSE_FRACTION(axes[0]["range"][1].get<double>(),
0186                              180. * UnitConstants::degree, 1e-12);
0187   BOOST_CHECK(axes[1] == protoAxes[1]);  // Normalized edges stay dimensionless.
0188   BOOST_CHECK(output["surfaces"][0]["material"]["binning"]["transform"]
0189                     ["translation"] == nlohmann::json({10., 20., 30.}));
0190   for (const auto* direction : {"x", "rphi", "eta", "theta"}) {
0191     protoAxes[0]["direction"] = direction;
0192     const auto converted = converter.toJson(converter.fromJson(templates));
0193     const double factor = std::string(direction) == "eta" ? 1.
0194                           : std::string(direction) == "theta"
0195                               ? UnitConstants::degree
0196                               : 10.;
0197     BOOST_CHECK_CLOSE_FRACTION(converted["surfaces"]
0198                                    .back()["material"]["axes"][0]["range"][1]
0199                                    .get<double>(),
0200                                180. * factor, 1e-12);
0201   }
0202 
0203   for (const auto* key : {"length", "angle", "energy", "material_amount"}) {
0204     auto invalid = document;
0205     invalid["header"]["units"][key] = "unknown";
0206     BOOST_CHECK_EXCEPTION(converter.fromJson(invalid), std::invalid_argument,
0207                           [&](const auto& error) {
0208                             return std::string(error.what()).find(key) !=
0209                                    std::string::npos;
0210                           });
0211     invalid["header"]["units"].erase(key);
0212     BOOST_CHECK_THROW(converter.fromJson(invalid), nlohmann::json::exception);
0213   }
0214   document["header"]["units"]["length"] = "GeV";
0215   BOOST_CHECK_THROW(converter.fromJson(document), std::invalid_argument);
0216   document["header"].erase("units");
0217   BOOST_CHECK_THROW(converter.fromJson(document), nlohmann::json::exception);
0218 }
0219 
0220 BOOST_AUTO_TEST_CASE(MaterialDocumentQuantization) {
0221   Converter converter;
0222   const auto id = GeometryIdentifier().withVolume(1);
0223   for (unsigned int bits : {0u, 12u, 16u, 20u, 23u}) {
0224     Converter::Options options;
0225     options.materialFractionBits = bits;
0226     const double bound = std::ldexp(1., -static_cast<int>(bits) - 1);
0227     for (float value :
0228          {0.f, -0.f, 7.23751f, std::nextafter(1.f, 2.f),
0229           1.f + std::ldexp(1.f, -17), 1.f + 3 * std::ldexp(1.f, -17),
0230           std::numeric_limits<float>::denorm_min(),
0231           std::numeric_limits<float>::min(),
0232           std::numeric_limits<float>::max()}) {
0233       const auto material = Material::fromMolarDensity(
0234           value, value, value > 0 ? value : 28.f, value, value, value, value);
0235       TrackingGeometryMaterial source;
0236       const double split = 0.123456789012345;
0237       source.surfaceMaterials[id] =
0238           std::make_shared<HomogeneousSurfaceMaterial>(
0239               MaterialSlab(material, value), split);
0240       const auto original = converter.toJson(source);
0241       const auto encoded = converter.toJson(source, options);
0242       if (bits == 23) {
0243         BOOST_CHECK(encoded == original);
0244       }
0245       for (const auto& parsed :
0246            {nlohmann::json::parse(encoded.dump()),
0247             nlohmann::json::from_cbor(nlohmann::json::to_cbor(encoded))}) {
0248         const auto recovered = converter.fromJson(parsed);
0249         BOOST_CHECK(converter.toJson(recovered, options) == encoded);
0250         const auto& surface = *recovered.surfaceMaterials.at(id);
0251         const float actual = surface.materialSlab(Vector2::Zero()).thickness();
0252         if (!std::isnormal(value) || bits == 23) {
0253           BOOST_CHECK_EQUAL(std::bit_cast<std::uint32_t>(actual),
0254                             std::bit_cast<std::uint32_t>(value));
0255         } else {
0256           BOOST_CHECK_LE(std::abs((double(actual) - value) / value), bound);
0257         }
0258         if (bits == 16 && value == 1.f + std::ldexp(1.f, -17)) {
0259           BOOST_CHECK_EQUAL(actual, 1.f);
0260         }
0261         if (bits == 16 && value == 1.f + 3 * std::ldexp(1.f, -17)) {
0262           BOOST_CHECK_EQUAL(actual, 1.f + std::ldexp(1.f, -15));
0263         }
0264         BOOST_CHECK_EQUAL(surface.factor(Direction::Backward(),
0265                                          MaterialUpdateMode::PreUpdate),
0266                           split);
0267       }
0268       for (const auto& change : nlohmann::json::diff(original, encoded)) {
0269         BOOST_CHECK_EQUAL(change.at("op").get<std::string>(), "replace");
0270         const auto path =
0271             nlohmann::json::json_pointer(change.at("path").get<std::string>());
0272         const double before = original.at(path).get<double>();
0273         const double after = encoded.at(path).get<double>();
0274         BOOST_REQUIRE_NE(before, 0.);
0275         BOOST_CHECK_LE(std::abs((after - before) / before), bound);
0276       }
0277     }
0278   }
0279   Converter::Options invalid;
0280   invalid.materialFractionBits = 24;
0281   BOOST_CHECK_THROW(converter.toJson({}, invalid), std::invalid_argument);
0282 }
0283 
0284 BOOST_AUTO_TEST_CASE(MaterialDocumentQuantizationStorageAndFiles) {
0285   Converter converter;
0286   Converter::Options options;
0287   options.materialFractionBits = 16;
0288   TemporaryDirectory tmp;
0289   for (const auto* name : {"minimal.json", "surfaces.json", "templates.json"}) {
0290     const auto source = converter.fromJson(fixture(name));
0291     const auto original = converter.toJson(source);
0292     const auto encoded = converter.toJson(source, options);
0293     for (const auto& change : nlohmann::json::diff(original, encoded)) {
0294       BOOST_CHECK_EQUAL(change.at("op").get<std::string>(), "replace");
0295       const auto path = change.at("path").get<std::string>();
0296       // IDs, indices, coordinates, settings, strings and structure must
0297       // survive.
0298       const std::array fields{"/thickness",
0299                               "/radiation_length",
0300                               "/interaction_length",
0301                               "/relative_atomic_mass",
0302                               "/atomic_number",
0303                               "/molar_density",
0304                               "/molar_electron_density",
0305                               "/mean_excitation_energy"};
0306       BOOST_CHECK(std::ranges::any_of(
0307           fields, [&](const auto* field) { return path.ends_with(field); }));
0308     }
0309     std::vector<std::string> extensions{".json", ".cbor"};
0310     if (detail::zstdSupported()) {
0311       extensions.insert(extensions.end(), {".json.zst", ".cbor.zst"});
0312     }
0313     for (const auto& extension : extensions) {
0314       const auto path = tmp.path() / (std::string(name) + extension);
0315       converter.toFile(source, path, options);
0316       BOOST_CHECK(converter.toJson(converter.fromFile(path)) == encoded);
0317     }
0318   }
0319 }
0320 
0321 BOOST_AUTO_TEST_CASE(MaterialDocumentDescription) {
0322   Converter converter;
0323   TrackingGeometryMaterial material;
0324   BOOST_CHECK(!material.description());
0325   BOOST_CHECK(!converter.toJson(material).at("header").contains("description"));
0326   material.setDescription("Mapped detector material");
0327   auto copy = material;
0328   copy.setDescription("");
0329   BOOST_CHECK_EQUAL(*material.description(), "Mapped detector material");
0330   BOOST_REQUIRE(copy.description());
0331   BOOST_CHECK(copy.description()->empty());
0332   const auto encoded = converter.toJson(material);
0333   BOOST_CHECK_EQUAL(*converter.fromJson(encoded).description(),
0334                     "Mapped detector material");
0335   BOOST_REQUIRE(converter.fromJson(converter.toJson(copy)).description());
0336   copy.setDescription(std::nullopt);
0337   BOOST_CHECK(!converter.toJson(copy).at("header").contains("description"));
0338   for (const nlohmann::json& invalid :
0339        {nlohmann::json(nullptr), nlohmann::json(42), nlohmann::json::object(),
0340         nlohmann::json::array()}) {
0341     auto malformed = encoded;
0342     malformed["header"]["description"] = invalid;
0343     BOOST_CHECK_THROW(converter.fromJson(malformed), nlohmann::json::exception);
0344   }
0345 }
0346 
0347 BOOST_AUTO_TEST_CASE(MaterialDocumentPhysicalPropertiesAndStorage) {
0348   Converter converter;
0349   const auto silicon = Material::fromMolarDensity(90.f, 450.f, 28.f, 14.f,
0350                                                   0.001f, 0.02f, 0.0003f);
0351   const MaterialSlab slab(silicon, 0.25f);
0352   TrackingGeometryMaterial source;
0353   const auto id = GeometryIdentifier()
0354                       .withVolume(255)
0355                       .withBoundary(255)
0356                       .withLayer(4095)
0357                       .withPassive(255)
0358                       .withSensitive(1048575)
0359                       .withExtra(255);
0360   source.surfaceMaterials[id] = std::make_shared<HomogeneousSurfaceMaterial>(
0361       slab, 0.25, MappingType::Sensor);
0362   source.setDescription("roundtrip");
0363   const auto encoded = converter.toJson(source);
0364   BOOST_CHECK_EQUAL(encoded.at("$schema").get<std::string>(),
0365                     "urn:acts:material-map:1");
0366   const auto& encodedId =
0367       encoded.at("surfaces").at(0).at("target").at("geometry_id");
0368   BOOST_CHECK_EQUAL(encodedId.at("passive").get<unsigned int>(), 255u);
0369   BOOST_CHECK(!encodedId.contains("approach"));
0370   BOOST_CHECK_EQUAL(encodedId.at("portal").get<unsigned int>(), 255u);
0371   BOOST_CHECK(!encodedId.contains("boundary"));
0372   const auto recovered = converter.fromJson(encoded);
0373   BOOST_CHECK(source.description() == recovered.description());
0374   const auto& material = *recovered.surfaceMaterials.at(id);
0375   BOOST_CHECK(material.materialSlab(Vector2::Zero()) == slab);
0376   BOOST_CHECK_EQUAL(
0377       material.factor(Direction::Negative(), MaterialUpdateMode::PreUpdate),
0378       0.25);
0379   BOOST_CHECK(material.mappingType() == MappingType::Sensor);
0380 
0381   // Unequal dimensions and unique values expose accidental native-order copies,
0382   // including all guard cells rather than only regular bins.
0383   MultiAxisSpec2D axes(std::array<AxisSpec, 2>{
0384       AxisSpec::Equidistant(2, 0., 2., AxisBoundaryType::Open,
0385                             AxisDirection::AxisX),
0386       AxisSpec::Equidistant(3, 0., 3., AxisBoundaryType::Open,
0387                             AxisDirection::AxisY)});
0388   auto multi = axes.buildMultiAxis();
0389   GridSurfaceMaterial::Direct values(20);
0390   for (std::size_t y = 0; y < 5; ++y) {
0391     for (std::size_t x = 0; x < 4; ++x) {
0392       values[multi->getGlobalBinFromLocalBins({x, y})] =
0393           MaterialSlab::Vacuum(static_cast<float>(x + 10 * y));
0394     }
0395   }
0396   source.surfaceMaterials.clear();
0397   source.surfaceMaterials[id] =
0398       std::make_shared<GridSurfaceMaterial>(axes, values);
0399   const auto j = converter.toJson(source);
0400   const auto& serialized = j["surfaces"][0]["material"]["storage"]["values"];
0401   BOOST_CHECK_EQUAL(serialized[1]["thickness"].get<double>(), 1.);
0402   BOOST_CHECK_EQUAL(serialized[4]["thickness"].get<double>(), 10.);
0403   const auto decoded = converter.fromJson(j);
0404   const auto& grid = dynamic_cast<const GridSurfaceMaterial&>(
0405       *decoded.surfaceMaterials.at(id));
0406   BOOST_CHECK(std::get<GridSurfaceMaterial::Direct>(grid.storage()) == values);
0407   BOOST_CHECK_EQUAL(grid.materialSlab(Vector2(-1., -1.)).thickness(), 0.);
0408   BOOST_CHECK_EQUAL(grid.materialSlab(Vector2(3., 4.)).thickness(), 43.);
0409 }
0410 
0411 BOOST_AUTO_TEST_CASE(MaterialDocumentRejectsVolumes) {
0412   Converter converter;
0413   TrackingGeometryMaterial material;
0414   material.volumeMaterials.emplace(
0415       GeometryIdentifier().withVolume(1),
0416       std::make_shared<HomogeneousVolumeMaterial>(Material::Vacuum()));
0417   BOOST_CHECK_THROW(converter.toJson(material), std::invalid_argument);
0418   // Even an explicit null assignment must not be silently discarded.
0419   material.volumeMaterials.begin()->second.reset();
0420   BOOST_CHECK_THROW(converter.toJson(material), std::invalid_argument);
0421   auto document = fixture("minimal.json");
0422   BOOST_CHECK(!document.contains("volumes"));
0423   document["volumes"] = nlohmann::json::array();
0424   BOOST_CHECK_THROW(converter.fromJson(document), std::invalid_argument);
0425   document["volumes"].push_back(
0426       {{"geometry_id", {{"volume", 1}}}, {"material", nullptr}});
0427   BOOST_CHECK_THROW(converter.fromJson(document), std::invalid_argument);
0428   const auto decoded = converter.fromJson(fixture("minimal.json"));
0429   BOOST_CHECK(decoded.volumeMaterials.empty());
0430   BOOST_CHECK(!converter.toJson(decoded).contains("volumes"));
0431 }
0432 
0433 BOOST_AUTO_TEST_CASE(MaterialDocumentRejectsInvalidInputs) {
0434   Converter converter;
0435   const auto base = fixture("surfaces.json");
0436   auto bad = [&](auto mutation) {
0437     auto j = base;
0438     mutation(j);
0439     BOOST_CHECK_THROW(converter.fromJson(j), std::invalid_argument);
0440   };
0441   bad([](auto& j) { j["header"]["version"] = 2; });
0442   bad([](auto& j) { j["header"]["version"] = 1.5; });
0443   bad([](auto& j) { j["header"]["format"] = "legacy"; });
0444   bad([](auto& j) { j["surfaces"][0]["material"]["kind"] = "unknown"; });
0445   bad([](auto& j) {
0446     j["surfaces"][0]["target"]["geometry_id"]["volume"] = 256;
0447   });
0448   bad([](auto& j) {
0449     auto copy = j["surfaces"][0];
0450     copy["target"]["geometry_id"]["extra"] = 0;
0451     j["surfaces"].push_back(copy);
0452   });
0453   bad([](auto& j) {
0454     j["surfaces"][5]["material"]["storage"]["store"] = "missing";
0455   });
0456   bad([](auto& j) {
0457     j["surfaces"][4]["material"]["storage"]["indices"][4] = 2;
0458   });
0459   bad([](auto& j) {
0460     j["surfaces"][3]["material"]["storage"]["values"].erase(0);
0461   });
0462   bad([](auto& j) {
0463     j["surfaces"][3]["material"]["axes"][0]["range"] = {1., -1.};
0464   });
0465   bad([](auto& j) { j["surfaces"][5]["material"] = nullptr; });
0466   bad([](auto& j) {
0467     j["surfaces"][0]["material"]["slab"]["thickness"] =
0468         std::numeric_limits<double>::quiet_NaN();
0469   });
0470   auto proto = fixture("templates.json");
0471   proto["surfaces"][1]["material"]["material_key"] = "mismatch";
0472   BOOST_CHECK_THROW(converter.fromJson(proto), std::invalid_argument);
0473 }
0474 
0475 BOOST_AUTO_TEST_CASE(MaterialDocumentBasicParsing) {
0476   Converter converter;
0477   auto document = fixture("minimal.json");
0478   document.erase("header");
0479   BOOST_CHECK_THROW(converter.fromJson(document), std::invalid_argument);
0480   const nlohmann::json legacy{
0481       {"Surfaces", {{"entries", nlohmann::json::array()}}}};
0482   BOOST_CHECK_EXCEPTION(
0483       converter.fromJson(legacy), std::invalid_argument, [](const auto& error) {
0484         return std::string(error.what()).find("ActsMaterialMapMigrate") !=
0485                std::string::npos;
0486       });
0487   document = fixture("minimal.json");
0488   document["header"] = nullptr;
0489   BOOST_CHECK_THROW(converter.fromJson(document), nlohmann::json::exception);
0490   document = fixture("minimal.json");
0491   document["extra"] = "ignored by codec; rejected by offline schema";
0492   document["surfaces"][0]["material"]["extra"] = true;
0493   BOOST_CHECK_NO_THROW(converter.fromJson(document));
0494   document["surfaces"][0]["material"].erase("slab");
0495   BOOST_CHECK_THROW(converter.fromJson(document), nlohmann::json::exception);
0496   document = fixture("minimal.json");
0497   document["surfaces"][0]["material"]["slab"]["thickness"] = "invalid";
0498   BOOST_CHECK_THROW(converter.fromJson(document), nlohmann::json::exception);
0499 }
0500 
0501 BOOST_AUTO_TEST_CASE(MaterialDocumentExtensionDispatchAndFiles) {
0502   Converter::Config config = Converter::Config::defaultConfig();
0503   config.encodeSurface.registerFunction(encodeCustom);
0504   config.decodeSurface.registerKind("application-custom", decodeCustom);
0505   Converter converter(std::move(config));
0506   TrackingGeometryMaterial source;
0507   source.surfaceMaterials[GeometryIdentifier().withVolume(1)] =
0508       std::make_shared<CustomMaterial>(7.23751);
0509   const auto encoded = converter.toJson(source);
0510   Converter::Options quantized;
0511   quantized.materialFractionBits = 0;
0512   BOOST_CHECK(converter.toJson(source, quantized) == encoded);
0513   BOOST_CHECK_THROW(Converter().fromJson(encoded), std::invalid_argument);
0514   auto centimeters = encoded;
0515   centimeters["header"]["units"]["length"] = "cm";
0516   const auto scaled = converter.fromJson(centimeters);
0517   BOOST_CHECK_CLOSE_FRACTION(scaled.surfaceMaterials.begin()
0518                                  ->second->materialSlab(Vector2::Zero())
0519                                  .thickness(),
0520                              source.surfaceMaterials.begin()
0521                                      ->second->materialSlab(Vector2::Zero())
0522                                      .thickness() *
0523                                  10.,
0524                              1e-6);
0525   const auto decoded = converter.fromJson(encoded);
0526   BOOST_CHECK(dynamic_cast<const CustomMaterial*>(
0527                   decoded.surfaceMaterials.begin()->second.get()) != nullptr);
0528   TemporaryDirectory tmp;
0529   std::vector<std::string> extensions{".json", ".cbor"};
0530   if (detail::zstdSupported()) {
0531     extensions.insert(extensions.end(), {".json.zst", ".cbor.zst"});
0532   }
0533   for (const auto& ext : extensions) {
0534     const auto path = tmp.path() / ("material" + ext);
0535     converter.toFile(source, path);
0536     const auto renamed = tmp.path() / "unknown-extension.bin";
0537     std::filesystem::rename(path, renamed);
0538     BOOST_CHECK(converter.toJson(converter.fromFile(renamed)) == encoded);
0539     std::filesystem::remove(renamed);
0540   }
0541 }
0542 BOOST_AUTO_TEST_SUITE_END()
0543 }  // namespace ActsTests