diff --git a/src/fesa/io/abaqus/domain_mapper.cpp b/src/fesa/io/abaqus/domain_mapper.cpp index 66a7867..27dc04c 100644 --- a/src/fesa/io/abaqus/domain_mapper.cpp +++ b/src/fesa/io/abaqus/domain_mapper.cpp @@ -48,12 +48,24 @@ struct RawNode { }; struct RawElement { + enum class Type { + b33, + s4, + s4r + }; + std::int64_t label; std::string labelText; - std::array nodeLabels; + Type type; + std::vector nodeLabels; SourceLocation location; }; +enum class ElementFamily { + beam, + shell +}; + struct RawSet { std::string name; std::vector members; @@ -69,6 +81,13 @@ struct RawSection { SourceLocation location; }; +struct RawShellSection { + std::string elementSetName; + std::string materialName; + double thickness; + SourceLocation location; +}; + struct RawPart { std::string name; SourceLocation location; @@ -77,6 +96,7 @@ struct RawPart { std::vector nodeSets; std::vector elementSets; std::vector sections; + std::vector shellSections; }; struct RawInstance { @@ -551,7 +571,7 @@ private: "Part names are unique under case-insensitive lookup."); return; } - parts_.push_back({*name, block.location, {}, {}, {}, {}, {}}); + parts_.push_back({*name, block.location, {}, {}, {}, {}, {}, {}}); currentPart_ = parts_.size() - 1U; partElementsSeen_ = false; partSetsSeen_ = false; @@ -565,11 +585,12 @@ private: return; } const RawPart& part = parts_[*currentPart_]; + const bool shellPart = modelElementFamily_ == ElementFamily::shell; if (part.nodes.empty() || part.elements.empty() || - part.sections.empty()) { + (!shellPart && part.sections.empty())) { invalidKeywordLocation( block, - "A part closes only after node, element, and beam-section blocks."); + "A part closes only after node, element, and matching section blocks."); return; } currentPart_.reset(); @@ -647,9 +668,33 @@ private: "BEAM GENERAL SECTION follows the part mesh and optional sets."); return; } + if (modelElementFamily_ == ElementFamily::shell) { + inputFailure( + "unsupported-mixed-element-model", block.location, + block.canonicalName, "", + "Beam-section semantics cannot be mixed with shell elements."); + return; + } parseBeamSection(block, part); partSectionsSeen_ = !failure_; beamSectionContextActive_ = !failure_; + } else if (block.canonicalName == "SHELL SECTION") { + beamSectionContextActive_ = false; + if (!partElementsSeen_) { + invalidKeywordLocation( + block, + "SHELL SECTION follows the part mesh and optional sets."); + return; + } + if (modelElementFamily_ == ElementFamily::beam) { + inputFailure( + "unsupported-mixed-element-model", block.location, + block.canonicalName, "", + "Shell-section semantics cannot be mixed with beam elements."); + return; + } + parseShellSection(block, part); + partSectionsSeen_ = !failure_; } else if (block.canonicalName == "SECTION POINTS") { parseSectionPoints(block, part); } else if (block.canonicalName == "TRANSVERSE SHEAR STIFFNESS") { @@ -726,42 +771,91 @@ private: if (type == nullptr) { return; } - if (!equalName(*type, "B33")) { + RawElement::Type elementType{}; + ElementFamily elementFamily{}; + std::size_t expectedFieldCount = 0U; + if (equalName(*type, "B33")) { + elementType = RawElement::Type::b33; + elementFamily = ElementFamily::beam; + expectedFieldCount = 3U; + } else if (equalName(*type, "S4")) { + elementType = RawElement::Type::s4; + elementFamily = ElementFamily::shell; + expectedFieldCount = 5U; + } else if (equalName(*type, "S4R")) { + elementType = RawElement::Type::s4r; + elementFamily = ElementFamily::shell; + expectedFieldCount = 5U; + } else { inputFailure( "unsupported-element-formulation", block.location, block.canonicalName, *type, - "Only TYPE=B33 maps to the V0 Euler beam."); + "Only TYPE=B33, TYPE=S4, and TYPE=S4R belong to the approved element subsets."); return; } + if (modelElementFamily_ && *modelElementFamily_ != elementFamily) { + inputFailure( + "unsupported-mixed-element-model", + block.location, + block.canonicalName, + *type, + "Beam and shell elements cannot be mixed in one model."); + return; + } + modelElementFamily_ = elementFamily; if (block.data.empty()) { inputFailure( - "invalid-data-arity", block.location, block.canonicalName, - "", "ELEMENT requires at least one three-field row."); + elementFamily == ElementFamily::shell + ? "invalid-shell-connectivity" + : "invalid-data-arity", + block.location, block.canonicalName, "", + "ELEMENT requires at least one row with the approved connectivity arity."); return; } for (const auto& row : block.data) { - if (row.fields.size() != 3U) { + if (row.fields.size() != expectedFieldCount) { inputFailure( - "invalid-data-arity", row.location, block.canonicalName, - "", "B33 rows require label, node 1, node 2."); + elementFamily == ElementFamily::shell + ? "invalid-shell-connectivity" + : "invalid-data-arity", + row.location, block.canonicalName, "", + elementFamily == ElementFamily::shell + ? "S4 and S4R rows require a label and exactly four nodes." + : "B33 rows require label, node 1, node 2."); return; } RawElement element{}; element.labelText = row.fields[0]; + element.type = elementType; element.location = row.location; if (!parseInteger( row.fields[0], element.label, row.location, - block.canonicalName, true) || - !parseInteger( - row.fields[1], element.nodeLabels[0], row.location, - block.canonicalName, true) || - !parseInteger( - row.fields[2], element.nodeLabels[1], row.location, block.canonicalName, true)) { return; } + element.nodeLabels.reserve(expectedFieldCount - 1U); + for (std::size_t field = 1U; field < expectedFieldCount; ++field) { + std::int64_t nodeLabel = 0; + if (!parseInteger( + row.fields[field], nodeLabel, row.location, + block.canonicalName, true)) { + return; + } + element.nodeLabels.push_back(nodeLabel); + } + if (elementFamily == ElementFamily::shell) { + const std::set distinctNodes{ + element.nodeLabels.begin(), element.nodeLabels.end()}; + if (distinctNodes.size() != element.nodeLabels.size()) { + inputFailure( + "invalid-shell-connectivity", row.location, + block.canonicalName, element.labelText, + "Shell connectivity requires four distinct source nodes."); + return; + } + } if (std::any_of( part.elements.begin(), part.elements.end(), [&element](const RawElement& existing) { @@ -777,6 +871,62 @@ private: } } + void parseShellSection(const KeywordBlock& block, RawPart& part) { + for (const auto& candidate : block.parameters) { + if (candidate.name != "ELSET" && candidate.name != "MATERIAL") { + inputFailure( + "unsupported-shell-section-option", block.location, + block.canonicalName, candidate.name, + "The shell-section option is outside the centered single-layer subset."); + return; + } + } + if (!validateParameters(block, {"ELSET", "MATERIAL"})) { + return; + } + const auto* elementSet = requiredParameterValue(block, "ELSET"); + const auto* material = requiredParameterValue(block, "MATERIAL"); + if (elementSet == nullptr || material == nullptr) { + return; + } + if (block.data.size() != 1U || block.data[0].fields.empty() || + block.data[0].fields.size() > 2U) { + inputFailure( + "unsupported-shell-section-option", block.location, + block.canonicalName, *elementSet, + "SHELL SECTION requires one thickness row with one optional integration-point field."); + return; + } + double thickness = 0.0; + if (!parseModelDouble( + block.data[0].fields[0], thickness, + block.data[0].location, block.canonicalName, + "invalid-shell-thickness", + "Shell thickness must be finite and positive.")) { + return; + } + if (!(thickness > 0.0)) { + modelFailure( + "invalid-shell-thickness", block.data[0].location, + block.canonicalName, *elementSet, + "Shell thickness must be finite and positive."); + return; + } + if (block.data[0].fields.size() == 2U) { + std::int64_t ignoredIntegrationPoints = 0; + if (!tryPositiveInteger( + block.data[0].fields[1], ignoredIntegrationPoints)) { + inputFailure( + "unsupported-shell-section-option", block.data[0].location, + block.canonicalName, block.data[0].fields[1], + "The optional shell integration-point field must be a positive integer."); + return; + } + } + part.shellSections.push_back( + {*elementSet, *material, thickness, block.location}); + } + bool parseSetMembers( const KeywordBlock& block, bool generate, @@ -1208,12 +1358,16 @@ private: if (!parseModelDouble( block.data[0].fields[0], material.youngsModulus, block.data[0].location, block.canonicalName, - "invalid-beam-property", + modelElementFamily_ == ElementFamily::shell + ? "invalid-shell-material" + : "invalid-beam-property", "Elastic material values must be finite.") || !parseModelDouble( block.data[0].fields[1], material.poissonRatio, block.data[0].location, block.canonicalName, - "invalid-beam-property", + modelElementFamily_ == ElementFamily::shell + ? "invalid-shell-material" + : "invalid-beam-property", "Elastic material values must be finite.")) { return; } @@ -1346,10 +1500,17 @@ private: return; } if (!equalName(*nlgeom->value, "NO")) { - modelFailure( - "unsupported-nonlinear-geometry", block.location, - block.canonicalName, *nlgeom->value, - "Only absent NLGEOM or NLGEOM=NO is supported."); + if (modelElementFamily_ == ElementFamily::shell) { + inputFailure( + "unsupported-nonlinear-geometry", block.location, + block.canonicalName, *nlgeom->value, + "Only absent NLGEOM or NLGEOM=NO is supported."); + } else { + modelFailure( + "unsupported-nonlinear-geometry", block.location, + block.canonicalName, *nlgeom->value, + "Only absent NLGEOM or NLGEOM=NO is supported."); + } return; } } @@ -1517,6 +1678,14 @@ private: } void rejectUnknown(const KeywordBlock& block) { + if (block.canonicalName == "DLOAD" && + modelElementFamily_ == ElementFamily::shell) { + inputFailure( + "unsupported-distributed-load", block.location, + block.canonicalName, "", + "Distributed, pressure, gravity, body, edge, and follower loads are unsupported."); + return; + } inputFailure( "unsupported-keyword", block.location, block.canonicalName, "", @@ -1615,6 +1784,23 @@ private: "A material must own exactly one ELASTIC row."); return; } + if (modelElementFamily_ == ElementFamily::shell) { + if (!(material.youngsModulus > 0.0) || + !(material.poissonRatio > -1.0) || + !(material.poissonRatio < 0.5)) { + modelFailure( + "invalid-shell-material", material.elasticLocation, + "ELASTIC", material.name, + "Shell isotropic elasticity requires E>0 and -1(definition_.shellSections.size()); + definition_.shellSections.push_back({ + section.elementSetName, + section.thickness, + *materialIndex, + section.location}); + for (const auto elementLabel : elementSet->members) { + if (!assignments.emplace( + elementLabel, + std::make_pair( + sectionIndex, *materialIndex)).second) { + inputFailure( + "invalid-shell-section-assignment", + section.location, "SHELL SECTION", + std::to_string(elementLabel), + "A shell element cannot receive multiple section assignments."); + return; + } + } + } + for (const auto& element : part.elements) { + if (assignments.find(element.label) == assignments.end()) { + inputFailure( + "invalid-shell-section-assignment", element.location, + "ELEMENT", element.labelText, + "Every shell element requires exactly one resolved section assignment."); + return; + } + } + continue; + } + auto& assignments = partSectionAssignments_[partIndex]; for (const auto& section : part.sections) { const auto* elementSet = findSet( @@ -1873,44 +2110,92 @@ private: instance.nodeMappings.push_back({rawNode.label, index}); } - const auto& assignments = - partSectionAssignments_[partIndex(*part)]; for (const auto& rawElement : part->elements) { - const auto first = nodeIndices.find(rawElement.nodeLabels[0]); - const auto second = nodeIndices.find(rawElement.nodeLabels[1]); - const auto assignment = assignments.find(rawElement.label); - if (first == nodeIndices.end() || second == nodeIndices.end() || - assignment == assignments.end()) { - inputFailure( - "unresolved-reference", rawElement.location, - "ELEMENT", rawElement.labelText, - "Expanded connectivity and section assignment must resolve."); - return; + EntityIndex index = 0U; + if (rawElement.type == RawElement::Type::b33) { + const auto first = nodeIndices.find(rawElement.nodeLabels[0]); + const auto second = nodeIndices.find(rawElement.nodeLabels[1]); + const auto& assignments = + partSectionAssignments_[partIndex(*part)]; + const auto assignment = assignments.find(rawElement.label); + if (first == nodeIndices.end() || second == nodeIndices.end() || + assignment == assignments.end()) { + inputFailure( + "unresolved-reference", rawElement.location, + "ELEMENT", rawElement.labelText, + "Expanded connectivity and section assignment must resolve."); + return; + } + if (definition_.elements.size() > + std::numeric_limits::max()) { + inputFailure( + "entity-index-overflow", rawElement.location, + "ELEMENT", rawElement.labelText, + "Expanded element count exceeds EntityIndex capacity."); + return; + } + index = static_cast(definition_.elements.size()); + const auto& section = + definition_.sections[assignment->second.first]; + if (!validateGeometry( + rawElement, + definition_.nodes[first->second], + definition_.nodes[second->second], + section)) { + return; + } + definition_.elements.push_back({ + {rawInstance.name, rawElement.label, rawElement.labelText}, + {first->second, second->second}, + assignment->second.second, + assignment->second.first, + rawElement.location}); + } else { + std::array connectedNodes{}; + for (std::size_t node = 0U; + node < connectedNodes.size(); + ++node) { + const auto found = + nodeIndices.find(rawElement.nodeLabels[node]); + if (found == nodeIndices.end()) { + inputFailure( + "invalid-shell-connectivity", rawElement.location, + "ELEMENT", rawElement.labelText, + "Expanded shell connectivity must resolve."); + return; + } + connectedNodes[node] = found->second; + } + const auto& assignments = + partShellSectionAssignments_[partIndex(*part)]; + const auto assignment = assignments.find(rawElement.label); + if (assignment == assignments.end()) { + inputFailure( + "invalid-shell-section-assignment", + rawElement.location, "ELEMENT", rawElement.labelText, + "Expanded shell section assignment must resolve exactly once."); + return; + } + if (definition_.shellElements.size() > + std::numeric_limits::max()) { + inputFailure( + "entity-index-overflow", rawElement.location, + "ELEMENT", rawElement.labelText, + "Expanded shell element count exceeds EntityIndex capacity."); + return; + } + index = + static_cast(definition_.shellElements.size()); + definition_.shellElements.push_back({ + {rawInstance.name, rawElement.label, rawElement.labelText}, + rawElement.type == RawElement::Type::s4 + ? ShellSourceElementType::s4 + : ShellSourceElementType::s4r, + connectedNodes, + assignment->second.second, + assignment->second.first, + rawElement.location}); } - if (definition_.elements.size() > - std::numeric_limits::max()) { - inputFailure( - "entity-index-overflow", rawElement.location, - "ELEMENT", rawElement.labelText, - "Expanded element count exceeds EntityIndex capacity."); - return; - } - const EntityIndex index = - static_cast(definition_.elements.size()); - const auto& section = definition_.sections[assignment->second.first]; - if (!validateGeometry( - rawElement, - definition_.nodes[first->second], - definition_.nodes[second->second], - section)) { - return; - } - definition_.elements.push_back({ - {rawInstance.name, rawElement.label, rawElement.labelText}, - {first->second, second->second}, - assignment->second.second, - assignment->second.first, - rawElement.location}); elementIndices.emplace(rawElement.label, index); instance.elementMappings.push_back({rawElement.label, index}); } @@ -2134,10 +2419,14 @@ private: std::vector>> partSectionAssignments_; + std::vector>> partShellSectionAssignments_; std::optional currentPart_; std::optional pendingSection_; std::optional materialEligible_; + std::optional modelElementFamily_; bool headingSeen_{false}; bool partElementsSeen_{false}; bool partSetsSeen_{false}; diff --git a/tests/unit/io/abaqus/domain_mapper_test.cpp b/tests/unit/io/abaqus/domain_mapper_test.cpp index 6c471ef..ca685f8 100644 --- a/tests/unit/io/abaqus/domain_mapper_test.cpp +++ b/tests/unit/io/abaqus/domain_mapper_test.cpp @@ -128,6 +128,55 @@ Tip, 2, -1. )inp"; } +std::string shellDeck() { + return R"inp(*Part, name=ShellPart +*Node +1, 0., 0., 0. +2, 1., 0., 0. +3, 1., 1., 0. +4, 0., 1., 0. +5, 2., 0., 0. +6, 2., 1., 0. +*Element, type=S4 +0010, 1, 2, 3, 4 +*Element, type=S4R +0020, 2, 5, 6, 3 +*Elset, elset=ShellS4 +10 +*Elset, elset=ShellS4R +20 +*Shell Section, elset=ShellS4, material=Steel +0.1, 5 +*Shell Section, elset=ShellS4R, material=Aluminum +0.2 +*End Part +*Assembly, name=Assembly +*Instance, name=First, part=ShellPart +*End Instance +*Instance, name=Second, part=ShellPart +*End Instance +*Nset, nset=RootFirst, instance=First +1 +*Nset, nset=TipSecond, instance=Second +6 +*End Assembly +*Material, name=Steel +*Elastic +210000., 0.3 +*Material, name=Aluminum +*Elastic +70000., 0.25 +*Boundary +RootFirst, 1, 6 +*Step, name=Load, nlgeom=NO +*Static +0.1, 1., 0.01, 1. +*Cload +TipSecond, 6, 1. +*End Step +)inp"; +} + std::string supportedInventoryDeck(bool includeNoOps) { const std::string preprint = includeNoOps ? "*Preprint, echo=NO, model=NO, history=NO, contact=NO\n" @@ -422,6 +471,188 @@ TEST(InpDomainMapping, NoOpAllowlistWarnsWithoutSemanticEffect) { plain.value().steps()[0].loads.size()); } +// MITC4-MAP-001 +TEST(InpDomainMapping, MapsS4AndS4rThroughOneMitc4Identity) { + auto result = mapText("mitc4-map-001", shellDeck()); + + ASSERT_TRUE(result.hasValue()); + const fesa::Domain& domain = result.value(); + EXPECT_TRUE(domain.elements().empty()); + ASSERT_EQ(domain.shellElements().size(), 4U); + EXPECT_EQ(domain.shellElements()[0].sourceId.instanceName, "First"); + EXPECT_EQ(domain.shellElements()[0].sourceId.sourceLabelText, "0010"); + EXPECT_EQ( + domain.shellElements()[0].sourceType, + fesa::ShellSourceElementType::s4); + EXPECT_EQ( + domain.shellElements()[0].nodeIndices, + (std::array{0U, 1U, 2U, 3U})); + EXPECT_EQ(domain.shellElements()[0].sectionIndex, 0U); + EXPECT_EQ(domain.shellElements()[0].materialIndex, 0U); + + EXPECT_EQ(domain.shellElements()[1].sourceId.instanceName, "First"); + EXPECT_EQ( + domain.shellElements()[1].sourceType, + fesa::ShellSourceElementType::s4r); + EXPECT_EQ( + domain.shellElements()[1].nodeIndices, + (std::array{1U, 4U, 5U, 2U})); + EXPECT_EQ(domain.shellElements()[1].sectionIndex, 1U); + EXPECT_EQ(domain.shellElements()[1].materialIndex, 1U); + + EXPECT_EQ(domain.shellElements()[2].sourceId.instanceName, "Second"); + EXPECT_EQ( + domain.shellElements()[2].nodeIndices, + (std::array{6U, 7U, 8U, 9U})); + EXPECT_EQ(domain.shellElements()[3].sourceId.instanceName, "Second"); + EXPECT_EQ( + fesa::kMitc4InternalFormulation, + std::string_view{"FESA-MITC4"}); + + ASSERT_EQ(domain.shellSections().size(), 2U); + EXPECT_EQ(domain.shellSections()[0].name, "ShellS4"); + EXPECT_DOUBLE_EQ(domain.shellSections()[0].thickness, 0.1); + EXPECT_EQ(domain.shellSections()[0].materialIndex, 0U); + EXPECT_EQ(domain.shellSections()[1].name, "ShellS4R"); + EXPECT_DOUBLE_EQ(domain.shellSections()[1].thickness, 0.2); + EXPECT_EQ(domain.shellSections()[1].materialIndex, 1U); + + ASSERT_EQ(domain.steps().size(), 1U); + ASSERT_EQ(domain.steps()[0].boundaries.size(), 1U); + EXPECT_EQ(domain.steps()[0].boundaries[0].lastDof, 6); + ASSERT_EQ(domain.steps()[0].loads.size(), 1U); + EXPECT_EQ(domain.steps()[0].loads[0].dof, 6); +} + +// MITC4-MAP-002 +TEST(InpDomainMapping, RejectsInvalidShellAssignmentsAndProperties) { + struct InvalidCase { + std::string name; + std::string deck; + std::string expectedCode; + fesa::FailureCategory category; + }; + + const std::string base = shellDeck(); + const std::vector cases{ + {"unresolved-material", + replaceOnce(base, "material=Steel", "material=Missing"), + "unresolved-shell-section", fesa::FailureCategory::input}, + {"unresolved-elset", + replaceOnce(base, "elset=ShellS4, material=Steel", + "elset=Missing, material=Steel"), + "unresolved-shell-section", fesa::FailureCategory::input}, + {"missing-assignment", + replaceOnce( + base, + "*Shell Section, elset=ShellS4R, material=Aluminum\n0.2\n", + ""), + "invalid-shell-section-assignment", fesa::FailureCategory::input}, + {"conflicting-assignment", + replaceOnce(base, "elset=ShellS4R, material=Aluminum", + "elset=ShellS4, material=Aluminum"), + "invalid-shell-section-assignment", fesa::FailureCategory::input}, + {"invalid-thickness", + replaceOnce(base, "0.2\n*End Part", "0.\n*End Part"), + "invalid-shell-thickness", fesa::FailureCategory::model}, + {"invalid-material", + replaceOnce(base, "70000., 0.25", "70000., 0.5"), + "invalid-shell-material", fesa::FailureCategory::model}}; + + for (const auto& testCase : cases) { + SCOPED_TRACE(testCase.name); + auto result = mapText("mitc4-map-002-" + testCase.name, testCase.deck); + ASSERT_FALSE(result.hasValue()); + EXPECT_EQ(result.status().failureCategory(), testCase.category); + ASSERT_NE(findDiagnostic(result.status(), testCase.expectedCode), nullptr); + } +} + +// MITC4-MAP-003 +TEST(InpDomainMapping, RejectsInvalidShellConnectivityOptionsAndMixedModels) { + struct InvalidCase { + std::string name; + std::string deck; + std::string expectedCode; + }; + + const std::string base = shellDeck(); + const std::vector cases{ + {"wrong-arity", replaceOnce(base, "0010, 1, 2, 3, 4", "0010, 1, 2, 3"), + "invalid-shell-connectivity"}, + {"repeated-node", replaceOnce(base, "0010, 1, 2, 3, 4", "0010, 1, 2, 2, 4"), + "invalid-shell-connectivity"}, + {"dangling-node", replaceOnce(base, "0010, 1, 2, 3, 4", "0010, 1, 2, 3, 99"), + "invalid-shell-connectivity"}, + {"unsupported-section-option", + replaceOnce(base, "material=Steel\n0.1", "material=Steel, offset=0.1\n0.1"), + "unsupported-shell-section-option"}, + {"unsupported-element", + replaceOnce(base, "type=S4R", "type=S8R"), + "unsupported-element-formulation"}, + {"mixed-beam-shell", + replaceOnce(base, + "0020, 2, 5, 6, 3\n*Elset", + "0020, 2, 5, 6, 3\n*Element, type=B33\n30, 1, 2\n*Elset"), + "unsupported-mixed-element-model"}}; + + for (const auto& testCase : cases) { + SCOPED_TRACE(testCase.name); + auto result = mapText("mitc4-map-003-" + testCase.name, testCase.deck); + ASSERT_FALSE(result.hasValue()); + EXPECT_EQ( + result.status().failureCategory(), + fesa::FailureCategory::input); + ASSERT_NE(findDiagnostic(result.status(), testCase.expectedCode), nullptr); + } +} + +// MITC4-MAP-004 +TEST(InpDomainMapping, PreservesProcedureLoadAndOutputRequestBoundariesForShells) { + const std::string withNoOps = replaceOnce( + shellDeck(), + "*End Step\n", + "*Output, field\n*Node Output\nU, RF\n*Element Output\nS\n*End Step\n"); + auto valid = mapText("mitc4-map-004-no-ops", withNoOps); + ASSERT_TRUE(valid.hasValue()); + ASSERT_EQ(valid.value().warnings().size(), 3U); + EXPECT_EQ(valid.value().warnings()[0].keyword, "OUTPUT"); + EXPECT_EQ(valid.value().warnings()[1].keyword, "NODE OUTPUT"); + EXPECT_EQ(valid.value().warnings()[2].keyword, "ELEMENT OUTPUT"); + + struct InvalidCase { + std::string name; + std::string deck; + std::string expectedCode; + }; + const std::string base = shellDeck(); + const std::vector cases{ + {"second-step", + base + "*Step\n*Static\n1., 1., 1., 1.\n*End Step\n", + "unsupported-multiple-step"}, + {"nonlinear-step", + replaceOnce(base, "nlgeom=NO", "nlgeom=YES"), + "unsupported-nonlinear-geometry"}, + {"other-procedure", + replaceOnce(base, "*Static\n0.1, 1., 0.01, 1.", + "*Dynamic\n0.1, 1., 0.01, 1."), + "unsupported-keyword"}, + {"distributed-load", + replaceOnce(base, "*Cload\nTipSecond, 6, 1.", + "*Dload\nShellS4, P, 1."), + "unsupported-distributed-load"}}; + + for (const auto& testCase : cases) { + SCOPED_TRACE(testCase.name); + auto result = mapText("mitc4-map-004-" + testCase.name, testCase.deck); + ASSERT_FALSE(result.hasValue()); + EXPECT_EQ( + result.status().failureCategory(), + fesa::FailureCategory::input); + ASSERT_NE(findDiagnostic(result.status(), testCase.expectedCode), nullptr); + } +} + TEST(InpDomainMapping, RejectsUnsupportedAndInvalidPortfolio) { struct InvalidCase { std::string name;