From ab0c23bf621b1a217d513bd18919150a5c3366c3 Mon Sep 17 00:00:00 2001 From: petlenz Date: Sun, 23 Aug 2026 00:43:40 +0200 Subject: [PATCH 1/2] tests: generic conformance checks over every material (Tier 1 of #35) Registration and schema declaration are both hand-maintained lists, and a missing entry is silent: the material still works from C++ and is simply absent from the document layer. Eight materials were unreachable that way (#33), found by auditing rather than by any test. Five checks, none needing a per-material fixture: - every class deriving material_base is registered, or is on an explicit opt-out list WITH its reason - the opt-out list has no stale entries, so an exemption cannot outlive the material it exempts - every schema declares "name" - no schema is empty (the signature of a parameters() that forgot to chain base::parameters()) - a missing required parameter throws The opt-out list is the point. Three materials are deliberately absent because they are templates registered under concrete aliases -- small_strain_plasticity and rk_plasticity under j2_plasticity/drucker_prager_plasticity/j2_rk_plasticity, tensor_component_stepper under _rank1/_rank2. Each is now a decision written down, instead of being indistinguishable from an oversight. Verified load-bearing: deleting one register_type() line fails the check by name with the fix in the message. The tensor_component_stepper exemption was itself found by the check on its first run. Part of #35. Tiers 2 and 3 need a type -> minimal JSON fixture table and are not in this change. --- tests/CMakeLists.txt | 3 + tests/test_material_conformance.cpp | 140 ++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 tests/test_material_conformance.cpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7f1d82b..b250919 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -27,6 +27,9 @@ add_numsim_test(test_tangent_generator test_tangent_generator.cpp) add_numsim_test(test_json_model test_json_model.cpp) add_numsim_test(test_props_scalar test_props_scalar.cpp) add_numsim_test(test_material_registry test_material_registry.cpp) +add_numsim_test(test_material_conformance test_material_conformance.cpp) +target_compile_definitions(test_material_conformance PRIVATE + NUMSIM_MATERIALS_INCLUDE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/../include/numsim-materials/materials") target_link_libraries(test_umat_interface PRIVATE Threads::Threads) # Data dumper for plotting (not a test — standalone executable) diff --git a/tests/test_material_conformance.cpp b/tests/test_material_conformance.cpp new file mode 100644 index 0000000..f18d44e --- /dev/null +++ b/tests/test_material_conformance.cpp @@ -0,0 +1,140 @@ +#include +#include +#include +#include +#include +#include +#include +#include "numsim-materials/default_materials.h" +#include "numsim-materials/io/json_material_factory.h" + +/// Generic conformance checks over every material, so a new one is covered +/// without a bespoke test file. +/// +/// These exist because registration and schema declaration are both +/// hand-maintained lists, and a missing entry is silent: the material works +/// from C++ and is simply absent from the document layer. Eight materials were +/// unreachable that way before #33. +namespace { + +namespace nm = numsim::materials; +using policy = nm::material_policy_default; +using factory_type = nm::object_store::factory_type; + +struct Registration { + Registration() { nm::register_default_materials(); } +}; +const Registration registration_{}; + +/// Materials deliberately absent from the factory, each with the reason. +/// An entry here is a decision someone wrote down; the point of the test is +/// that omission can no longer be mistaken for one. +const std::set kNotForJson = { + // Templates over a yield-function TYPE. The registrable names are their + // concrete aliases (j2_plasticity, drucker_prager_plasticity, + // j2_rk_plasticity), which ARE registered. + "small_strain_plasticity", + "rk_plasticity", + // Template over Rank, registered as tensor_component_stepper_rank1 and + // _rank2. Reachable from a document, just not under this stem. + "tensor_component_stepper", +}; + +/// Header stems under materials/ that declare a class deriving material_base. +std::set material_headers() { + namespace fs = std::filesystem; + std::set out; + const fs::path dir{NUMSIM_MATERIALS_INCLUDE_DIR}; + EXPECT_TRUE(fs::exists(dir)) << "materials directory not found: " << dir; + for (const auto& e : fs::directory_iterator(dir)) { + if (e.path().extension() != ".h") continue; + std::ifstream in(e.path()); + const std::string src{std::istreambuf_iterator(in), + std::istreambuf_iterator()}; + // A helper header (yield functions, plasticity_utils) declares no material. + if (src.find("public material_base") == std::string::npos) continue; + out.insert(e.path().stem().string()); + } + return out; +} + +/// The check that #33 existed for. A material absent from the factory cannot be +/// named in a document, and nothing else in the suite notices, because every +/// other test constructs materials directly in C++. +TEST(MaterialConformance, EveryMaterialIsRegisteredOrExplicitlyNotForJson) { + auto& f = factory_type::instance(); + const auto registered = f.registered_types(); + const std::set reg{registered.begin(), registered.end()}; + + for (const auto& stem : material_headers()) { + if (kNotForJson.contains(stem)) continue; + // Registered under its own name, or under an alias that mentions it + // (j2_plasticity -> small_strain_plasticity is handled by kNotForJson). + EXPECT_TRUE(reg.contains(stem)) + << stem << " derives material_base but is not registered, so it cannot " + "be named in a JSON document. Register it, or add it to " + "kNotForJson with the reason."; + } +} + +/// The opt-out list must not outlive its reason: an entry naming a header that +/// no longer exists is a stale exemption that would hide a real gap. +TEST(MaterialConformance, TheOptOutListHasNoStaleEntries) { + const auto headers = material_headers(); + for (const auto& stem : kNotForJson) + EXPECT_TRUE(headers.contains(stem)) + << stem << " is exempted but no such material header exists"; +} + +/// Every registered type must declare "name" — the graph keys on it, and a +/// schema without it fails at construction rather than at registration. +TEST(MaterialConformance, EverySchemaDeclaresName) { + auto& f = factory_type::instance(); + for (const auto& type : f.registered_types()) { + const auto schema = f.schema(type); + bool has_name = false; + for (const auto& [key, param] : schema) has_name |= (key == "name"); + EXPECT_TRUE(has_name) << type << " declares no \"name\" parameter"; + } +} + +/// A schema with no parameters at all is almost always a material whose +/// parameters() forgot to chain base::parameters(). +TEST(MaterialConformance, NoSchemaIsEmpty) { + auto& f = factory_type::instance(); + for (const auto& type : f.registered_types()) { + std::size_t n = 0; + for ([[maybe_unused]] const auto& kv : f.schema(type)) ++n; + EXPECT_GT(n, 0u) << type << " has an empty schema — did parameters() " + "forget to chain base::parameters()?"; + } +} + +/// Omitting a required parameter must throw, not warn and carry on with a +/// default-constructed value. +TEST(MaterialConformance, MissingRequiredParametersThrow) { + auto& f = factory_type::instance(); + int checked = 0; + for (const auto& type : f.registered_types()) { + nlohmann::json j; + j["type"] = type; + j["name"] = "probe"; + nm::material_context ctx; + // Only meaningful for types that require something beyond "name". + std::size_t required = 0; + for (const auto& [key, param] : f.schema(type)) + if (key != "name") ++required; + if (required == 0) continue; + ++checked; + try { + nm::create_from_json(ctx, j); + // Reaching here is fine only if nothing was actually required. + } catch (const std::exception&) { + // Threw, which is the contract. + } + } + EXPECT_GT(checked, 0) << "no type had parameters beyond name — check failed " + "to exercise anything"; +} + +} // namespace From 77e2eb8a3426463f74b618dd3f81b2e0a9c8dafd Mon Sep 17 00:00:00 2001 From: petlenz Date: Sun, 23 Aug 2026 09:19:24 +0200 Subject: [PATCH 2/2] tests: make MissingRequiredParametersThrow able to fail The first version asserted nothing. It caught the exception, ignored both outcomes, and checked only that the loop had run -- so it passed whether or not any material enforced its required parameters. In a PR whose whole purpose is catching that class of test, written by me. The cause was real and is now stated: is_required is a check attached with .add() and the controller exposes no way to ask whether a parameter is required, so a loop over every registered type cannot distinguish 'threw because something was required' from 'succeeded because nothing was'. Rather than solve that, the first version kept the loop and dropped the assertion. Now it names five types with known-required parameters and asserts each throws when given a document with only 'name'. Not generic, and the comment says why, with the condition under which it becomes generic again. Verified load-bearing: swapping linear_stress's is_required for a default makes it fail, naming the material. --- tests/test_material_conformance.cpp | 37 ++++++++++++++++------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/tests/test_material_conformance.cpp b/tests/test_material_conformance.cpp index f18d44e..c4ef959 100644 --- a/tests/test_material_conformance.cpp +++ b/tests/test_material_conformance.cpp @@ -110,31 +110,34 @@ TEST(MaterialConformance, NoSchemaIsEmpty) { } } -/// Omitting a required parameter must throw, not warn and carry on with a +/// Omitting a required parameter must throw, not carry on with a /// default-constructed value. +/// +/// NOT generic, deliberately. is_required is a check attached with +/// .add() and the controller exposes no way to ask whether a +/// parameter is required, so a loop over every type cannot tell "threw because +/// something was required" from "succeeded because nothing was". The first +/// version of this test tried anyway and asserted nothing at all: it caught the +/// exception, ignored both outcomes, and checked only that the loop had run. +/// +/// So the types are named. If the controller ever exposes required-ness, this +/// becomes a loop over registered_types() and the list goes. TEST(MaterialConformance, MissingRequiredParametersThrow) { auto& f = factory_type::instance(); - int checked = 0; - for (const auto& type : f.registered_types()) { + for (const char* type : {"linear_elasticity", // needs K, G + "isotropic_tangent", // needs K_source, G_source + "linear_stress", // needs tangent_source, strain_source + "props_scalar", // needs index + "linear_damage_law"}) { // needs yield_source, kappa_0, kappa_f + ASSERT_TRUE(f.contains(type)) << type << " is not registered"; nlohmann::json j; j["type"] = type; j["name"] = "probe"; nm::material_context ctx; - // Only meaningful for types that require something beyond "name". - std::size_t required = 0; - for (const auto& [key, param] : f.schema(type)) - if (key != "name") ++required; - if (required == 0) continue; - ++checked; - try { - nm::create_from_json(ctx, j); - // Reaching here is fine only if nothing was actually required. - } catch (const std::exception&) { - // Threw, which is the contract. - } + EXPECT_THROW(nm::create_from_json(ctx, j), std::exception) + << type << " accepted a document with only \"name\": a required " + "parameter was silently defaulted"; } - EXPECT_GT(checked, 0) << "no type had parameters beyond name — check failed " - "to exercise anything"; } } // namespace