From 51939fba5b559ebf74f7793b1dee11646def7322 Mon Sep 17 00:00:00 2001 From: "KOKO\\Mimi" Date: Sun, 2 Aug 2026 02:34:07 +0900 Subject: [PATCH] fix(result-contract-completion): enforce complete result provenance --- docs/HDF5_SCHEMA.md | 8 +- include/fesa/io/abaqus/parser.hpp | 2 + include/fesa/io/hdf5/writer.hpp | 10 +- src/fesa/analysis/run_solver.cpp | 16 +- src/fesa/io/abaqus/parser.cpp | 39 +- src/fesa/io/hdf5/writer.cpp | 315 ++++++++--- tests/CMakeLists.txt | 6 + tests/integration/io/hdf5_results_test.cpp | 531 +++++++++++++++--- .../pipeline/minimal_cantilever_test.cpp | 12 + tests/unit/io/abaqus/parser_test.cpp | 3 + 10 files changed, 785 insertions(+), 157 deletions(-) diff --git a/docs/HDF5_SCHEMA.md b/docs/HDF5_SCHEMA.md index fbd9e0d..feae439 100644 --- a/docs/HDF5_SCHEMA.md +++ b/docs/HDF5_SCHEMA.md @@ -18,7 +18,11 @@ infer compatibility from a version prefix. - Root attributes are variable-length UTF-8 strings: `schema_version="2.0.0"`, `fesa_version`, and - `unit_policy="consistent_input_units_no_conversion"`. + `unit_policy="consistent_input_units_no_conversion"`, `input_source`, and + `input_fingerprint`. `input_source` is the UTF-8 path supplied to the solve + request. `input_fingerprint` is `fnv1a64:` followed by the 16 lowercase + hexadecimal digits of FNV-1a 64 over the original input bytes; it is a + reproducibility identifier, not a cryptographic integrity guarantee. - Integer datasets use the stated little-endian fixed-width type. Floating datasets use IEEE 754 little-endian `float64`. Strings are variable-length UTF-8. @@ -32,7 +36,7 @@ infer compatibility from a version prefix. attributes where listed. `components` is a comma-separated ordered list. - Step and frame group names are contiguous decimal indices beginning at zero. Phase 1 requires exactly one analysis step and one result step with the same - name. + name, and exactly one result frame in that step. ## 3. Required objects diff --git a/include/fesa/io/abaqus/parser.hpp b/include/fesa/io/abaqus/parser.hpp index 29a9fa4..76c4871 100644 --- a/include/fesa/io/abaqus/parser.hpp +++ b/include/fesa/io/abaqus/parser.hpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -12,6 +13,7 @@ namespace fesa { struct ParseDeckResult final { std::optional deck; std::vector diagnostics; + std::string input_fingerprint; }; [[nodiscard]] ParseDeckResult parse_deck( diff --git a/include/fesa/io/hdf5/writer.hpp b/include/fesa/io/hdf5/writer.hpp index 4139ebf..255b914 100644 --- a/include/fesa/io/hdf5/writer.hpp +++ b/include/fesa/io/hdf5/writer.hpp @@ -60,6 +60,13 @@ struct Hdf5MetadataSnapshot final { std::string schema_version; std::string fesa_version; std::string unit_policy; + std::string input_source; + std::string input_fingerprint; +}; + +struct Hdf5InputIdentity final { + std::string source; + std::string fingerprint; }; struct Hdf5SolverSettingsSnapshot final { @@ -86,7 +93,8 @@ struct Hdf5ReadResult final { [[nodiscard]] std::vector write_hdf5( const std::filesystem::path& path, const Domain& domain, - const ResultDatabase& database); + const ResultDatabase& database, + const Hdf5InputIdentity& input_identity); [[nodiscard]] Hdf5ReadResult read_hdf5_results( const std::filesystem::path& path); diff --git a/src/fesa/analysis/run_solver.cpp b/src/fesa/analysis/run_solver.cpp index 2a8a859..75d8cc9 100644 --- a/src/fesa/analysis/run_solver.cpp +++ b/src/fesa/analysis/run_solver.cpp @@ -1,6 +1,7 @@ #include #include +#include #include #include @@ -8,6 +9,14 @@ #include namespace fesa { +namespace { + +std::string path_utf8(const std::filesystem::path& path) { + const std::u8string value = path.u8string(); + return {reinterpret_cast(value.data()), value.size()}; +} + +} // namespace AnalysisRunResult run_solver(const AnalysisRequest& request) { ParseDeckResult parsed = parse_deck(request.input_path); @@ -20,13 +29,18 @@ AnalysisRunResult run_solver(const AnalysisRequest& request) { return {false, std::nullopt, std::move(mapped.diagnostics)}; } + const Hdf5InputIdentity identity{ + path_utf8(request.input_path), + parsed.input_fingerprint, + }; + AnalysisRunResult run = LinearStaticAnalysis{}.run(*mapped.domain); if (!run.succeeded || !run.results.has_value()) { return run; } std::vector write_diagnostics = write_hdf5( - request.output_path, *mapped.domain, *run.results); + request.output_path, *mapped.domain, *run.results, identity); if (!write_diagnostics.empty()) { return {false, std::nullopt, std::move(write_diagnostics)}; } diff --git a/src/fesa/io/abaqus/parser.cpp b/src/fesa/io/abaqus/parser.cpp index 5234893..a838ed1 100644 --- a/src/fesa/io/abaqus/parser.cpp +++ b/src/fesa/io/abaqus/parser.cpp @@ -6,9 +6,12 @@ #include #include #include +#include #include +#include #include #include +#include #include #include #include @@ -50,6 +53,20 @@ std::string uppercase_ascii(std::string value) { return value; } +std::string input_fingerprint(const std::string_view bytes) { + std::uint64_t fingerprint = 14695981039346656037ULL; + for (const char byte : bytes) { + fingerprint ^= + static_cast(static_cast(byte)); + fingerprint *= 1099511628211ULL; + } + + std::ostringstream encoded; + encoded << "fnv1a64:" << std::hex << std::setfill('0') + << std::setw(16) << fingerprint; + return encoded.str(); +} + std::vector split_fields(const std::string_view value) { std::vector fields; std::size_t first = 0; @@ -374,14 +391,26 @@ ParseDeckResult missing_parameter( } // namespace ParseDeckResult parse_deck(const std::filesystem::path& path) { - std::ifstream input{path, std::ios::binary}; - if (!input) { + std::ifstream file{path, std::ios::binary}; + if (!file) { return failure( DiagnosticStage::io, "abaqus.io.open_failed", "Unable to open Abaqus input file.", SourceLocation{path, 0U, 0U}); } + const std::string source_bytes{ + std::istreambuf_iterator{file}, + std::istreambuf_iterator{}, + }; + if (file.bad()) { + return failure( + DiagnosticStage::io, + "abaqus.io.read_failed", + "Failed while reading Abaqus input file.", + SourceLocation{path, 0U, 0U}); + } + std::istringstream input{source_bytes}; ParsedDeck deck; Scope scope = Scope::global; @@ -846,7 +875,11 @@ ParseDeckResult parse_deck(const std::filesystem::path& path) { *current_step_source); } - return {std::move(deck), {}}; + return { + std::move(deck), + {}, + input_fingerprint(source_bytes), + }; } } // namespace fesa diff --git a/src/fesa/io/hdf5/writer.cpp b/src/fesa/io/hdf5/writer.cpp index 3cc444a..7869dba 100644 --- a/src/fesa/io/hdf5/writer.cpp +++ b/src/fesa/io/hdf5/writer.cpp @@ -1,6 +1,8 @@ #include #include +#include +#include #include @@ -11,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -33,6 +36,45 @@ constexpr std::string_view solver_matrix_type = constexpr std::string_view solver_constraint_method = "essential_dof_elimination"; constexpr std::string_view solver_assembly = "deterministic_serial"; +constexpr std::string_view fingerprint_prefix = "fnv1a64:"; + +[[nodiscard]] bool nearly_equal( + const double left, + const double right) noexcept { + const double scale = std::max({1.0, std::abs(left), std::abs(right)}); + return std::abs(left - right) <= + 256.0 * std::numeric_limits::epsilon() * scale; +} + +[[nodiscard]] bool same_frame( + const BeamFrame& left, + const BeamFrame& right) noexcept { + return nearly_equal(left.ex.x, right.ex.x) && + nearly_equal(left.ex.y, right.ex.y) && + nearly_equal(left.ex.z, right.ex.z) && + nearly_equal(left.ey.x, right.ey.x) && + nearly_equal(left.ey.y, right.ey.y) && + nearly_equal(left.ey.z, right.ey.z) && + nearly_equal(left.ez.x, right.ez.x) && + nearly_equal(left.ez.y, right.ez.y) && + nearly_equal(left.ez.z, right.ez.z); +} + +[[nodiscard]] bool valid_input_identity( + const Hdf5InputIdentity& identity) { + if ( + identity.source.empty() || + identity.fingerprint.size() != fingerprint_prefix.size() + 16U || + !identity.fingerprint.starts_with(fingerprint_prefix)) { + return false; + } + return std::ranges::all_of( + identity.fingerprint.substr(fingerprint_prefix.size()), + [](const char value) { + return (value >= '0' && value <= '9') || + (value >= 'a' && value <= 'f'); + }); +} class Hdf5Error final : public std::runtime_error { public: @@ -322,6 +364,171 @@ std::optional find_unknown_result_node( return std::nullopt; } +std::optional validate_result_contract( + const Domain& domain, + const ResultDatabase& database) { + if ( + database.steps.size() != 1U || + database.steps[0].name != domain.step().name) { + return error_diagnostic( + "hdf5.analysis_result_mismatch", + "HDF5 schema 2.0.0 requires one result step matching the " + "Domain step."); + } + if (database.steps[0].frames.size() != 1U) { + return error_diagnostic( + "hdf5.incomplete_result_frame", + "HDF5 schema 2.0.0 requires exactly one result frame."); + } + + std::unordered_set model_node_ids; + std::unordered_map model_nodes; + for (const Node& node : domain.nodes()) { + model_node_ids.insert(node.id.value()); + model_nodes.emplace(node.id.value(), &node); + } + if (const auto unknown = + find_unknown_result_node(database, model_node_ids)) { + return error_diagnostic( + "hdf5.result_node_not_in_model", + "Result node " + std::to_string(*unknown) + + " is not present in the serialized model."); + } + + std::unordered_map model_elements; + for (const BeamElement& element : domain.beam_elements()) { + model_elements.emplace(element.id.value(), &element); + } + for (const ResultFrame& frame : database.steps[0].frames) { + for (std::size_t index = 0; + index < frame.nodal.node_ids.size(); ++index) { + const auto found = + model_nodes.find(frame.nodal.node_ids[index].value()); + if ( + found == model_nodes.end() || + frame.nodal.origins[index] != found->second->origin) { + return error_diagnostic( + "hdf5.result_provenance_mismatch", + "Nodal result provenance does not match the model."); + } + } + for (const BeamElementFrame& beam : frame.element.beams) { + const auto found = model_elements.find(beam.element.value()); + if (found == model_elements.end()) { + return error_diagnostic( + "hdf5.result_element_not_in_model", + "Beam result references an element not present in " + "the serialized model."); + } + const BeamElement& element = *found->second; + const BeamSection& section = domain.section(element.section); + const BeamFrameResult expected_frame = make_beam_frame( + domain.node(element.nodes[0]).position, + domain.node(element.nodes[1]).position, + section.orientation); + if ( + beam.origin != element.origin || + beam.end_results[0].end_node != element.nodes[0] || + beam.end_results[1].end_node != element.nodes[1] || + beam.end_results[0].sigma_xx.size() != + section.recovery_points.size() || + !expected_frame.frame.has_value() || + !same_frame(beam.local_frame, *expected_frame.frame)) { + return error_diagnostic( + "hdf5.result_element_mismatch", + "Beam result provenance, connectivity, local frame, " + "or recovery points do not match the model."); + } + } + // Result-database validation guarantees unique IDs. The unknown-ID + // checks above plus equal counts therefore imply exact coverage. + if ( + frame.nodal.node_ids.size() != model_nodes.size() || + frame.element.beams.size() != model_elements.size()) { + return error_diagnostic( + "hdf5.incomplete_result_frame", + "Every result frame must contain exactly one nodal row " + "per model node and one Beam row per model element."); + } + } + return std::nullopt; +} + +Domain rebuild_domain( + const Hdf5ModelSnapshot& model, + const Hdf5AnalysisSnapshot& analysis) { + DomainBuilder builder; + std::vector> node_ids(model.nodes.size()); + for (const Hdf5NodeSnapshot& node : model.nodes) { + if ( + node.dense_index >= node_ids.size() || + node_ids[node.dense_index].has_value()) { + fail( + "hdf5.invalid_model_data", + "Serialized node dense indices are invalid."); + } + node_ids[node.dense_index] = node.id; + builder.add_node({node.id, node.origin, node.coordinates}); + } + for (const IsotropicElastic& material : model.materials) { + builder.add_material(material); + } + for (const Hdf5SectionSnapshot& section : model.sections) { + builder.add_section({ + section.id, + section.name, + section.area, + section.iy, + section.iz, + section.torsion_j, + section.shear_area_y, + section.shear_area_z, + section.shear_source, + section.orientation, + section.recovery_points, + }); + } + for (const Hdf5ElementSnapshot& element : model.elements) { + if ( + element.connectivity[0] >= node_ids.size() || + element.connectivity[1] >= node_ids.size() || + !node_ids[element.connectivity[0]].has_value() || + !node_ids[element.connectivity[1]].has_value()) { + fail( + "hdf5.invalid_model_data", + "Serialized element connectivity is invalid."); + } + builder.add_beam_element({ + element.id, + element.origin, + { + *node_ids[element.connectivity[0]], + *node_ids[element.connectivity[1]], + }, + element.material, + element.section, + }); + } + for (const NodeSet& set : model.node_sets) { + builder.add_node_set(set); + } + for (const ElementSet& set : model.element_sets) { + builder.add_element_set(set); + } + builder.set_step(analysis.step); + + DomainBuildResult built = std::move(builder).build(); + if (!built.domain.has_value()) { + std::string message = "Serialized model violates Domain rules."; + if (!built.diagnostics.empty()) { + message += " " + built.diagnostics.front().code + ": " + + built.diagnostics.front().message; + } + fail("hdf5.invalid_model_data", std::move(message)); + } + return std::move(*built.domain); +} + void write_string_attribute( Hdf5Context& context, const hid_t parent, @@ -2724,7 +2931,8 @@ ResultDatabase read_database( std::vector write_hdf5( const std::filesystem::path& path, const Domain& domain, - const ResultDatabase& database) { + const ResultDatabase& database, + const Hdf5InputIdentity& input_identity) { const Status validation = validate_result_database(database); if (!validation.succeeded) { return validation.diagnostics; @@ -2734,68 +2942,14 @@ std::vector write_hdf5( "hdf5.unsupported_schema", "HDF5 writer supports schema version 2.0.0 only.")}; } - if ( - database.steps.size() != 1U || - database.steps[0].name != domain.step().name) { + if (!valid_input_identity(input_identity)) { return {error_diagnostic( - "hdf5.analysis_result_mismatch", - "HDF5 schema 2.0.0 requires one result step matching the " - "Domain step.")}; + "hdf5.invalid_input_identity", + "HDF5 schema 2.0.0 requires an input source and a lowercase " + "FNV-1a 64-bit fingerprint.")}; } - std::unordered_set model_node_ids; - std::unordered_map model_nodes; - for (const Node& node : domain.nodes()) { - model_node_ids.insert(node.id.value()); - model_nodes.emplace(node.id.value(), &node); - } - if (const auto unknown = - find_unknown_result_node(database, model_node_ids)) { - return {error_diagnostic( - "hdf5.result_node_not_in_model", - "Result node " + std::to_string(*unknown) + - " is not present in the serialized model.")}; - } - std::unordered_map model_elements; - for (const BeamElement& element : domain.beam_elements()) { - model_elements.emplace(element.id.value(), &element); - } - for (const ResultStep& step : database.steps) { - for (const ResultFrame& frame : step.frames) { - for (std::size_t index = 0; - index < frame.nodal.node_ids.size(); ++index) { - const auto found = - model_nodes.find(frame.nodal.node_ids[index].value()); - if ( - found == model_nodes.end() || - frame.nodal.origins[index] != found->second->origin) { - return {error_diagnostic( - "hdf5.result_provenance_mismatch", - "Nodal result provenance does not match the model.")}; - } - } - for (const BeamElementFrame& beam : frame.element.beams) { - const auto found = model_elements.find(beam.element.value()); - if (found == model_elements.end()) { - return {error_diagnostic( - "hdf5.result_element_not_in_model", - "Beam result references an element not present in " - "the serialized model.")}; - } - const BeamElement& element = *found->second; - const BeamSection& section = domain.section(element.section); - if ( - beam.origin != element.origin || - beam.end_results[0].end_node != element.nodes[0] || - beam.end_results[1].end_node != element.nodes[1] || - beam.end_results[0].sigma_xx.size() != - section.recovery_points.size()) { - return {error_diagnostic( - "hdf5.result_element_mismatch", - "Beam result provenance, connectivity, or recovery " - "points do not match the model.")}; - } - } - } + if (const auto contract = validate_result_contract(domain, database)) { + return {*contract}; } Hdf5Context context; @@ -2819,6 +2973,13 @@ std::vector write_hdf5( context, file.get(), "fesa_version", version()); write_string_attribute( context, file.get(), "unit_policy", unit_policy); + write_string_attribute( + context, file.get(), "input_source", input_identity.source); + write_string_attribute( + context, + file.get(), + "input_fingerprint", + input_identity.fingerprint); write_model(context, file.get(), domain); write_analysis(context, file.get(), domain); write_results(context, file.get(), database); @@ -2862,39 +3023,37 @@ Hdf5ReadResult read_hdf5_results(const std::filesystem::path& path) { version, read_string_attribute(context, file.get(), "fesa_version"), read_string_attribute(context, file.get(), "unit_policy"), + read_string_attribute(context, file.get(), "input_source"), + read_string_attribute( + context, file.get(), "input_fingerprint"), }; if (metadata.unit_policy != unit_policy) { fail( "hdf5.read_failed", "HDF5 unit policy is not supported."); } + if (!valid_input_identity({ + metadata.input_source, + metadata.input_fingerprint, + })) { + fail( + "hdf5.invalid_input_identity", + "Serialized input identity is invalid."); + } Hdf5ModelSnapshot model = read_model(context, file.get()); Hdf5AnalysisSnapshot analysis = read_analysis(context, file.get(), model); + const Domain domain = rebuild_domain(model, analysis); ResultDatabase database = read_database(context, file.get(), version, model); - if ( - database.steps.size() != 1U || - database.steps[0].name != analysis.step.name) { - fail( - "hdf5.invalid_result_data", - "Result step does not match the analysis step."); - } const Status validation = validate_result_database(database); if (!validation.succeeded) { file.reset(); return {std::nullopt, validation.diagnostics, std::nullopt}; } - std::unordered_set model_node_ids; - for (const Hdf5NodeSnapshot& node : model.nodes) { - model_node_ids.insert(node.id.value()); - } - if (const auto unknown = - find_unknown_result_node(database, model_node_ids)) { - fail( - "hdf5.result_node_not_in_model", - "Result node " + std::to_string(*unknown) + - " is not present in the serialized model."); + if (const auto contract = + validate_result_contract(domain, database)) { + fail(contract->code, contract->message); } file.reset(); if (!context.close_error.empty()) { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1880b6f..be62c98 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -604,6 +604,12 @@ add_test( --gtest_filter=ElementFrame.* ) +add_test( + NAME ResultContractMetadata + COMMAND "$" + --gtest_filter=CompleteResultContract.* +) + add_executable(fesa_hdf5_results_tests integration/io/hdf5_results_test.cpp ) diff --git a/tests/integration/io/hdf5_results_test.cpp b/tests/integration/io/hdf5_results_test.cpp index 2e231f3..844dcf3 100644 --- a/tests/integration/io/hdf5_results_test.cpp +++ b/tests/integration/io/hdf5_results_test.cpp @@ -98,6 +98,63 @@ void replace_step_time_with_vector(const std::filesystem::path& path) { H5Awrite(attribute.get(), H5T_NATIVE_DOUBLE, values.data())); } +void delete_link( + const std::filesystem::path& path, + const std::string_view link_path) { + const std::string encoded_path = hdf5_path(path); + const std::string owned_link_path{link_path}; + TestHdf5Handle file{ + H5Fopen(encoded_path.c_str(), H5F_ACC_RDWR, H5P_DEFAULT), + &H5Fclose, + }; + require_hdf5_status( + H5Ldelete(file.get(), owned_link_path.c_str(), H5P_DEFAULT)); +} + +void copy_object( + const std::filesystem::path& path, + const std::string_view source_path, + const std::string_view target_path) { + const std::string encoded_path = hdf5_path(path); + const std::string owned_source_path{source_path}; + const std::string owned_target_path{target_path}; + TestHdf5Handle file{ + H5Fopen(encoded_path.c_str(), H5F_ACC_RDWR, H5P_DEFAULT), + &H5Fclose, + }; + require_hdf5_status(H5Ocopy( + file.get(), + owned_source_path.c_str(), + file.get(), + owned_target_path.c_str(), + H5P_DEFAULT, + H5P_DEFAULT)); +} + +void write_double_attribute( + const std::filesystem::path& path, + const std::string_view object_path, + const std::string_view attribute_name, + const double value) { + const std::string encoded_path = hdf5_path(path); + const std::string owned_object_path{object_path}; + const std::string owned_attribute_name{attribute_name}; + TestHdf5Handle file{ + H5Fopen(encoded_path.c_str(), H5F_ACC_RDWR, H5P_DEFAULT), + &H5Fclose, + }; + TestHdf5Handle object{ + H5Oopen(file.get(), owned_object_path.c_str(), H5P_DEFAULT), + &H5Oclose, + }; + TestHdf5Handle attribute{ + H5Aopen(object.get(), owned_attribute_name.c_str(), H5P_DEFAULT), + &H5Aclose, + }; + require_hdf5_status( + H5Awrite(attribute.get(), H5T_NATIVE_DOUBLE, &value)); +} + void write_int64_dataset( const std::filesystem::path& path, const std::string_view dataset_path, @@ -146,6 +203,30 @@ void write_double_dataset( values.data())); } +void write_uint8_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_UINT8, + H5S_ALL, + H5S_ALL, + H5P_DEFAULT, + values.data())); +} + void write_root_string_attribute( const std::filesystem::path& path, const std::string_view name, @@ -176,7 +257,15 @@ std::filesystem::path self_contained_path() { "Temporary" / "fesa-self-contained.h5"; } -fesa::Domain make_domain() { +const fesa::Hdf5InputIdentity& test_input_identity() { + static const fesa::Hdf5InputIdentity identity{ + "beam model.inp", + "fnv1a64:0123456789abcdef", + }; + return identity; +} + +fesa::Domain make_domain(const bool add_unreported_node = false) { fesa::DomainBuilder builder; builder.add_node({ fesa::NodeId{42}, @@ -186,8 +275,15 @@ fesa::Domain make_domain() { builder.add_node({ fesa::NodeId{7}, fesa::EntityOrigin{"BeamPart", "Beam-1", 1002}, - fesa::Vec3{4.0, 5.5, -6.25}, + fesa::Vec3{2.25, -2.5, 3.75}, }); + if (add_unreported_node) { + builder.add_node({ + fesa::NodeId{99}, + fesa::EntityOrigin{"BeamPart", "Beam-1", 1003}, + fesa::Vec3{8.0, 0.0, 0.0}, + }); + } builder.add_material({ fesa::MaterialId{6}, "Steel", @@ -257,8 +353,13 @@ fesa::Domain make_domain() { return std::move(*built.domain); } +fesa::BeamSectionResult make_end_result( + double xi, + fesa::NodeId node, + double offset); + fesa::ResultDatabase make_database() { - return { + fesa::ResultDatabase database{ "2.0.0", {{ "Load/Case", @@ -286,6 +387,35 @@ fesa::ResultDatabase make_database() { }}, }}, }; + database.steps[0].frames[0].element.beams = { + { + fesa::ElementId{9}, + {"BeamPart", "Beam-1", 2001}, + { + {-1.0, 0.0, 0.0}, + {0.0, 1.0, 0.0}, + {0.0, 0.0, -1.0}, + }, + { + make_end_result(-1.0, fesa::NodeId{7}, 0.0), + make_end_result(1.0, fesa::NodeId{42}, 100.0), + }, + }, + { + fesa::ElementId{17}, + {"BeamPart", "Beam-1", 2002}, + { + {1.0, 0.0, 0.0}, + {0.0, 1.0, 0.0}, + {0.0, 0.0, 1.0}, + }, + { + make_end_result(-1.0, fesa::NodeId{42}, 200.0), + make_end_result(1.0, fesa::NodeId{7}, 300.0), + }, + }, + }; + return database; } fesa::BeamSectionResult make_end_result( @@ -319,34 +449,6 @@ fesa::BeamSectionResult make_end_result( fesa::ResultDatabase make_complete_database() { fesa::ResultDatabase database = make_database(); auto& frame = database.steps[0].frames[0]; - frame.element.beams = { - { - fesa::ElementId{9}, - {"BeamPart", "Beam-1", 2001}, - { - {1.0, 0.0, 0.0}, - {0.0, 1.0, 0.0}, - {0.0, 0.0, 1.0}, - }, - { - make_end_result(-1.0, fesa::NodeId{7}, 0.0), - make_end_result(1.0, fesa::NodeId{42}, 100.0), - }, - }, - { - fesa::ElementId{17}, - {"BeamPart", "Beam-1", 2002}, - { - {-1.0, 0.0, 0.0}, - {0.0, 1.0, 0.0}, - {0.0, 0.0, -1.0}, - }, - { - make_end_result(-1.0, fesa::NodeId{42}, 200.0), - make_end_result(1.0, fesa::NodeId{7}, 300.0), - }, - }, - }; frame.diagnostics = { { fesa::DiagnosticStage::solver, @@ -385,8 +487,8 @@ TEST(ResultRoundTrip, PreservesMinimalSchemaModelAndNodalResults) { const auto domain = make_domain(); const auto database = make_database(); - const auto write_diagnostics = - fesa::write_hdf5(path, domain, database); + const auto write_diagnostics = fesa::write_hdf5( + path, domain, database, test_input_identity()); ASSERT_TRUE(write_diagnostics.empty()); const auto read = fesa::read_hdf5_results(path); @@ -439,9 +541,9 @@ TEST(ResultRoundTrip, PreservesMinimalSchemaModelAndNodalResults) { 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); + EXPECT_DOUBLE_EQ(read.model->nodes[1].coordinates.x, 2.25); + EXPECT_DOUBLE_EQ(read.model->nodes[1].coordinates.y, -2.5); + EXPECT_DOUBLE_EQ(read.model->nodes[1].coordinates.z, 3.75); ASSERT_EQ(read.model->elements.size(), 2U); EXPECT_EQ(read.model->elements[0].dense_index, 0U); @@ -477,9 +579,12 @@ TEST(SelfContainedHdf5, PublicReaderReconstructsCompletePhase1Contract) { std::filesystem::create_directories(path.parent_path()); std::filesystem::remove(path); - ASSERT_TRUE( - fesa::write_hdf5(path, make_domain(), make_complete_database()) - .empty()); + ASSERT_TRUE(fesa::write_hdf5( + path, + make_domain(), + make_complete_database(), + test_input_identity()) + .empty()); const fesa::Hdf5ReadResult read = fesa::read_hdf5_results(path); ASSERT_TRUE(read.diagnostics.empty()); @@ -493,6 +598,10 @@ TEST(SelfContainedHdf5, PublicReaderReconstructsCompletePhase1Contract) { EXPECT_EQ( read.metadata->unit_policy, "consistent_input_units_no_conversion"); + EXPECT_EQ(read.metadata->input_source, "beam model.inp"); + EXPECT_EQ( + read.metadata->input_fingerprint, + "fnv1a64:0123456789abcdef"); ASSERT_EQ(read.model->nodes.size(), 2U); ASSERT_EQ(read.model->elements.size(), 2U); @@ -547,7 +656,7 @@ TEST(SelfContainedHdf5, PublicReaderReconstructsCompletePhase1Contract) { const auto& frame = read.database->steps[0].frames[0]; ASSERT_EQ(frame.element.beams.size(), 2U); EXPECT_EQ(frame.element.beams[0].element, fesa::ElementId{9}); - EXPECT_DOUBLE_EQ(frame.element.beams[0].local_frame.ex.x, 1.0); + EXPECT_DOUBLE_EQ(frame.element.beams[0].local_frame.ex.x, -1.0); EXPECT_EQ( frame.element.beams[0].end_results[0].end_node, fesa::NodeId{7}); @@ -568,6 +677,73 @@ TEST(SelfContainedHdf5, PublicReaderReconstructsCompletePhase1Contract) { EXPECT_FALSE(frame.diagnostics[1].source.has_value()); } +TEST(SelfContainedHdf5, RejectsMissingResultFrameBeforeWriting) { + const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} / + "Testing" / "Temporary" / + "fesa-missing-result-frame.h5"; + std::filesystem::remove(path); + auto database = make_complete_database(); + database.steps[0].frames.clear(); + + const auto diagnostics = fesa::write_hdf5( + path, make_domain(), database, test_input_identity()); + + EXPECT_TRUE(has_results_error( + diagnostics, "hdf5.incomplete_result_frame")); + EXPECT_FALSE(std::filesystem::exists(path)); +} + +TEST(SelfContainedHdf5, RejectsIncompleteNodalCoverageBeforeWriting) { + const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} / + "Testing" / "Temporary" / + "fesa-incomplete-nodal-results.h5"; + std::filesystem::remove(path); + const auto database = make_complete_database(); + + const auto diagnostics = fesa::write_hdf5( + path, make_domain(true), database, test_input_identity()); + + EXPECT_TRUE(has_results_error( + diagnostics, "hdf5.incomplete_result_frame")); + EXPECT_FALSE(std::filesystem::exists(path)); +} + +TEST(SelfContainedHdf5, RejectsIncompleteBeamCoverageBeforeWriting) { + const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} / + "Testing" / "Temporary" / + "fesa-incomplete-beam-results.h5"; + std::filesystem::remove(path); + auto database = make_complete_database(); + database.steps[0].frames[0].element.beams.pop_back(); + + const auto diagnostics = fesa::write_hdf5( + path, make_domain(), database, test_input_identity()); + + EXPECT_TRUE(has_results_error( + diagnostics, "hdf5.incomplete_result_frame")); + EXPECT_FALSE(std::filesystem::exists(path)); +} + +TEST(SelfContainedHdf5, RejectsFiniteLocalFrameThatDisagreesWithModel) { + const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} / + "Testing" / "Temporary" / + "fesa-wrong-local-frame.h5"; + std::filesystem::remove(path); + auto database = make_complete_database(); + database.steps[0].frames[0].element.beams[0].local_frame = { + {1.0, 0.0, 0.0}, + {0.0, 1.0, 0.0}, + {0.0, 0.0, 1.0}, + }; + + const auto diagnostics = fesa::write_hdf5( + path, make_domain(), database, test_input_identity()); + + EXPECT_TRUE(has_results_error( + diagnostics, "hdf5.result_element_mismatch")); + EXPECT_FALSE(std::filesystem::exists(path)); +} + TEST(Hdf5, RejectsVersion1AfterMajorSchemaChange) { const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} / "Testing" / "Temporary" / "fesa-schema-1.h5"; @@ -575,19 +751,34 @@ TEST(Hdf5, RejectsVersion1AfterMajorSchemaChange) { auto database = make_database(); database.schema_version = "1.0.0"; - const auto diagnostics = - fesa::write_hdf5(path, make_domain(), database); + const auto diagnostics = fesa::write_hdf5( + path, make_domain(), database, test_input_identity()); EXPECT_TRUE(has_results_error(diagnostics, "hdf5.unsupported_schema")); EXPECT_FALSE(std::filesystem::exists(path)); } +TEST(Hdf5, RejectsMissingInputIdentityBeforeWriting) { + const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} / + "Testing" / "Temporary" / + "fesa-missing-input-identity.h5"; + std::filesystem::remove(path); + + const auto diagnostics = fesa::write_hdf5( + path, make_domain(), make_database(), {"", ""}); + + EXPECT_TRUE(has_results_error( + diagnostics, "hdf5.invalid_input_identity")); + EXPECT_FALSE(std::filesystem::exists(path)); +} + TEST(Hdf5, RejectsUnlistedMinorSchemaVersion) { const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} / "Testing" / "Temporary" / "fesa-schema-2-1.h5"; std::filesystem::remove(path); - ASSERT_TRUE( - fesa::write_hdf5(path, make_domain(), make_database()).empty()); + ASSERT_TRUE(fesa::write_hdf5( + path, make_domain(), make_database(), test_input_identity()) + .empty()); write_root_string_attribute(path, "schema_version", "2.1.0"); const auto read = fesa::read_hdf5_results(path); @@ -608,7 +799,8 @@ TEST(Hdf5, RejectsInvalidResultDatabaseBeforeWriting) { auto database = make_database(); database.steps[0].frames[0].nodal.reaction.pop_back(); - const auto diagnostics = fesa::write_hdf5(path, domain, database); + const auto diagnostics = + fesa::write_hdf5(path, domain, database, test_input_identity()); EXPECT_TRUE( has_results_error(diagnostics, "results.nodal_size_mismatch")); @@ -630,7 +822,8 @@ TEST(Hdf5, PreservesFrameDiagnosticsRepresentedBySchema) { std::nullopt, }); - const auto diagnostics = fesa::write_hdf5(path, domain, database); + const auto diagnostics = + fesa::write_hdf5(path, domain, database, test_input_identity()); ASSERT_TRUE(diagnostics.empty()); @@ -653,9 +846,14 @@ TEST(Hdf5, RejectsResultNodeMissingFromDomainBeforeWriting) { 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}; + auto& nodal = database.steps[0].frames[0].nodal; + nodal.node_ids.push_back(fesa::NodeId{999}); + nodal.origins.push_back({"BeamPart", "Beam-1", 1999}); + nodal.displacement.push_back({}); + nodal.reaction.push_back({}); - const auto diagnostics = fesa::write_hdf5(path, domain, database); + const auto diagnostics = + fesa::write_hdf5(path, domain, database, test_input_identity()); EXPECT_TRUE(has_results_error( diagnostics, "hdf5.result_node_not_in_model")); @@ -679,8 +877,8 @@ TEST(Hdf5, RejectsNonScalarStepTimeAttribute) { "Testing" / "Temporary" / "fesa-nonscalar-step-time.h5"; std::filesystem::remove(path); - const auto write_diagnostics = - fesa::write_hdf5(path, make_domain(), make_database()); + const auto write_diagnostics = fesa::write_hdf5( + path, make_domain(), make_database(), test_input_identity()); ASSERT_TRUE(write_diagnostics.empty()); replace_step_time_with_vector(path); @@ -691,19 +889,88 @@ TEST(Hdf5, RejectsNonScalarStepTimeAttribute) { EXPECT_TRUE(has_results_error(read.diagnostics, "hdf5.read_failed")); } +TEST(Hdf5, RejectsSerializedResultWithoutRequiredFrame) { + const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} / + "Testing" / "Temporary" / + "fesa-missing-serialized-frame.h5"; + std::filesystem::remove(path); + ASSERT_TRUE(fesa::write_hdf5( + path, make_domain(), make_database(), test_input_identity()) + .empty()); + delete_link(path, "/results/steps/0/frames/0"); + + const auto read = fesa::read_hdf5_results(path); + + EXPECT_FALSE(read.database.has_value()); + EXPECT_FALSE(read.model.has_value()); + EXPECT_FALSE(read.metadata.has_value()); + EXPECT_FALSE(read.analysis.has_value()); + EXPECT_TRUE(has_results_error( + read.diagnostics, "hdf5.incomplete_result_frame")); +} + +TEST(Hdf5, RejectsSerializedResultWithExtraFrame) { + const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} / + "Testing" / "Temporary" / + "fesa-extra-serialized-frame.h5"; + std::filesystem::remove(path); + ASSERT_TRUE(fesa::write_hdf5( + path, make_domain(), make_database(), test_input_identity()) + .empty()); + copy_object( + path, + "/results/steps/0/frames/0", + "/results/steps/0/frames/1"); + write_double_attribute( + path, "/results/steps/0/frames/1", "step_time", 2.5); + + const auto read = fesa::read_hdf5_results(path); + + EXPECT_FALSE(read.database.has_value()); + EXPECT_FALSE(read.model.has_value()); + EXPECT_FALSE(read.metadata.has_value()); + EXPECT_FALSE(read.analysis.has_value()); + EXPECT_TRUE(has_results_error( + read.diagnostics, "hdf5.incomplete_result_frame")); +} + +TEST(Hdf5, RejectsInvalidSerializedInputFingerprint) { + const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} / + "Testing" / "Temporary" / + "fesa-invalid-input-fingerprint.h5"; + std::filesystem::remove(path); + ASSERT_TRUE(fesa::write_hdf5( + path, make_domain(), make_database(), test_input_identity()) + .empty()); + write_root_string_attribute( + path, "input_fingerprint", "sha256:not-the-contract"); + + const auto read = fesa::read_hdf5_results(path); + + EXPECT_FALSE(read.database.has_value()); + EXPECT_FALSE(read.model.has_value()); + EXPECT_FALSE(read.metadata.has_value()); + EXPECT_FALSE(read.analysis.has_value()); + EXPECT_TRUE(has_results_error( + read.diagnostics, "hdf5.invalid_input_identity")); +} + 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()); + auto database = make_database(); + auto& nodal = database.steps[0].frames[0].nodal; + nodal.node_ids.push_back(fesa::NodeId{99}); + nodal.origins.push_back({"BeamPart", "Beam-1", 1003}); + nodal.displacement.push_back({}); + nodal.reaction.push_back({}); + const auto write_diagnostics = fesa::write_hdf5( + path, make_domain(true), database, test_input_identity()); 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 std::array node_ids{42, 7, 100}; + write_int64_dataset(path, "/model/nodes/internal_id", node_ids); const auto read = fesa::read_hdf5_results(path); @@ -718,8 +985,9 @@ TEST(Hdf5, RejectsDuplicateSerializedNodeIds) { "Testing" / "Temporary" / "fesa-duplicate-node-ids.h5"; std::filesystem::remove(path); - ASSERT_TRUE( - fesa::write_hdf5(path, make_domain(), make_database()).empty()); + ASSERT_TRUE(fesa::write_hdf5( + path, make_domain(), make_database(), test_input_identity()) + .empty()); const std::array ids{42, 42}; write_int64_dataset(path, "/model/nodes/internal_id", ids); @@ -736,8 +1004,9 @@ TEST(Hdf5, RejectsDuplicateSerializedElementIds) { "Testing" / "Temporary" / "fesa-duplicate-element-ids.h5"; std::filesystem::remove(path); - ASSERT_TRUE( - fesa::write_hdf5(path, make_domain(), make_database()).empty()); + ASSERT_TRUE(fesa::write_hdf5( + path, make_domain(), make_database(), test_input_identity()) + .empty()); const std::array ids{9, 9}; write_int64_dataset(path, "/model/elements/internal_id", ids); @@ -754,8 +1023,9 @@ TEST(Hdf5, RejectsDuplicateSerializedSectionIds) { "Testing" / "Temporary" / "fesa-duplicate-section-ids.h5"; std::filesystem::remove(path); - ASSERT_TRUE( - fesa::write_hdf5(path, make_domain(), make_database()).empty()); + ASSERT_TRUE(fesa::write_hdf5( + path, make_domain(), make_database(), test_input_identity()) + .empty()); const std::array ids{4, 4}; write_int64_dataset(path, "/model/sections/internal_id", ids); @@ -772,15 +1042,16 @@ TEST(Hdf5, RejectsNonfiniteSerializedCoordinates) { "Testing" / "Temporary" / "fesa-nonfinite-coordinates.h5"; std::filesystem::remove(path); - ASSERT_TRUE( - fesa::write_hdf5(path, make_domain(), make_database()).empty()); + ASSERT_TRUE(fesa::write_hdf5( + path, make_domain(), make_database(), test_input_identity()) + .empty()); const std::array coordinates{ std::numeric_limits::quiet_NaN(), -2.5, 3.75, - 4.0, - 5.5, - -6.25, + 2.25, + -2.5, + 3.75, }; write_double_dataset(path, "/model/nodes/coordinates", coordinates); @@ -792,13 +1063,128 @@ TEST(Hdf5, RejectsNonfiniteSerializedCoordinates) { has_results_error(read.diagnostics, "hdf5.invalid_model_data")); } +TEST(Hdf5, RejectsSerializedZeroLengthElement) { + const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} / + "Testing" / "Temporary" / + "fesa-zero-length-element.h5"; + std::filesystem::remove(path); + ASSERT_TRUE(fesa::write_hdf5( + path, make_domain(), make_database(), test_input_identity()) + .empty()); + const std::array coordinates{ + 1.25, -2.5, 3.75, 1.25, -2.5, 3.75}; + 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, RejectsSerializedOrientationParallelToElement) { + const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} / + "Testing" / "Temporary" / + "fesa-parallel-orientation.h5"; + std::filesystem::remove(path); + ASSERT_TRUE(fesa::write_hdf5( + path, make_domain(), make_database(), test_input_identity()) + .empty()); + const std::array orientations{ + 1.0, 0.0, 0.0, 0.0, 1.0, 0.0}; + write_double_dataset( + path, "/model/sections/orientation", orientations); + + 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, RejectsDuplicateSerializedNodeOrigins) { + const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} / + "Testing" / "Temporary" / + "fesa-duplicate-node-origins.h5"; + std::filesystem::remove(path); + ASSERT_TRUE(fesa::write_hdf5( + path, make_domain(), make_database(), test_input_identity()) + .empty()); + const std::array labels{1001, 1001}; + write_int64_dataset(path, "/model/nodes/local_label", labels); + + 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, RejectsDuplicateSerializedBoundaryConditions) { + const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} / + "Testing" / "Temporary" / + "fesa-duplicate-boundary-conditions.h5"; + std::filesystem::remove(path); + ASSERT_TRUE(fesa::write_hdf5( + path, make_domain(), make_database(), test_input_identity()) + .empty()); + const std::array node_ids{42, 42}; + const std::array dofs{1, 1}; + write_int64_dataset( + path, + "/analysis/steps/0/boundary_conditions/node_ids", + node_ids); + write_uint8_dataset( + path, "/analysis/steps/0/boundary_conditions/dofs", dofs); + + 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, RejectsSerializedLocalFrameThatDisagreesWithModel) { + const auto path = std::filesystem::path{FESA_TEST_BINARY_DIR} / + "Testing" / "Temporary" / + "fesa-serialized-wrong-local-frame.h5"; + std::filesystem::remove(path); + ASSERT_TRUE(fesa::write_hdf5( + path, make_domain(), make_database(), test_input_identity()) + .empty()); + const std::array local_frames{ + 1.0, 0.0, 0.0, + 0.0, 1.0, 0.0, + 0.0, 0.0, 1.0, + 1.0, 0.0, 0.0, + 0.0, 1.0, 0.0, + 0.0, 0.0, 1.0, + }; + write_double_dataset( + path, + "/results/steps/0/frames/0/element/beam/local_frame", + local_frames); + + 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_element_mismatch")); +} + 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()); + ASSERT_TRUE(fesa::write_hdf5( + path, make_domain(), make_database(), test_input_identity()) + .empty()); const std::array shear_areas{ std::numeric_limits::infinity(), 0.05, @@ -818,8 +1204,9 @@ TEST(Hdf5, RejectsNonpositiveSerializedShearArea) { "Testing" / "Temporary" / "fesa-nonpositive-shear-area.h5"; std::filesystem::remove(path); - ASSERT_TRUE( - fesa::write_hdf5(path, make_domain(), make_database()).empty()); + ASSERT_TRUE(fesa::write_hdf5( + path, make_domain(), make_database(), test_input_identity()) + .empty()); const std::array shear_areas{-0.031, 0.05}; write_double_dataset(path, "/model/sections/shear_area_y", shear_areas); diff --git a/tests/integration/pipeline/minimal_cantilever_test.cpp b/tests/integration/pipeline/minimal_cantilever_test.cpp index 3d6f6b4..e8851fb 100644 --- a/tests/integration/pipeline/minimal_cantilever_test.cpp +++ b/tests/integration/pipeline/minimal_cantilever_test.cpp @@ -69,6 +69,11 @@ std::string quote(const std::filesystem::path& path) { return '"' + path.string() + '"'; } +std::string path_utf8(const std::filesystem::path& path) { + const std::u8string value = path.u8string(); + return {reinterpret_cast(value.data()), value.size()}; +} + std::string read_text(const std::filesystem::path& path) { std::ifstream input{path, std::ios::binary}; return { @@ -93,8 +98,15 @@ TEST(MinimalCantileverPipeline, WritesReadableFiniteEquilibratedResults) { fesa::read_hdf5_results(output.path()); ASSERT_TRUE(read.database.has_value()); ASSERT_TRUE(read.model.has_value()); + ASSERT_TRUE(read.metadata.has_value()); EXPECT_TRUE(read.diagnostics.empty()); EXPECT_EQ(read.database->schema_version, "2.0.0"); + EXPECT_EQ( + read.metadata->input_source, + path_utf8(fixture_path("minimal_cantilever.inp"))); + EXPECT_EQ( + read.metadata->input_fingerprint, + "fnv1a64:73f31da4615f09b3"); ASSERT_EQ(read.model->nodes.size(), 2U); EXPECT_EQ(read.model->nodes[0].id, fesa::NodeId{0}); diff --git a/tests/unit/io/abaqus/parser_test.cpp b/tests/unit/io/abaqus/parser_test.cpp index c878faf..14156db 100644 --- a/tests/unit/io/abaqus/parser_test.cpp +++ b/tests/unit/io/abaqus/parser_test.cpp @@ -66,6 +66,9 @@ TEST(AbaqusParser, ParsesCaseInsensitiveKeywordsCommentsAndCommaFields) { ASSERT_TRUE(result.deck.has_value()); EXPECT_TRUE(result.diagnostics.empty()); + EXPECT_EQ( + result.input_fingerprint, + "fnv1a64:73f31da4615f09b3"); EXPECT_TRUE(result.deck->parts.empty()); EXPECT_FALSE(result.deck->assembly.has_value());