diff --git a/CMakeLists.txt b/CMakeLists.txt index e86164a..1c31d3a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -27,6 +27,7 @@ include(cmake/FesaDependencies.cmake) add_library(fesa_core STATIC src/fesa/core/version.cpp + src/fesa/io/abaqus/parser.cpp src/fesa/model/domain.cpp src/fesa/model/domain_builder.cpp ) diff --git a/include/fesa/io/abaqus/deck_record.hpp b/include/fesa/io/abaqus/deck_record.hpp new file mode 100644 index 0000000..d685f3d --- /dev/null +++ b/include/fesa/io/abaqus/deck_record.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +namespace fesa { + +struct DeckRecord final { + std::string keyword; + std::map> parameters; + std::vector> data; + SourceLocation source; +}; + +struct ParsedPart final { + std::string name; + std::vector records; + SourceLocation source; +}; + +struct ParsedInstance final { + std::string name; + std::string part_name; + std::vector> transform_data; + SourceLocation source; +}; + +struct ParsedAssembly final { + std::string name; + std::vector instances; + std::vector records; + SourceLocation source; +}; + +struct ParsedDeck final { + std::vector global_records; + std::vector parts; + std::optional assembly; +}; + +} // namespace fesa diff --git a/include/fesa/io/abaqus/parser.hpp b/include/fesa/io/abaqus/parser.hpp new file mode 100644 index 0000000..29a9fa4 --- /dev/null +++ b/include/fesa/io/abaqus/parser.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include +#include +#include + +#include +#include + +namespace fesa { + +struct ParseDeckResult final { + std::optional deck; + std::vector diagnostics; +}; + +[[nodiscard]] ParseDeckResult parse_deck( + const std::filesystem::path& path); + +} // namespace fesa diff --git a/phases/index.json b/phases/index.json index 2f65cf4..b72bee6 100644 --- a/phases/index.json +++ b/phases/index.json @@ -42,4 +42,4 @@ "status": "pending" } ] -} \ No newline at end of file +} diff --git a/src/fesa/io/abaqus/parser.cpp b/src/fesa/io/abaqus/parser.cpp new file mode 100644 index 0000000..55b2c53 --- /dev/null +++ b/src/fesa/io/abaqus/parser.cpp @@ -0,0 +1,411 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fesa { +namespace { + +enum class Scope { global, part, assembly, instance }; + +struct KeywordLine final { + std::string keyword; + std::map> parameters; + SourceLocation source; +}; + +std::string_view trim(const std::string_view value) { + constexpr std::string_view whitespace{" \t\f\v\r\n"}; + const std::size_t first = value.find_first_not_of(whitespace); + if (first == std::string_view::npos) { + return {}; + } + const std::size_t last = value.find_last_not_of(whitespace); + return value.substr(first, last - first + 1); +} + +std::string uppercase_ascii(std::string value) { + std::ranges::transform(value, value.begin(), [](const char character) { + if (character >= 'a' && character <= 'z') { + return static_cast(character - 'a' + 'A'); + } + return character; + }); + return value; +} + +std::vector split_fields(const std::string_view value) { + std::vector fields; + std::size_t first = 0; + while (true) { + const std::size_t comma = value.find(',', first); + const std::string_view field = comma == std::string_view::npos + ? value.substr(first) + : value.substr(first, comma - first); + fields.emplace_back(trim(field)); + if (comma == std::string_view::npos) { + break; + } + first = comma + 1; + } + return fields; +} + +ParseDeckResult failure( + const DiagnosticStage stage, + std::string code, + std::string message, + std::optional source) { + std::vector diagnostics; + diagnostics.push_back({ + stage, + Severity::error, + std::move(code), + std::move(message), + std::move(source), + }); + return {std::nullopt, std::move(diagnostics)}; +} + +ParseDeckResult syntax_failure( + std::string code, + std::string message, + SourceLocation source) { + return failure( + DiagnosticStage::syntax, + std::move(code), + std::move(message), + std::move(source)); +} + +bool is_supported_record(const std::string_view keyword) { + constexpr std::array supported{ + std::string_view{"BEAM GENERAL SECTION"}, + std::string_view{"BOUNDARY"}, + std::string_view{"CLOAD"}, + std::string_view{"ELASTIC"}, + std::string_view{"ELEMENT"}, + std::string_view{"ELSET"}, + std::string_view{"END STEP"}, + std::string_view{"MATERIAL"}, + std::string_view{"NODE"}, + std::string_view{"NSET"}, + std::string_view{"STATIC"}, + std::string_view{"STEP"}, + std::string_view{"TRANSVERSE SHEAR STIFFNESS"}, + }; + return std::ranges::find(supported, keyword) != supported.end(); +} + +std::optional parse_keyword_line( + const std::string_view line, + const SourceLocation& source, + ParseDeckResult& error) { + const std::vector fields = split_fields(line.substr(1)); + if (fields.empty() || fields[0].empty()) { + error = syntax_failure( + "abaqus.syntax.empty_keyword", + "Abaqus keyword name is empty.", + source); + return std::nullopt; + } + + KeywordLine parsed{ + uppercase_ascii(fields[0]), + {}, + source, + }; + for (std::size_t index = 1; index < fields.size(); ++index) { + const std::string_view field = fields[index]; + if (field.empty()) { + continue; + } + + const std::size_t equals = field.find('='); + const std::string_view key_text = + trim(field.substr(0, equals)); + const std::string_view value_text = + equals == std::string_view::npos + ? std::string_view{} + : trim(field.substr(equals + 1)); + if (key_text.empty()) { + error = syntax_failure( + "abaqus.syntax.invalid_parameter", + "Abaqus keyword parameter name is empty.", + source); + return std::nullopt; + } + + const std::string key = uppercase_ascii(std::string{key_text}); + if (!parsed.parameters.emplace(key, value_text).second) { + error = syntax_failure( + "abaqus.syntax.duplicate_parameter", + "Abaqus keyword parameter '" + key + + "' is specified more than once.", + source); + return std::nullopt; + } + } + return parsed; +} + +const std::string* parameter( + const KeywordLine& keyword, + const std::string_view name) { + const auto found = keyword.parameters.find(name); + return found == keyword.parameters.end() ? nullptr : &found->second; +} + +ParseDeckResult missing_parameter( + const KeywordLine& keyword, + const std::string_view parameter_name) { + return syntax_failure( + "abaqus.syntax.missing_parameter", + "Abaqus *" + keyword.keyword + " requires parameter " + + std::string{parameter_name} + ".", + keyword.source); +} + +} // namespace + +ParseDeckResult parse_deck(const std::filesystem::path& path) { + std::ifstream input{path, std::ios::binary}; + if (!input) { + return failure( + DiagnosticStage::io, + "abaqus.io.open_failed", + "Unable to open Abaqus input file.", + SourceLocation{path, 0U, 0U}); + } + + ParsedDeck deck; + Scope scope = Scope::global; + std::optional current_part; + std::optional current_assembly; + std::optional current_instance; + DeckRecord* current_record = nullptr; + + std::string line; + std::size_t line_number = 0; + while (std::getline(input, line)) { + ++line_number; + if (line_number == 1U && line.starts_with("\xEF\xBB\xBF")) { + line.erase(0, 3); + } + + const std::size_t first_nonspace = + line.find_first_not_of(" \t\f\v\r"); + if (first_nonspace == std::string::npos) { + continue; + } + const std::string_view content = trim(line); + if (content.starts_with("**")) { + continue; + } + + if (!content.starts_with('*')) { + const std::vector fields = split_fields(content); + if (scope == Scope::instance) { + current_instance->transform_data.push_back(fields); + } else if (current_record != nullptr) { + current_record->data.push_back(fields); + } else { + return syntax_failure( + "abaqus.syntax.data_without_keyword", + "Abaqus data line has no preceding keyword record.", + SourceLocation{path, line_number, first_nonspace + 1U}); + } + continue; + } + + const SourceLocation source{ + path, + line_number, + first_nonspace + 1U, + }; + ParseDeckResult keyword_error; + std::optional parsed = + parse_keyword_line(content, source, keyword_error); + if (!parsed.has_value()) { + return keyword_error; + } + KeywordLine& keyword = *parsed; + current_record = nullptr; + + if (keyword.keyword == "PART") { + if (scope != Scope::global) { + return syntax_failure( + "abaqus.syntax.invalid_part_scope", + "*PART is only valid in global input scope.", + source); + } + const std::string* name = parameter(keyword, "NAME"); + if (name == nullptr || name->empty()) { + return missing_parameter(keyword, "NAME"); + } + current_part = ParsedPart{*name, {}, source}; + scope = Scope::part; + continue; + } + + if (keyword.keyword == "END PART") { + if (scope != Scope::part || !current_part.has_value()) { + return syntax_failure( + "abaqus.syntax.unexpected_end_part", + "*END PART does not match an open *PART.", + source); + } + deck.parts.push_back(std::move(*current_part)); + current_part.reset(); + scope = Scope::global; + continue; + } + + if (keyword.keyword == "ASSEMBLY") { + if (scope != Scope::global) { + return syntax_failure( + "abaqus.syntax.invalid_assembly_scope", + "*ASSEMBLY is only valid in global input scope.", + source); + } + if (deck.assembly.has_value() || + current_assembly.has_value()) { + return syntax_failure( + "abaqus.syntax.multiple_assemblies", + "ParsedDeck can preserve only one *ASSEMBLY.", + source); + } + const std::string* name = parameter(keyword, "NAME"); + if (name == nullptr || name->empty()) { + return missing_parameter(keyword, "NAME"); + } + current_assembly = ParsedAssembly{*name, {}, {}, source}; + scope = Scope::assembly; + continue; + } + + if (keyword.keyword == "END ASSEMBLY") { + if (scope != Scope::assembly || + !current_assembly.has_value()) { + return syntax_failure( + "abaqus.syntax.unexpected_end_assembly", + "*END ASSEMBLY does not match an open *ASSEMBLY.", + source); + } + deck.assembly = std::move(*current_assembly); + current_assembly.reset(); + scope = Scope::global; + continue; + } + + if (keyword.keyword == "INSTANCE") { + if (scope != Scope::assembly || + !current_assembly.has_value()) { + return syntax_failure( + "abaqus.syntax.invalid_instance_scope", + "*INSTANCE is only valid in an open *ASSEMBLY.", + source); + } + const std::string* name = parameter(keyword, "NAME"); + if (name == nullptr || name->empty()) { + return missing_parameter(keyword, "NAME"); + } + const std::string* part_name = parameter(keyword, "PART"); + if (part_name == nullptr || part_name->empty()) { + return missing_parameter(keyword, "PART"); + } + current_instance = + ParsedInstance{*name, *part_name, {}, source}; + scope = Scope::instance; + continue; + } + + if (keyword.keyword == "END INSTANCE") { + if (scope != Scope::instance || + !current_instance.has_value() || + !current_assembly.has_value()) { + return syntax_failure( + "abaqus.syntax.unexpected_end_instance", + "*END INSTANCE does not match an open *INSTANCE.", + source); + } + current_assembly->instances.push_back( + std::move(*current_instance)); + current_instance.reset(); + scope = Scope::assembly; + continue; + } + + if (!is_supported_record(keyword.keyword)) { + return syntax_failure( + "abaqus.unsupported_keyword", + "Unsupported Abaqus keyword *" + keyword.keyword + ".", + source); + } + + DeckRecord next_record{ + std::move(keyword.keyword), + std::move(keyword.parameters), + {}, + source, + }; + switch (scope) { + case Scope::global: + deck.global_records.push_back(std::move(next_record)); + current_record = &deck.global_records.back(); + break; + case Scope::part: + current_part->records.push_back(std::move(next_record)); + current_record = ¤t_part->records.back(); + break; + case Scope::assembly: + current_assembly->records.push_back( + std::move(next_record)); + current_record = ¤t_assembly->records.back(); + break; + case Scope::instance: + return syntax_failure( + "abaqus.syntax.instance_local_keyword", + "Keyword records inside *INSTANCE are unsupported.", + source); + } + } + + if (input.bad()) { + return failure( + DiagnosticStage::io, + "abaqus.io.read_failed", + "Failed while reading Abaqus input file.", + SourceLocation{path, line_number, 0U}); + } + + if (current_instance.has_value()) { + return syntax_failure( + "abaqus.syntax.unclosed_instance", + "Abaqus *INSTANCE is not closed by *END INSTANCE.", + current_instance->source); + } + if (current_part.has_value()) { + return syntax_failure( + "abaqus.syntax.unclosed_part", + "Abaqus *PART is not closed by *END PART.", + current_part->source); + } + if (current_assembly.has_value()) { + return syntax_failure( + "abaqus.syntax.unclosed_assembly", + "Abaqus *ASSEMBLY is not closed by *END ASSEMBLY.", + current_assembly->source); + } + + return {std::move(deck), {}}; +} + +} // namespace fesa diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a87e8b2..e418d76 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -110,3 +110,33 @@ add_test( NAME DomainValidation COMMAND "$" --gtest_filter=*DomainValidation* ) + +add_executable(fesa_abaqus_parser_tests + unit/io/abaqus/parser_test.cpp +) + +target_compile_features(fesa_abaqus_parser_tests PRIVATE cxx_std_20) +target_compile_options(fesa_abaqus_parser_tests PRIVATE /W4 /permissive- /EHsc) +target_compile_definitions( + fesa_abaqus_parser_tests + PRIVATE + FESA_TEST_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}" +) + +target_link_libraries(fesa_abaqus_parser_tests + PRIVATE + fesa_core + GTest::gtest_main +) + +add_test( + NAME AbaqusParser + COMMAND "$" + --gtest_filter=AbaqusParser.* +) + +add_test( + NAME ScopedDeck + COMMAND "$" + --gtest_filter=ScopedDeck.* +) diff --git a/tests/fixtures/abaqus/minimal_cantilever.inp b/tests/fixtures/abaqus/minimal_cantilever.inp new file mode 100644 index 0000000..39f876c --- /dev/null +++ b/tests/fixtures/abaqus/minimal_cantilever.inp @@ -0,0 +1,26 @@ +** Minimal flat/orphan-mesh cantilever syntax fixture. + +*nOdE +1, 0.0, 0.0, 0.0 +** Comments and blank lines do not terminate the current data record. + +2, 1.0, 0.0, 0.0, +*eLeMeNt, TYPE=B31, elset=Beam +1, 1, 2 +*NSET, NSET=Fixed +1 +*ELSET, ELSET=Beam +1 +*MATERIAL, NAME=Steel +*ELASTIC +210000.0, 0.3 +*BEAM GENERAL SECTION, SECTION=GENERAL, ELSET=Beam, MATERIAL=Steel +1.0, 1.0, 1.0, 1.0, 1.0 +0.0, 1.0, 0.0 +*BOUNDARY +Fixed, 1, 6 +*STEP, NAME=Load +*STATIC +*CLOAD +2, 2, -1.0 +*END STEP diff --git a/tests/fixtures/abaqus/minimal_part_instance_cantilever.inp b/tests/fixtures/abaqus/minimal_part_instance_cantilever.inp new file mode 100644 index 0000000..17c4a2c --- /dev/null +++ b/tests/fixtures/abaqus/minimal_part_instance_cantilever.inp @@ -0,0 +1,37 @@ +** Minimal scoped cantilever syntax fixture. +*PART, NAME=BeamPart +*NODE +1, 0.0, 0.0, 0.0 +2, 1.0, 0.0, 0.0 +*ELEMENT, TYPE=B31, ELSET=Beam +1, 1, 2 +*NSET, NSET=Fixed +1 +*NSET, NSET=Tip +2 +*ELSET, ELSET=Beam +1 +*BEAM GENERAL SECTION, SECTION=GENERAL, ELSET=Beam, MATERIAL=Steel +1.0, 1.0, 1.0, 1.0, 1.0 +0.0, 1.0, 0.0 +*END PART + +*ASSEMBLY, NAME=RootAssembly +*INSTANCE, NAME=Beam-1, PART=BeamPart +*END INSTANCE +*NSET, NSET=Fixed, INSTANCE=Beam-1 +1 +*NSET, NSET=Tip, INSTANCE=Beam-1 +2 +*END ASSEMBLY + +*MATERIAL, NAME=Steel +*ELASTIC +210000.0, 0.3 +*STEP, NAME=Load +*STATIC +*BOUNDARY +Fixed, 1, 6 +*CLOAD +Tip, 2, -1.0 +*END STEP diff --git a/tests/unit/io/abaqus/parser_test.cpp b/tests/unit/io/abaqus/parser_test.cpp new file mode 100644 index 0000000..7692f3f --- /dev/null +++ b/tests/unit/io/abaqus/parser_test.cpp @@ -0,0 +1,235 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +class TemporaryDeck final { +public: + TemporaryDeck(std::string_view name, std::string_view contents) + : path_{std::filesystem::path{testing::TempDir()} / name} { + std::ofstream output{path_, std::ios::binary}; + output.write( + contents.data(), static_cast(contents.size())); + if (!output) { + throw std::runtime_error{"Failed to write temporary Abaqus deck."}; + } + } + + ~TemporaryDeck() { + std::error_code error; + std::filesystem::remove(path_, error); + } + + TemporaryDeck(const TemporaryDeck&) = delete; + TemporaryDeck& operator=(const TemporaryDeck&) = delete; + + [[nodiscard]] const std::filesystem::path& path() const noexcept { + return path_; + } + +private: + std::filesystem::path path_; +}; + +std::filesystem::path fixture_path(std::string_view name) { + return std::filesystem::path{FESA_TEST_SOURCE_DIR} / "fixtures" / + "abaqus" / name; +} + +const fesa::DeckRecord& record( + const std::vector& records, + const std::string_view keyword) { + const auto found = std::ranges::find( + records, keyword, &fesa::DeckRecord::keyword); + if (found == records.end()) { + throw std::runtime_error{"Expected deck record was not parsed."}; + } + return *found; +} + +TEST(AbaqusParser, ParsesCaseInsensitiveKeywordsCommentsAndCommaFields) { + const auto path = fixture_path("minimal_cantilever.inp"); + + const auto result = fesa::parse_deck(path); + + ASSERT_TRUE(result.deck.has_value()); + EXPECT_TRUE(result.diagnostics.empty()); + EXPECT_TRUE(result.deck->parts.empty()); + EXPECT_FALSE(result.deck->assembly.has_value()); + + const auto& nodes = record(result.deck->global_records, "NODE"); + ASSERT_EQ(nodes.data.size(), 2U); + EXPECT_EQ(nodes.data[0], (std::vector{ + "1", "0.0", "0.0", "0.0"})); + EXPECT_EQ(nodes.data[1], (std::vector{ + "2", "1.0", "0.0", "0.0", ""})); + EXPECT_EQ(nodes.source.file, path); + EXPECT_EQ(nodes.source.line, 3U); + EXPECT_EQ(nodes.source.column, 1U); + + const auto& element = record(result.deck->global_records, "ELEMENT"); + EXPECT_EQ(element.parameters.at("TYPE"), "B31"); + EXPECT_EQ(element.parameters.at("ELSET"), "Beam"); + EXPECT_EQ(element.source.line, 8U); +} + +TEST(AbaqusParser, PreservesUtf8ParameterValues) { + const std::string utf8_name{ + "\xEB\xB9\x94\xEB\xB6\x80\xED\x92\x88"}; + const TemporaryDeck input{ + "fesa-parser-utf8.inp", + "*MATERIAL, NAME=" + utf8_name + "\n" + "*ELASTIC\n" + "210000.0, 0.3\n"}; + + const auto result = fesa::parse_deck(input.path()); + + ASSERT_TRUE(result.deck.has_value()); + ASSERT_TRUE(result.diagnostics.empty()); + const auto& material = record(result.deck->global_records, "MATERIAL"); + EXPECT_EQ(material.parameters.at("NAME"), utf8_name); +} + +TEST(AbaqusParser, RejectsUnsupportedKeywordsInsteadOfIgnoringThem) { + const TemporaryDeck input{ + "fesa-parser-unsupported.inp", + "** The error source must identify the keyword line.\n" + "*INCLUDE, INPUT=other.inp\n"}; + + const auto result = fesa::parse_deck(input.path()); + + EXPECT_FALSE(result.deck.has_value()); + ASSERT_EQ(result.diagnostics.size(), 1U); + EXPECT_EQ( + result.diagnostics[0].stage, fesa::DiagnosticStage::syntax); + EXPECT_EQ( + result.diagnostics[0].code, "abaqus.unsupported_keyword"); + ASSERT_TRUE(result.diagnostics[0].source.has_value()); + EXPECT_EQ(result.diagnostics[0].source->file, input.path()); + EXPECT_EQ(result.diagnostics[0].source->line, 2U); +} + +TEST(ScopedDeck, PreservesPartAssemblyAndInstanceScopes) { + const auto path = + fixture_path("minimal_part_instance_cantilever.inp"); + + const auto result = fesa::parse_deck(path); + + ASSERT_TRUE(result.deck.has_value()); + EXPECT_TRUE(result.diagnostics.empty()); + ASSERT_EQ(result.deck->parts.size(), 1U); + EXPECT_EQ(result.deck->parts[0].name, "BeamPart"); + EXPECT_EQ(result.deck->parts[0].source.file, path); + EXPECT_EQ(result.deck->parts[0].source.line, 2U); + EXPECT_EQ(record(result.deck->parts[0].records, "NODE").source.line, 3U); + + ASSERT_TRUE(result.deck->assembly.has_value()); + EXPECT_EQ(result.deck->assembly->name, "RootAssembly"); + EXPECT_EQ(result.deck->assembly->source.line, 19U); + ASSERT_EQ(result.deck->assembly->instances.size(), 1U); + EXPECT_EQ(result.deck->assembly->instances[0].name, "Beam-1"); + EXPECT_EQ(result.deck->assembly->instances[0].part_name, "BeamPart"); + EXPECT_EQ(result.deck->assembly->instances[0].source.line, 20U); + EXPECT_TRUE( + result.deck->assembly->instances[0].transform_data.empty()); + EXPECT_EQ( + record(result.deck->assembly->records, "NSET") + .parameters.at("INSTANCE"), + "Beam-1"); + + EXPECT_NE( + std::ranges::find( + result.deck->global_records, + "MATERIAL", + &fesa::DeckRecord::keyword), + result.deck->global_records.end()); +} + +TEST(ScopedDeck, PreservesInstanceTransformDataWithoutApplyingIt) { + const TemporaryDeck input{ + "fesa-parser-transform.inp", + "*PART, NAME=BeamPart\n" + "*END PART\n" + "*ASSEMBLY, NAME=RootAssembly\n" + "*INSTANCE, NAME=Beam-1, PART=BeamPart\n" + "1.0, 2.0, 3.0\n" + "0.0, 0.0, 1.0, 90.0\n" + "*END INSTANCE\n" + "*END ASSEMBLY\n"}; + + const auto result = fesa::parse_deck(input.path()); + + ASSERT_TRUE(result.deck.has_value()); + ASSERT_TRUE(result.deck->assembly.has_value()); + ASSERT_EQ(result.deck->assembly->instances.size(), 1U); + EXPECT_EQ( + result.deck->assembly->instances[0].transform_data, + (std::vector>{ + {"1.0", "2.0", "3.0"}, + {"0.0", "0.0", "1.0", "90.0"}})); +} + +TEST(ScopedDeck, ReportsUnexpectedAndUnclosedScopeTerminators) { + struct Case final { + std::string_view name; + std::string_view contents; + std::string_view code; + std::size_t line; + }; + const Case cases[]{ + { + "fesa-parser-unexpected-end-part.inp", + "*END PART\n", + "abaqus.syntax.unexpected_end_part", + 1U, + }, + { + "fesa-parser-unclosed-part.inp", + "*PART, NAME=BeamPart\n" + "*NODE\n" + "1, 0.0, 0.0, 0.0\n", + "abaqus.syntax.unclosed_part", + 1U, + }, + { + "fesa-parser-unexpected-end-instance.inp", + "*ASSEMBLY, NAME=RootAssembly\n" + "*END INSTANCE\n", + "abaqus.syntax.unexpected_end_instance", + 2U, + }, + { + "fesa-parser-unclosed-assembly.inp", + "*ASSEMBLY, NAME=RootAssembly\n", + "abaqus.syntax.unclosed_assembly", + 1U, + }, + }; + + for (const auto& test_case : cases) { + const TemporaryDeck input{test_case.name, test_case.contents}; + + const auto result = fesa::parse_deck(input.path()); + + SCOPED_TRACE(test_case.name); + EXPECT_FALSE(result.deck.has_value()); + ASSERT_EQ(result.diagnostics.size(), 1U); + EXPECT_EQ(result.diagnostics[0].code, test_case.code); + ASSERT_TRUE(result.diagnostics[0].source.has_value()); + EXPECT_EQ(result.diagnostics[0].source->line, test_case.line); + } +} + +} // namespace