#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { class TestHdf5Handle final { public: using CloseFunction = herr_t (*)(hid_t); TestHdf5Handle(hid_t id, CloseFunction close) : id_{id}, close_{close} { if (id_ < 0) { throw std::runtime_error{"Failed to open test HDF5 resource."}; } } ~TestHdf5Handle() { if (id_ >= 0) { close_(id_); } } TestHdf5Handle(const TestHdf5Handle&) = delete; TestHdf5Handle& operator=(const TestHdf5Handle&) = delete; TestHdf5Handle(TestHdf5Handle&& other) noexcept : id_{std::exchange(other.id_, H5I_INVALID_HID)}, close_{other.close_} {} TestHdf5Handle& operator=(TestHdf5Handle&&) = delete; [[nodiscard]] hid_t get() const noexcept { return id_; } private: hid_t id_; CloseFunction close_; }; std::string hdf5_path(const std::filesystem::path& path) { const std::u8string value = path.u8string(); return {reinterpret_cast(value.data()), value.size()}; } void require_hdf5_status(const herr_t status) { if (status < 0) { throw std::runtime_error{"Test HDF5 mutation failed."}; } } void replace_step_time_with_vector(const std::filesystem::path& path) { const std::string encoded_path = hdf5_path(path); TestHdf5Handle file{ H5Fopen(encoded_path.c_str(), H5F_ACC_RDWR, H5P_DEFAULT), &H5Fclose, }; TestHdf5Handle frame{ H5Gopen2( file.get(), "/results/steps/0/frames/0", H5P_DEFAULT), &H5Gclose, }; require_hdf5_status(H5Adelete(frame.get(), "step_time")); const std::array dimensions{2U}; TestHdf5Handle space{ H5Screate_simple(1, dimensions.data(), nullptr), &H5Sclose, }; TestHdf5Handle attribute{ H5Acreate2( frame.get(), "step_time", H5T_IEEE_F64LE, space.get(), H5P_DEFAULT, H5P_DEFAULT), &H5Aclose, }; const std::array values{1.25, 2.5}; require_hdf5_status( H5Awrite(attribute.get(), H5T_NATIVE_DOUBLE, values.data())); } void write_int64_dataset( const std::filesystem::path& path, const std::string_view dataset_path, const std::span values) { const std::string encoded_path = hdf5_path(path); const std::string owned_dataset_path{dataset_path}; TestHdf5Handle file{ H5Fopen(encoded_path.c_str(), H5F_ACC_RDWR, H5P_DEFAULT), &H5Fclose, }; TestHdf5Handle dataset{ H5Dopen2( file.get(), owned_dataset_path.c_str(), H5P_DEFAULT), &H5Dclose, }; require_hdf5_status(H5Dwrite( dataset.get(), H5T_NATIVE_INT64, H5S_ALL, H5S_ALL, H5P_DEFAULT, values.data())); } void write_double_dataset( const std::filesystem::path& path, const std::string_view dataset_path, const std::span values) { const std::string encoded_path = hdf5_path(path); const std::string owned_dataset_path{dataset_path}; TestHdf5Handle file{ H5Fopen(encoded_path.c_str(), H5F_ACC_RDWR, H5P_DEFAULT), &H5Fclose, }; TestHdf5Handle dataset{ H5Dopen2( file.get(), owned_dataset_path.c_str(), H5P_DEFAULT), &H5Dclose, }; require_hdf5_status(H5Dwrite( dataset.get(), H5T_NATIVE_DOUBLE, H5S_ALL, H5S_ALL, H5P_DEFAULT, values.data())); } std::filesystem::path round_trip_path() { return std::filesystem::path{FESA_TEST_BINARY_DIR} / "Testing" / "Temporary" / "fesa-round-trip.h5"; } fesa::Domain make_domain() { fesa::DomainBuilder builder; builder.add_node({ fesa::NodeId{42}, fesa::EntityOrigin{"BeamPart", "Beam-1", 1001}, fesa::Vec3{1.25, -2.5, 3.75}, }); builder.add_node({ fesa::NodeId{7}, fesa::EntityOrigin{"BeamPart", "Beam-1", 1002}, fesa::Vec3{4.0, 5.5, -6.25}, }); builder.add_material({ fesa::MaterialId{6}, "Steel", 210.0e9, 0.3, }); builder.add_section({ fesa::SectionId{4}, "InputShear", 0.04, 1.2e-4, 1.4e-4, 2.0e-4, 0.031, 0.032, fesa::ShearPropertySource::input, fesa::Vec3{0.0, 1.0, 0.0}, {}, }); builder.add_section({ fesa::SectionId{12}, "DefaultShear", 0.06, 1.5e-4, 1.7e-4, 2.2e-4, 0.05, 0.05, fesa::ShearPropertySource::phase1_default, fesa::Vec3{0.0, 1.0, 0.0}, {}, }); builder.add_beam_element({ fesa::ElementId{9}, fesa::EntityOrigin{"BeamPart", "Beam-1", 2001}, {fesa::NodeId{7}, fesa::NodeId{42}}, fesa::MaterialId{6}, fesa::SectionId{4}, }); builder.add_beam_element({ fesa::ElementId{17}, fesa::EntityOrigin{"BeamPart", "Beam-1", 2002}, {fesa::NodeId{42}, fesa::NodeId{7}}, fesa::MaterialId{6}, fesa::SectionId{4}, }); builder.set_step({"Load/Case", {}, {}}); auto built = std::move(builder).build(); EXPECT_TRUE(built.domain.has_value()); EXPECT_TRUE(built.diagnostics.empty()); return std::move(*built.domain); } fesa::ResultDatabase make_database() { return { "1.0.0", {{ "Load/Case", {{ 1.25, { {fesa::NodeId{7}, fesa::NodeId{42}}, { {1.0, 2.0, 3.0, 4.0, 5.0, 6.0}, {-1.0, -2.0, -3.0, -4.0, -5.0, -6.0}, }, { {10.0, 20.0, 30.0, 40.0, 50.0, 60.0}, {-10.0, -20.0, -30.0, -40.0, -50.0, -60.0}, }, }, {}, }}, }}, }; } bool has_results_error( const std::vector& diagnostics, const std::string_view code) { return std::ranges::any_of( diagnostics, [code](const fesa::Diagnostic& diagnostic) { return diagnostic.stage == fesa::DiagnosticStage::results && diagnostic.severity == fesa::Severity::error && diagnostic.code == code; }); } TEST(ResultRoundTrip, PreservesMinimalSchemaModelAndNodalResults) { const auto path = round_trip_path(); std::filesystem::create_directories(path.parent_path()); std::filesystem::remove(path); const auto domain = make_domain(); const auto database = make_database(); const auto write_diagnostics = fesa::write_hdf5(path, domain, database); ASSERT_TRUE(write_diagnostics.empty()); const auto read = fesa::read_hdf5_results(path); ASSERT_TRUE(read.diagnostics.empty()); ASSERT_TRUE(read.database.has_value()); ASSERT_TRUE(read.model.has_value()); EXPECT_EQ(read.database->schema_version, "1.0.0"); ASSERT_EQ(read.database->steps.size(), 1U); const auto& step = read.database->steps[0]; EXPECT_EQ(step.name, "Load/Case"); ASSERT_EQ(step.frames.size(), 1U); const auto& frame = step.frames[0]; EXPECT_DOUBLE_EQ(frame.step_time, 1.25); EXPECT_EQ( frame.nodal.node_ids, (std::vector{fesa::NodeId{7}, fesa::NodeId{42}})); EXPECT_EQ( frame.nodal.displacement[0], (std::array{1.0, 2.0, 3.0, 4.0, 5.0, 6.0})); EXPECT_EQ( frame.nodal.displacement[1], (std::array{-1.0, -2.0, -3.0, -4.0, -5.0, -6.0})); EXPECT_EQ( frame.nodal.reaction[0], (std::array{10.0, 20.0, 30.0, 40.0, 50.0, 60.0})); EXPECT_EQ( frame.nodal.reaction[1], (std::array{ -10.0, -20.0, -30.0, -40.0, -50.0, -60.0})); EXPECT_TRUE(frame.diagnostics.empty()); ASSERT_EQ(read.model->nodes.size(), 2U); EXPECT_EQ(read.model->nodes[0].dense_index, 0U); EXPECT_EQ(read.model->nodes[0].id, fesa::NodeId{42}); EXPECT_EQ( read.model->nodes[0].origin, (fesa::EntityOrigin{"BeamPart", "Beam-1", 1001})); EXPECT_DOUBLE_EQ(read.model->nodes[0].coordinates.x, 1.25); EXPECT_DOUBLE_EQ(read.model->nodes[0].coordinates.y, -2.5); EXPECT_DOUBLE_EQ(read.model->nodes[0].coordinates.z, 3.75); EXPECT_EQ(read.model->nodes[1].dense_index, 1U); EXPECT_EQ(read.model->nodes[1].id, fesa::NodeId{7}); EXPECT_EQ( read.model->nodes[1].origin, (fesa::EntityOrigin{"BeamPart", "Beam-1", 1002})); EXPECT_DOUBLE_EQ(read.model->nodes[1].coordinates.x, 4.0); EXPECT_DOUBLE_EQ(read.model->nodes[1].coordinates.y, 5.5); EXPECT_DOUBLE_EQ(read.model->nodes[1].coordinates.z, -6.25); ASSERT_EQ(read.model->elements.size(), 2U); EXPECT_EQ(read.model->elements[0].dense_index, 0U); EXPECT_EQ(read.model->elements[0].id, fesa::ElementId{9}); EXPECT_EQ( read.model->elements[0].connectivity, (std::array{1U, 0U})); EXPECT_EQ(read.model->elements[0].section, fesa::SectionId{4}); EXPECT_EQ(read.model->elements[1].dense_index, 1U); EXPECT_EQ(read.model->elements[1].id, fesa::ElementId{17}); EXPECT_EQ( read.model->elements[1].connectivity, (std::array{0U, 1U})); EXPECT_EQ(read.model->elements[1].section, fesa::SectionId{4}); ASSERT_EQ(read.model->sections.size(), 2U); EXPECT_EQ(read.model->sections[0].id, fesa::SectionId{4}); EXPECT_DOUBLE_EQ(read.model->sections[0].shear_area_y, 0.031); EXPECT_DOUBLE_EQ(read.model->sections[0].shear_area_z, 0.032); EXPECT_EQ( read.model->sections[0].shear_source, fesa::ShearPropertySource::input); EXPECT_EQ(read.model->sections[1].id, fesa::SectionId{12}); EXPECT_DOUBLE_EQ(read.model->sections[1].shear_area_y, 0.05); EXPECT_DOUBLE_EQ(read.model->sections[1].shear_area_z, 0.05); EXPECT_EQ( read.model->sections[1].shear_source, fesa::ShearPropertySource::phase1_default); } TEST(Hdf5, RejectsInvalidResultDatabaseBeforeWriting) { const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} / "Testing" / "Temporary" / "fesa-invalid.h5"; std::filesystem::remove(path); const auto domain = make_domain(); auto database = make_database(); database.steps[0].frames[0].nodal.reaction.pop_back(); const auto diagnostics = fesa::write_hdf5(path, domain, database); EXPECT_TRUE( has_results_error(diagnostics, "results.nodal_size_mismatch")); EXPECT_FALSE(std::filesystem::exists(path)); } TEST(Hdf5, RejectsFrameDiagnosticsThatSchemaCannotRepresent) { const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} / "Testing" / "Temporary" / "fesa-unsupported-diagnostics.h5"; std::filesystem::remove(path); const auto domain = make_domain(); auto database = make_database(); database.steps[0].frames[0].diagnostics.push_back({ fesa::DiagnosticStage::solver, fesa::Severity::warning, "solver.residual", "Residual diagnostic", std::nullopt, }); const auto diagnostics = fesa::write_hdf5(path, domain, database); EXPECT_TRUE(has_results_error( diagnostics, "hdf5.unsupported_result_diagnostics")); EXPECT_FALSE(std::filesystem::exists(path)); } TEST(Hdf5, RejectsResultNodeMissingFromDomainBeforeWriting) { const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} / "Testing" / "Temporary" / "fesa-missing-result-node.h5"; std::filesystem::remove(path); const auto domain = make_domain(); auto database = make_database(); database.steps[0].frames[0].nodal.node_ids[0] = fesa::NodeId{999}; const auto diagnostics = fesa::write_hdf5(path, domain, database); EXPECT_TRUE(has_results_error( diagnostics, "hdf5.result_node_not_in_model")); EXPECT_FALSE(std::filesystem::exists(path)); } TEST(Hdf5, ReportsMissingFileAtResultsStage) { const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} / "Testing" / "Temporary" / "fesa-missing.h5"; std::filesystem::remove(path); const auto read = fesa::read_hdf5_results(path); EXPECT_FALSE(read.database.has_value()); EXPECT_FALSE(read.model.has_value()); EXPECT_TRUE(has_results_error(read.diagnostics, "hdf5.open_failed")); } TEST(Hdf5, RejectsNonScalarStepTimeAttribute) { const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} / "Testing" / "Temporary" / "fesa-nonscalar-step-time.h5"; std::filesystem::remove(path); const auto write_diagnostics = fesa::write_hdf5(path, make_domain(), make_database()); ASSERT_TRUE(write_diagnostics.empty()); replace_step_time_with_vector(path); const auto read = fesa::read_hdf5_results(path); EXPECT_FALSE(read.database.has_value()); EXPECT_FALSE(read.model.has_value()); EXPECT_TRUE(has_results_error(read.diagnostics, "hdf5.read_failed")); } TEST(Hdf5, RejectsResultNodeMissingFromSerializedModel) { const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} / "Testing" / "Temporary" / "fesa-invalid-result-node.h5"; std::filesystem::remove(path); const auto write_diagnostics = fesa::write_hdf5(path, make_domain(), make_database()); ASSERT_TRUE(write_diagnostics.empty()); const std::array node_ids{999, 42}; write_int64_dataset( path, "/results/steps/0/frames/0/nodal/node_ids", node_ids); const auto read = fesa::read_hdf5_results(path); EXPECT_FALSE(read.database.has_value()); EXPECT_FALSE(read.model.has_value()); EXPECT_TRUE(has_results_error( read.diagnostics, "hdf5.result_node_not_in_model")); } TEST(Hdf5, RejectsDuplicateSerializedNodeIds) { const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} / "Testing" / "Temporary" / "fesa-duplicate-node-ids.h5"; std::filesystem::remove(path); ASSERT_TRUE( fesa::write_hdf5(path, make_domain(), make_database()).empty()); const std::array ids{42, 42}; write_int64_dataset(path, "/model/nodes/internal_id", ids); const auto read = fesa::read_hdf5_results(path); EXPECT_FALSE(read.database.has_value()); EXPECT_FALSE(read.model.has_value()); EXPECT_TRUE( has_results_error(read.diagnostics, "hdf5.invalid_model_data")); } TEST(Hdf5, RejectsDuplicateSerializedElementIds) { const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} / "Testing" / "Temporary" / "fesa-duplicate-element-ids.h5"; std::filesystem::remove(path); ASSERT_TRUE( fesa::write_hdf5(path, make_domain(), make_database()).empty()); const std::array ids{9, 9}; write_int64_dataset(path, "/model/elements/internal_id", ids); const auto read = fesa::read_hdf5_results(path); EXPECT_FALSE(read.database.has_value()); EXPECT_FALSE(read.model.has_value()); EXPECT_TRUE( has_results_error(read.diagnostics, "hdf5.invalid_model_data")); } TEST(Hdf5, RejectsDuplicateSerializedSectionIds) { const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} / "Testing" / "Temporary" / "fesa-duplicate-section-ids.h5"; std::filesystem::remove(path); ASSERT_TRUE( fesa::write_hdf5(path, make_domain(), make_database()).empty()); const std::array ids{4, 4}; write_int64_dataset(path, "/model/sections/internal_id", ids); const auto read = fesa::read_hdf5_results(path); EXPECT_FALSE(read.database.has_value()); EXPECT_FALSE(read.model.has_value()); EXPECT_TRUE( has_results_error(read.diagnostics, "hdf5.invalid_model_data")); } TEST(Hdf5, RejectsNonfiniteSerializedCoordinates) { const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} / "Testing" / "Temporary" / "fesa-nonfinite-coordinates.h5"; std::filesystem::remove(path); ASSERT_TRUE( fesa::write_hdf5(path, make_domain(), make_database()).empty()); const std::array coordinates{ std::numeric_limits::quiet_NaN(), -2.5, 3.75, 4.0, 5.5, -6.25, }; write_double_dataset(path, "/model/nodes/coordinates", coordinates); const auto read = fesa::read_hdf5_results(path); EXPECT_FALSE(read.database.has_value()); EXPECT_FALSE(read.model.has_value()); EXPECT_TRUE( has_results_error(read.diagnostics, "hdf5.invalid_model_data")); } TEST(Hdf5, RejectsNonfiniteSerializedShearArea) { const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} / "Testing" / "Temporary" / "fesa-nonfinite-shear-area.h5"; std::filesystem::remove(path); ASSERT_TRUE( fesa::write_hdf5(path, make_domain(), make_database()).empty()); const std::array shear_areas{ std::numeric_limits::infinity(), 0.05, }; write_double_dataset(path, "/model/sections/shear_area_y", shear_areas); const auto read = fesa::read_hdf5_results(path); EXPECT_FALSE(read.database.has_value()); EXPECT_FALSE(read.model.has_value()); EXPECT_TRUE( has_results_error(read.diagnostics, "hdf5.invalid_model_data")); } TEST(Hdf5, RejectsNonpositiveSerializedShearArea) { const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} / "Testing" / "Temporary" / "fesa-nonpositive-shear-area.h5"; std::filesystem::remove(path); ASSERT_TRUE( fesa::write_hdf5(path, make_domain(), make_database()).empty()); const std::array shear_areas{-0.031, 0.05}; write_double_dataset(path, "/model/sections/shear_area_y", shear_areas); const auto read = fesa::read_hdf5_results(path); EXPECT_FALSE(read.database.has_value()); EXPECT_FALSE(read.model.has_value()); EXPECT_TRUE( has_results_error(read.diagnostics, "hdf5.invalid_model_data")); } } // namespace