diff --git a/PLAN.md b/PLAN.md index 9395bfb..fbacc2a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -13,7 +13,7 @@ Every new agent session must read this file together with `PROGRESS.md` before p - If an item becomes obsolete, move it to `PROGRESS.md` with a short reason instead of silently deleting it. ## Current Objective -Continue the new Phase 1 rebaseline plan in `phases/1-linear-static-mitc4-rebaseline`, starting with P1R-03 parser/domain subset revalidation. The old `phases/1-linear-static-mitc4` path is historical and superseded by the paper-based MITC4 formulation reset. +Continue the new Phase 1 rebaseline plan in `phases/1-linear-static-mitc4-rebaseline`, starting with P1R-04 validation and singular diagnostic coverage. The old `phases/1-linear-static-mitc4` path is historical and superseded by the paper-based MITC4 formulation reset. ## Required Reading For New Agents 1. `AGENTS.md` @@ -36,7 +36,7 @@ Continue the new Phase 1 rebaseline plan in `phases/1-linear-static-mitc4-rebase ## Active Phase Files - Active phase directory: `phases/1-linear-static-mitc4-rebaseline` - Execute with: `python scripts/execute.py 1-linear-static-mitc4-rebaseline` -- Step numbering is zero-based. `step0.md` is complete and recorded in `phases/1-linear-static-mitc4-rebaseline/step0-audit.md`; `step1.md` is complete and created the `quad_02_phase1.inp` normalized reference path; `step2.md` is complete and revalidated core harness guardrails; `step15.md` is the independent evaluator closeout. +- Step numbering is zero-based. `step0.md` is complete and recorded in `phases/1-linear-static-mitc4-rebaseline/step0-audit.md`; `step1.md` is complete and created the `quad_02_phase1.inp` normalized reference path; `step2.md` is complete and revalidated core harness guardrails; `step3.md` is complete and revalidated the Phase 1 parser/domain subset; `step15.md` is the independent evaluator closeout. - Every step file contains a sprint contract with objective, required reading, scope, allowed files, explicit non-goals, tests to write first, reference artifacts, acceptance command, evaluator checklist, and handoff requirements. - Historical phase directory: `phases/1-linear-static-mitc4` - Historical phase status: blocked/superseded. Do not resume the old P1-15/P1-16 path unless the user explicitly requests recovery of that exact phase. @@ -74,7 +74,7 @@ Each gate should be satisfied before moving to the next implementation band unle |---|---|---|---| | G0 - Planning readiness | partial | Readiness task R-011 is resolved by `quad_02_phase1.inp`; R-010 and R-013 remain open. | Updated docs, PLAN.md, PROGRESS.md | | G1 - Build and validation | satisfied | Build system, test framework, and `scripts/validate_workspace.py` run real checks. | Validation command output | -| G2 - Parser and domain | pending rebaseline | Must be revalidated through steps 3 and 4 against the current parser subset and stored-reference compatibility policy. | Future parser and validation tests | +| G2 - Parser and domain | partial | Parser subset revalidated in step 3; validation and singular diagnostics remain for step 4. | Parser acceptance/rejection tests and validation output | | G3 - DOF/math/results infrastructure | partial | Core aliases, DOF mapping, validation harness, and model diagnostic context were revalidated in step 2; DofManager/results/assembly remain for steps 5, 6, and 12. | P1R-02 validation output | | G4 - MITC4 element readiness | reopened | MITC4 formulation was rewritten from local papers; element implementation must be rebuilt or revalidated through steps 7 through 11. | Revised `docs/MITC4_FORMULATION.md`, future element tests | | G5 - End-to-end solver | reopened | Linear static path must be revalidated through steps 13 and 14 after the MITC4 rebuild and `quad_02` compatibility path. | Future integration/reference regression output | @@ -84,7 +84,7 @@ All milestones are intended to become one or more self-contained sprint contract | ID | Status | Owner | Objective | Depends On | Acceptance Focus | |---|---|---|---|---|---| -| P1R-03 | pending | parser generator | Revalidate Phase 1 parser and immutable Domain subset. | none | Supported keywords accepted; unsupported features rejected | +| P1R-03 | completed | parser generator | Revalidate Phase 1 parser and immutable Domain subset. | none | Supported keywords accepted; unsupported features rejected | | P1R-04 | pending | validation generator | Rebuild validation and singular diagnostic coverage. | P1R-03 | Missing-reference and singular-prone negative tests | | P1R-05 | pending | DOF generator | Rebuild six-DOF DofManager, constrained/free mapping, equation numbering, and full-vector reconstruction. | none | DOF mapping and reaction foundation tests | | P1R-06 | pending | results generator | Rebuild minimum results model and displacement CSV comparator. | none | U/RF schema tests and CSV comparator tests | diff --git a/PROGRESS.md b/PROGRESS.md index c27b8ea..c22cc91 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -13,10 +13,38 @@ Every new agent session must read this file together with `PLAN.md` before plann - Do not remove history unless the user explicitly asks for archival cleanup. ## Current Status -Phase 1 has a new rebaseline phase definition in `phases/1-linear-static-mitc4-rebaseline`. Steps 0 through 2 are complete. `quad_02_phase1.inp` is now the normalized Phase 1-compatible input path for the stored `quad_02` S4 reference pair, while the original `quad_02.inp` remains preserved unsupported provenance. Core numeric aliases, DOF mapping, validation harness, and model diagnostic context have been revalidated. The old `phases/1-linear-static-mitc4` path is historical and superseded after the MITC4 formulation reset. +Phase 1 has a new rebaseline phase definition in `phases/1-linear-static-mitc4-rebaseline`. Steps 0 through 3 are complete. `quad_02_phase1.inp` is now the normalized Phase 1-compatible input path for the stored `quad_02` S4 reference pair, while the original `quad_02.inp` remains preserved unsupported provenance. Core numeric aliases, DOF mapping, validation harness, model diagnostic context, and the Phase 1 parser/domain subset have been revalidated. The old `phases/1-linear-static-mitc4` path is historical and superseded after the MITC4 formulation reset. ## Completed Work +### 2026-05-04 - P1R-03 parser/domain subset completed +Author: Codex + +Changed files: +- `include/fesa/fesa.hpp` +- `tests/test_main.cpp` +- `docs/ABAQUS_INPUT_SUBSET.md` +- `phases/1-linear-static-mitc4-rebaseline/index.json` +- `PLAN.md` +- `PROGRESS.md` + +Summary: +- Added parser acceptance coverage for repeated explicit sets, generated `*Nset` and `*Elset`, `D` exponent numeric input, keyword-line continuation, and every Phase 1 structural input keyword. +- Expanded unsupported-feature coverage for `S4R`, `Part`, `Assembly`, `Instance`, `*Include`, `*Density`, and `NLGEOM=YES`. +- Added negative coverage for unsupported parameters and flags on otherwise supported keywords, unsupported shell-section data modes, malformed numeric fields, and invalid DOF fields with file, line, and keyword diagnostics. +- Tightened the parser so supported keywords reject unknown parameters/flags, fixed-width data rows reject extra non-empty fields, generated sets require exactly `first,last,increment`, and keyword continuation lines are assembled before keyword parsing. +- Clarified `docs/ABAQUS_INPUT_SUBSET.md` so the strict parameter and fixed-width data-row rules are explicit. + +Verification: +- First ran `python scripts/validate_workspace.py` after adding unsupported-parameter tests; it failed as expected because the parser still accepted supported keywords with unknown controls. +- After implementing strict keyword-control rejection, validation passed. +- Added keyword-continuation tests and first observed the expected failure; after implementing continuation handling, `python scripts/validate_workspace.py` configured CMake, built `fesa_core` and `fesa_tests`, and ran CTest successfully. +- CTest result: 1 test executable passed. + +Follow-up: +- Continue with P1R-04 validation and singular diagnostic coverage. +- Keep `Part/Assembly/Instance`, `S4R`, `*Density`, and `NLGEOM=YES` outside the Phase 1 parser until an ADR and parser contract intentionally broaden the subset. + ### 2026-05-04 - P1R-02 core harness guardrails completed Author: Codex diff --git a/docs/ABAQUS_INPUT_SUBSET.md b/docs/ABAQUS_INPUT_SUBSET.md index 423ccc9..4f2c9e1 100644 --- a/docs/ABAQUS_INPUT_SUBSET.md +++ b/docs/ABAQUS_INPUT_SUBSET.md @@ -42,6 +42,8 @@ FESA parser rules: - Keyword continuation with a trailing comma should be supported for keyword lines. - Data continuation should be supported only where this document explicitly allows it. - Abbreviated Abaqus keywords are not supported in Phase 1. Require exact keyword names after case normalization. +- Supported keywords may use only the parameters and flags listed in this document. Unknown parameters and flags are errors, even when the keyword itself is supported. +- Fixed-width data rows for `*Node`, `*Element`, `*Elastic`, `*Shell Section`, `*Boundary`, and `*Cload` must not contain extra non-empty fields beyond the supported form. - Include files through `INPUT=` are not supported in Phase 1. - Part/Assembly/Instance syntax is not supported in Phase 1 unless added by ADR. - Normalized derivative inputs such as `references/quad_02_phase1.inp` may be used for Phase 1 parser and solver tests when the original stored Abaqus file contains unsupported Abaqus/CAE scaffolding. diff --git a/include/fesa/fesa.hpp b/include/fesa/fesa.hpp index 9154075..0c667f8 100644 --- a/include/fesa/fesa.hpp +++ b/include/fesa/fesa.hpp @@ -308,6 +308,9 @@ inline KeywordLine parseKeywordLine(const std::string& line) { keyword.name = lower(trim(pieces.front())); for (std::size_t i = 1; i < pieces.size(); ++i) { const std::string piece = trim(pieces[i]); + if (piece.empty()) { + continue; + } const auto eq = piece.find('='); if (eq == std::string::npos) { keyword.flags.insert(lower(piece)); @@ -337,9 +340,38 @@ class AbaqusInputParser { std::string current_material_key; KeywordLine current_shell_section; LocalIndex line_number = 0; + LocalIndex current_keyword_line = 0; auto add_error = [&](const std::string& code, const std::string& message) { - result.diagnostics.push_back({Severity::Error, code, message, {file_name, line_number, current.name}}); + const LocalIndex source_line = current_keyword_line == 0 ? line_number : current_keyword_line; + result.diagnostics.push_back({Severity::Error, code, message, {file_name, source_line, current.name}}); + }; + auto is_allowed = [](const std::string& value, std::initializer_list allowed_values) { + return std::any_of(allowed_values.begin(), allowed_values.end(), [&](const char* allowed) { + return value == allowed; + }); + }; + auto reject_unsupported_controls = [&](std::initializer_list allowed_parameters, + std::initializer_list allowed_flags) { + for (const auto& [parameter, value] : current.parameters) { + (void)value; + if (!is_allowed(parameter, allowed_parameters)) { + const LocalIndex source_line = current_keyword_line == 0 ? line_number : current_keyword_line; + result.diagnostics.push_back({Severity::Error, + "FESA-PARSE-UNSUPPORTED-PARAMETER", + "Unsupported *" + current.name + " parameter: " + parameter, + {file_name, source_line, current.name}}); + } + } + for (const std::string& flag : current.flags) { + if (!is_allowed(flag, allowed_flags)) { + const LocalIndex source_line = current_keyword_line == 0 ? line_number : current_keyword_line; + result.diagnostics.push_back({Severity::Error, + "FESA-PARSE-UNSUPPORTED-PARAMETER", + "Unsupported *" + current.name + " flag: " + flag, + {file_name, source_line, current.name}}); + } + } }; while (std::getline(stream, line)) { @@ -349,16 +381,52 @@ class AbaqusInputParser { continue; } if (!line.empty() && line.front() == '*') { - current = parseKeywordLine(line); - if (current.name == "node" || current.name == "element" || current.name == "nset" || - current.name == "elset" || current.name == "elastic" || current.name == "shell section" || - current.name == "boundary" || current.name == "cload" || current.name == "static") { - if (current.name == "shell section") { - current_shell_section = current; + current_keyword_line = line_number; + std::string keyword_line = line; + while (!keyword_line.empty() && keyword_line.back() == ',') { + std::string continuation; + if (!std::getline(stream, continuation)) { + break; } + ++line_number; + continuation = trim(continuation); + if (continuation.empty() || continuation.rfind("**", 0) == 0) { + continue; + } + keyword_line += continuation; + } + current = parseKeywordLine(keyword_line); + if (current.name == "node") { + reject_unsupported_controls({}, {}); + continue; + } + if (current.name == "element") { + reject_unsupported_controls({"type", "elset"}, {}); + continue; + } + if (current.name == "nset") { + reject_unsupported_controls({"nset"}, {"generate"}); + continue; + } + if (current.name == "elset") { + reject_unsupported_controls({"elset"}, {"generate"}); + continue; + } + if (current.name == "elastic") { + reject_unsupported_controls({}, {}); + continue; + } + if (current.name == "shell section") { + reject_unsupported_controls({"elset", "material"}, {}); + current_shell_section = current; + continue; + } + if (current.name == "boundary" || current.name == "cload" || current.name == "static") { + reject_unsupported_controls({}, {}); continue; } if (current.name == "material") { + reject_unsupported_controls({"name"}, {}); auto name_it = current.parameters.find("name"); if (name_it == current.parameters.end() || trim(name_it->second).empty()) { add_error("FESA-PARSE-MATERIAL-NAME", "*Material requires NAME"); @@ -376,6 +444,7 @@ class AbaqusInputParser { continue; } if (current.name == "step") { + reject_unsupported_controls({"name", "nlgeom"}, {}); auto nlgeom = current.parameters.find("nlgeom"); if (nlgeom != current.parameters.end() && lower(trim(nlgeom->second)) == "yes") { add_error("FESA-PARSE-UNSUPPORTED-NLGEOM", "NLGEOM=YES is not supported in Phase 1"); @@ -389,6 +458,7 @@ class AbaqusInputParser { continue; } if (current.name == "end step") { + reject_unsupported_controls({}, {}); continue; } add_error("FESA-PARSE-UNSUPPORTED-KEYWORD", "Unsupported keyword: *" + current.name); @@ -433,8 +503,16 @@ class AbaqusInputParser { } private: + static std::size_t effectiveFieldCount(const std::vector& fields) { + std::size_t count = fields.size(); + while (count > 0 && trim(fields[count - 1]).empty()) { + --count; + } + return count; + } + static void parseNode(const std::vector& fields, ParseResult& result, const std::string& file_name, LocalIndex line) { - if (fields.size() < 4) { + if (effectiveFieldCount(fields) != 4) { result.diagnostics.push_back({Severity::Error, "FESA-PARSE-NODE", "*Node data requires id,x,y,z", {file_name, line, "node"}}); return; } @@ -464,7 +542,7 @@ class AbaqusInputParser { result.diagnostics.push_back({Severity::Error, "FESA-PARSE-UNSUPPORTED-ELEMENT", "Unsupported element type: " + type_it->second, {file_name, line, "element"}}); return; } - if (fields.size() < 5) { + if (effectiveFieldCount(fields) != 5) { result.diagnostics.push_back({Severity::Error, "FESA-PARSE-ELEMENT", "S4 element requires id,n1,n2,n3,n4", {file_name, line, "element"}}); return; } @@ -524,7 +602,8 @@ class AbaqusInputParser { static void parseSetData(const std::vector& fields, bool generate, std::vector& output, std::vector& diagnostics, const std::string& file_name, LocalIndex line, const std::string& keyword) { if (generate) { - if (fields.size() < 3) { + const std::size_t field_count = effectiveFieldCount(fields); + if (field_count != 3) { diagnostics.push_back({Severity::Error, "FESA-PARSE-GENERATE", "Generated set requires first,last,increment", {file_name, line, keyword}}); return; } @@ -540,7 +619,9 @@ class AbaqusInputParser { } return; } - for (const std::string& field : fields) { + const std::size_t field_count = effectiveFieldCount(fields); + for (std::size_t i = 0; i < field_count; ++i) { + const std::string& field = fields[i]; if (trim(field).empty()) { continue; } @@ -558,10 +639,16 @@ class AbaqusInputParser { result.diagnostics.push_back({Severity::Error, "FESA-PARSE-ELASTIC-MATERIAL", "*Elastic must follow *Material", {file_name, line, "elastic"}}); return; } - if (fields.size() < 2) { + const std::size_t field_count = effectiveFieldCount(fields); + if (field_count < 2) { result.diagnostics.push_back({Severity::Error, "FESA-PARSE-ELASTIC", "*Elastic requires E,nu", {file_name, line, "elastic"}}); return; } + if (field_count > 2) { + result.diagnostics.push_back( + {Severity::Error, "FESA-PARSE-ELASTIC-UNSUPPORTED", "Only isotropic E,nu elastic data is supported", {file_name, line, "elastic"}}); + return; + } auto e = parseReal(fields[0]); auto nu = parseReal(fields[1]); if (!e || !nu || *e <= 0.0 || *nu <= -1.0 || *nu >= 0.5) { @@ -579,10 +666,18 @@ class AbaqusInputParser { result.diagnostics.push_back({Severity::Error, "FESA-PARSE-SHELL-SECTION-PARAM", "*Shell Section requires ELSET and MATERIAL", {file_name, line, "shell section"}}); return; } - if (fields.empty()) { + const std::size_t field_count = effectiveFieldCount(fields); + if (field_count == 0) { result.diagnostics.push_back({Severity::Error, "FESA-PARSE-SHELL-SECTION", "*Shell Section requires thickness", {file_name, line, "shell section"}}); return; } + if (field_count > 1) { + result.diagnostics.push_back({Severity::Error, + "FESA-PARSE-SHELL-SECTION-UNSUPPORTED", + "Only homogeneous shell thickness data is supported", + {file_name, line, "shell section"}}); + return; + } auto thickness = parseReal(fields[0]); if (!thickness || *thickness <= 0.0) { result.diagnostics.push_back({Severity::Error, "FESA-PARSE-SHELL-THICKNESS", "Shell thickness must be positive", {file_name, line, "shell section"}}); @@ -592,13 +687,21 @@ class AbaqusInputParser { } static void parseBoundary(const std::vector& fields, ParseResult& result, const std::string& file_name, LocalIndex line) { - if (fields.size() < 2) { + const std::size_t field_count = effectiveFieldCount(fields); + if (field_count < 2) { result.diagnostics.push_back({Severity::Error, "FESA-PARSE-BOUNDARY", "*Boundary requires target,first_dof", {file_name, line, "boundary"}}); return; } + if (field_count > 4) { + result.diagnostics.push_back({Severity::Error, + "FESA-PARSE-BOUNDARY-UNSUPPORTED", + "Only direct zero-valued boundary data is supported", + {file_name, line, "boundary"}}); + return; + } auto first = parseInt64(fields[1]); - auto last = fields.size() >= 3 && !fields[2].empty() ? parseInt64(fields[2]) : first; - auto magnitude = fields.size() >= 4 && !fields[3].empty() ? parseReal(fields[3]) : std::optional(0.0); + auto last = field_count >= 3 && !fields[2].empty() ? parseInt64(fields[2]) : first; + auto magnitude = field_count >= 4 && !fields[3].empty() ? parseReal(fields[3]) : std::optional(0.0); if (!first || !last || !magnitude || !dofFromAbaqus(static_cast(*first)) || !dofFromAbaqus(static_cast(*last)) || *first > *last) { result.diagnostics.push_back({Severity::Error, "FESA-PARSE-BOUNDARY-DOF", "Invalid boundary DOF range", {file_name, line, "boundary"}}); return; @@ -611,10 +714,18 @@ class AbaqusInputParser { } static void parseLoad(const std::vector& fields, ParseResult& result, const std::string& file_name, LocalIndex line) { - if (fields.size() < 3) { + const std::size_t field_count = effectiveFieldCount(fields); + if (field_count < 3) { result.diagnostics.push_back({Severity::Error, "FESA-PARSE-CLOAD", "*Cload requires target,dof,magnitude", {file_name, line, "cload"}}); return; } + if (field_count > 3) { + result.diagnostics.push_back({Severity::Error, + "FESA-PARSE-CLOAD-UNSUPPORTED", + "Only direct concentrated load data is supported", + {file_name, line, "cload"}}); + return; + } auto dof = parseInt64(fields[1]); auto magnitude = parseReal(fields[2]); if (!dof || !magnitude || !dofFromAbaqus(static_cast(*dof))) { diff --git a/phases/1-linear-static-mitc4-rebaseline/index.json b/phases/1-linear-static-mitc4-rebaseline/index.json index 0d9080c..6095598 100644 --- a/phases/1-linear-static-mitc4-rebaseline/index.json +++ b/phases/1-linear-static-mitc4-rebaseline/index.json @@ -5,7 +5,7 @@ { "step": 0, "name": "rebaseline-audit", "status": "completed" }, { "step": 1, "name": "reference-onboarding", "status": "completed" }, { "step": 2, "name": "core-harness-guardrails", "status": "completed" }, - { "step": 3, "name": "parser-domain-subset", "status": "pending" }, + { "step": 3, "name": "parser-domain-subset", "status": "completed" }, { "step": 4, "name": "validation-singular-diagnostics", "status": "pending" }, { "step": 5, "name": "dof-manager-reaction-foundation", "status": "pending" }, { "step": 6, "name": "results-comparator-foundation", "status": "pending" }, diff --git a/tests/test_main.cpp b/tests/test_main.cpp index f7f4ddb..310ba54 100644 --- a/tests/test_main.cpp +++ b/tests/test_main.cpp @@ -105,6 +105,25 @@ fesa::Domain parsedPhase1Domain() { return parsed.domain; } +const fesa::Diagnostic* findDiagnostic(const std::vector& diagnostics, const std::string& code) { + for (const auto& diagnostic : diagnostics) { + if (diagnostic.code == code) { + return &diagnostic; + } + } + return nullptr; +} + +std::size_t diagnosticCount(const std::vector& diagnostics, const std::string& code) { + std::size_t count = 0; + for (const auto& diagnostic : diagnostics) { + if (diagnostic.code == code) { + ++count; + } + } + return count; +} + } // namespace FESA_TEST(core_types_and_dof_mapping_are_stable) { @@ -144,24 +163,170 @@ FESA_TEST(parser_accepts_phase1_subset) { FESA_CHECK(parsed.domain.loads.size() == 2); } +FESA_TEST(parser_accepts_repeated_and_generated_sets) { + const std::string text = R"inp( +*Node +1, 0, 0, 0 +2, 1, 0, 0 +3, 1, 1, 0 +4, 0, 1, 0 +*Element, type=S4, elset=EALL +1, 1, 2, 3, 4 +*Nset, nset=FIXED +1, 2, 2 +3 +*Nset, nset=FIXED, generate +3, 4, 1 +*Nset, nset=LOADS, generate +2, 4, 2 +*Elset, elset=EALL +1, 1 +*Elset, elset=CHECK, generate +1, 5, 2 +*Material, name=MAT +*Elastic +2.0D5, 0.25 +*Shell Section, elset=EALL, material=MAT +0.2 +*Boundary +FIXED, 1, 6 +*Cload +LOADS, 3, -2.5 +*Step, name=Step-A, nlgeom=NO +*Static +*End Step +)inp"; + fesa::AbaqusInputParser parser; + auto parsed = parser.parseString(text); + FESA_CHECK(parsed.ok()); + FESA_CHECK(parsed.domain.node_sets.at("fixed").node_ids == std::vector({1, 2, 3, 4})); + FESA_CHECK(parsed.domain.node_sets.at("loads").node_ids == std::vector({2, 4})); + FESA_CHECK(parsed.domain.element_sets.at("eall").element_ids == std::vector({1})); + FESA_CHECK(parsed.domain.element_sets.at("check").element_ids == std::vector({1, 3, 5})); + FESA_CHECK(parsed.domain.materials.at("mat").elastic_modulus == 2.0e5); + FESA_CHECK(parsed.domain.steps.front().name == "Step-A"); +} + +FESA_TEST(parser_accepts_keyword_line_continuation) { + const std::string text = R"inp( +*Node +1, 0, 0, 0 +2, 1, 0, 0 +3, 1, 1, 0 +4, 0, 1, 0 +*Element, + type=S4, elset=EALL +1, 1, 2, 3, 4 +*Nset, + nset=FIXED, generate +1, 4, 3 +*Elset, + elset=EALL +1 +*Material, + name=MAT +*Elastic +2.0e5, 0.25 +*Shell Section, + elset=EALL, material=MAT +0.2 +*Boundary +FIXED, 1, 6 +*Cload +2, 3, -1.0 +*Step, + name=Step-1 +*Static +*End Step +)inp"; + fesa::AbaqusInputParser parser; + auto parsed = parser.parseString(text); + FESA_CHECK(parsed.ok()); + FESA_CHECK(parsed.domain.elements.at(1).source_elset == "EALL"); + FESA_CHECK(parsed.domain.node_sets.at("fixed").node_ids == std::vector({1, 4})); + FESA_CHECK(parsed.domain.materials.count("mat") == 1); + FESA_CHECK(parsed.domain.shell_sections.front().material == "MAT"); +} + FESA_TEST(parser_rejects_unsupported_features) { const std::string text = R"inp( *Part, name=P1 +*Assembly, name=A1 +*Instance, name=I1, part=P1 +*Include, input=other.inp *Node 1, 0, 0, 0 *Element, type=S4R 1, 1, 2, 3, 4 +*Density +7850 *Step, nlgeom=YES *End Step )inp"; fesa::AbaqusInputParser parser; auto parsed = parser.parseString(text); FESA_CHECK(!parsed.ok()); + FESA_CHECK(diagnosticCount(parsed.diagnostics, "FESA-PARSE-UNSUPPORTED-KEYWORD") >= 4); FESA_CHECK(fesa::containsDiagnostic(parsed.diagnostics, "FESA-PARSE-UNSUPPORTED-KEYWORD")); FESA_CHECK(fesa::containsDiagnostic(parsed.diagnostics, "FESA-PARSE-UNSUPPORTED-ELEMENT")); FESA_CHECK(fesa::containsDiagnostic(parsed.diagnostics, "FESA-PARSE-UNSUPPORTED-NLGEOM")); } +FESA_TEST(parser_rejects_unsupported_keyword_parameters_and_modes) { + const std::string text = R"inp( +*Node, input=nodes.csv +1, 0, 0, 0 +2, 1, 0, 0 +3, 1, 1, 0 +4, 0, 1, 0 +*Element, type=S4, elset=EALL, orientation=OR1 +1, 1, 2, 3, 4 +*Nset, nset=FIXED, unsorted +1, 4 +*Material, name=MAT, description=bad +*Elastic, type=ENGINEERING CONSTANTS +2.0e5, 0.25 +*Shell Section, elset=EALL, material=MAT, offset=SPOS +0.2, 5 +*Boundary, op=NEW +FIXED, 1, 6 +*Cload, amplitude=A1 +2, 3, -1.0 +*Step, name=Step-1, inc=100 +*Static, stabilize +*End Step +)inp"; + fesa::AbaqusInputParser parser; + auto parsed = parser.parseString(text, "unsupported_modes.inp"); + FESA_CHECK(!parsed.ok()); + FESA_CHECK(diagnosticCount(parsed.diagnostics, "FESA-PARSE-UNSUPPORTED-PARAMETER") >= 8); + FESA_CHECK(fesa::containsDiagnostic(parsed.diagnostics, "FESA-PARSE-SHELL-SECTION-UNSUPPORTED")); +} + +FESA_TEST(parser_diagnostics_include_file_line_and_keyword) { + const std::string text = R"inp( +*Node +1, bad, 0, 0 +*Boundary +FIXED, 7, 7 +)inp"; + fesa::AbaqusInputParser parser; + auto parsed = parser.parseString(text, "malformed.inp"); + FESA_CHECK(!parsed.ok()); + + const fesa::Diagnostic* node = findDiagnostic(parsed.diagnostics, "FESA-PARSE-NODE-NUMERIC"); + FESA_CHECK(node != nullptr); + FESA_CHECK(node->source.file == "malformed.inp"); + FESA_CHECK(node->source.line == 3); + FESA_CHECK(node->source.keyword == "node"); + + const fesa::Diagnostic* boundary = findDiagnostic(parsed.diagnostics, "FESA-PARSE-BOUNDARY-DOF"); + FESA_CHECK(boundary != nullptr); + FESA_CHECK(boundary->source.file == "malformed.inp"); + FESA_CHECK(boundary->source.line == 5); + FESA_CHECK(boundary->source.keyword == "boundary"); +} + FESA_TEST(quad01_reference_input_remains_unsupported) { fesa::AbaqusInputParser parser; auto parsed = parser.parseFile(sourceRoot() + "/references/quad_01.inp");