diff --git a/code/tests/cgal/test_layout.cpp b/code/tests/cgal/test_layout.cpp index bce1528..32c2372 100644 --- a/code/tests/cgal/test_layout.cpp +++ b/code/tests/cgal/test_layout.cpp @@ -435,3 +435,145 @@ TEST(Serialization, LoadResultXml_ThrowsOnMalformedSolverAttribute) EXPECT_THROW(load_result_xml(path, &res), std::runtime_error); std::filesystem::remove(path); } + +// ════════════════════════════════════════════════════════════════════════════ +// V5 (input-validation audit, 2026-06-01): strict XML subset rejection +// +// Finding V5: the hand-rolled XML reader assumed one element per line. +// Reformatted-but-valid XML (attributes on separate lines, etc.) was silently +// mis-read into zeros rather than rejected. The fix adds strict-subset +// format validation — only the exact one-element-per-line layout written by +// save_result_xml is accepted; everything else is explicitly rejected. +// +// These tests verify the rejection of the two most common reformatting cases: +// (a) root element with geometry= attribute on a separate line +// (b) with the '>' tag-open on a separate line +// Both must throw std::runtime_error, never silently return zeros. +// ════════════════════════════════════════════════════════════════════════════ + +TEST(Serialization, LoadResultXml_RejectsReformattedRootElement) +{ + // V5: the root element is split across lines — the + // geometry= attribute is on a separate line from the tag name. + // This is semantically valid XML but violates the strict internal subset. + const std::string path = "/tmp/conflab_reformatted_root.xml"; + { + std::ofstream ofs(path); + // geometry= is on a second line — xml_get_attr would return empty string, + // producing a silent misread. The V5 fix must detect this and reject it. + ofs << "\n" + << "\n" + << " \n" + << " 0.1 0.2\n" + << "\n"; + } + EXPECT_THROW(load_result_xml(path), std::runtime_error) + << "Reformatted root element (attributes on separate line) must be" + " rejected rather than silently mis-read"; + std::filesystem::remove(path); +} + +TEST(Serialization, LoadResultXml_RejectsDOFVectorWithTagOpenOnSeparateLine) +{ + // V5: the tag's closing '>' is on a different line from + // the opening '\n" + << "\n" + << " \n" + << " ' here + << " n=\"2\">0.1 0.2\n" + << "\n"; + } + EXPECT_THROW(load_result_xml(path), std::runtime_error) + << "DOFVector with tag '>' on separate line must be rejected rather" + " than silently mis-read into an empty DOF vector"; + std::filesystem::remove(path); +} + +TEST(Serialization, LoadResultXml_CanonicalFormatStillWorks) +{ + // V5 safety check: the canonical format produced by save_result_xml must + // still round-trip correctly after the strict-subset check is added. + // (Regression guard: V5 changes must not break valid round-trips.) + auto mesh = make_triangle(); + auto maps = setup_euclidean_maps(mesh); + compute_euclidean_lambda0_from_mesh(mesh, maps); + auto vit = mesh.vertices().begin(); + maps.v_idx[*vit++] = -1; + int idx = 0; + for (; vit != mesh.vertices().end(); ++vit) maps.v_idx[*vit] = idx++; + const int n = idx; + + std::vector x0(static_cast(n), 0.0); + auto G0 = euclidean_gradient(mesh, x0, maps); + for (auto v : mesh.vertices()) { + int iv = maps.v_idx[v]; + if (iv >= 0) maps.theta_v[v] -= G0[static_cast(iv)]; + } + auto res = newton_euclidean(mesh, x0, maps, 1e-10, 100); + ASSERT_TRUE(res.converged); + + const std::string path = "/tmp/conflab_v5_canonical_check.xml"; + ASSERT_NO_THROW(save_result_xml(path, res, "euclidean", + static_cast(mesh.number_of_vertices()), + static_cast(mesh.number_of_faces()))); + + std::string geom; + NewtonResult res2; + ASSERT_NO_THROW({ + auto x2 = load_result_xml(path, &res2, &geom); + EXPECT_EQ(geom, "euclidean"); + ASSERT_EQ(x2.size(), res.x.size()); + for (std::size_t i = 0; i < x2.size(); ++i) + EXPECT_NEAR(x2[i], res.x[i], 1e-12); + }); + std::filesystem::remove(path); +} + +// ════════════════════════════════════════════════════════════════════════════ +// V6 (input-validation audit, 2026-06-01): DOF-vector vs mesh size check +// +// Finding V6: a DOF vector loaded from a file for a *different* mesh had no +// size check — the mismatch only surfaced later (out-of-bounds or wrong +// answer) when x was indexed against the mesh. The fix adds the helper +// check_dof_vector_size(x, expected_dofs, context) that throws immediately +// with a clear message when the sizes don't match. +// ════════════════════════════════════════════════════════════════════════════ + +TEST(Serialization, CheckDofVectorSize_ThrowsOnMismatch) +{ + // V6: a DOF vector of size 3 but the mesh has 5 DOFs → mismatch. + std::vector x = {0.1, 0.2, 0.3}; + EXPECT_THROW(check_dof_vector_size(x, 5, "test.json"), std::runtime_error) + << "check_dof_vector_size must throw when sizes don't match"; +} + +TEST(Serialization, CheckDofVectorSize_PassesOnMatch) +{ + // V6: exact match → no exception. + std::vector x = {0.1, 0.2, 0.3}; + EXPECT_NO_THROW(check_dof_vector_size(x, 3)) + << "check_dof_vector_size must not throw when sizes match"; +} + +TEST(Serialization, CheckDofVectorSize_ErrorMessageNamesExpectedAndActual) +{ + // V6: the exception message must say both the loaded size and expected size + // so the user knows what went wrong. + std::vector x(2, 0.0); + try { + check_dof_vector_size(x, 7, "myfile.xml"); + FAIL() << "Expected std::runtime_error but no exception was thrown"; + } catch (const std::runtime_error& e) { + std::string msg = e.what(); + EXPECT_NE(msg.find("2"), std::string::npos) + << "Error message should mention the loaded size (2)"; + EXPECT_NE(msg.find("7"), std::string::npos) + << "Error message should mention the expected size (7)"; + } +} diff --git a/code/tests/cgal/test_newton_solver.cpp b/code/tests/cgal/test_newton_solver.cpp index b60da71..0c5f36d 100644 --- a/code/tests/cgal/test_newton_solver.cpp +++ b/code/tests/cgal/test_newton_solver.cpp @@ -737,3 +737,107 @@ TEST(NewtonCore, Status_LineSearchStalled) EXPECT_EQ(res.status, conformallab::NewtonStatus::LineSearchStalled); EXPECT_EQ(res.iterations, 0); // H1: no step completed } + +// ════════════════════════════════════════════════════════════════════════════ +// H5 (test-coverage audit, 2026-06-01): degenerate-triangle integration test +// +// Finding H5: euclidean_hessian.hpp:85-90 returns {0,0,0,false} for degenerate +// triangles (triangle inequality violated or area = 0), making the assembled +// Hessian singular. This path had no integration test: the behavior on a +// near-degenerate mesh was undefined. +// +// Test strategy: build a mesh with a very thin/sliver triangle (aspect ratio +// ~1000:1) so that euclidean_cot_weights returns valid=true but the Hessian +// is severely ill-conditioned (the cotangent weights blow up for a near-zero +// area). Then feed this through newton_euclidean and characterize the result: +// either converges (the SparseQR fallback handles the ill-conditioned H) or +// reports a non-Converged status. In either case the solver must not crash, +// must not produce NaN in the result, and the behavior is documented. +// +// We also test the exact-degenerate case (zero-area triangle), where +// euclidean_cot_weights explicitly returns valid=false and the Hessian row/col +// for those DOFs is zero → the SparseQR fallback must handle it without crash. +// ════════════════════════════════════════════════════════════════════════════ + +TEST(NewtonSolver, Euclidean_SliverTriangle_CharacterizedBehavior) +{ + // Build a very thin sliver triangle: v0=(0,0), v1=(1,0), v2=(0,1e-4). + // Area ≈ 5e-5, aspect ratio ≈ 10000. The cot weights are valid (triangle + // inequality holds) but the cotangent at v2 is huge (≈ l01/Area). + ConformalMesh mesh; + auto v0 = mesh.add_vertex(Point3(0.0, 0.0, 0.0)); + auto v1 = mesh.add_vertex(Point3(1.0, 0.0, 0.0)); + auto v2 = mesh.add_vertex(Point3(0.0, 1e-4, 0.0)); + mesh.add_face(v0, v1, v2); + + auto maps = setup_euclidean_maps(mesh); + compute_euclidean_lambda0_from_mesh(mesh, maps); + + // Pin v0; assign DOF indices to v1 and v2. + maps.v_idx[v0] = -1; + maps.v_idx[v1] = 0; + maps.v_idx[v2] = 1; + const int n = 2; + + // Natural theta: equilibrium at x* = 0 by construction. + set_natural_euclidean_theta(mesh, maps, n); + + std::vector x0(n, 0.0); + auto res = newton_euclidean(mesh, x0, maps, /*tol=*/1e-8, /*max_iter=*/100); + + // H5 acceptance criterion: behavior is characterized, not undefined. + // The solver must not crash or produce NaN. + EXPECT_EQ(static_cast(res.x.size()), n) + << "Result vector must always be populated"; + for (double xi : res.x) + EXPECT_FALSE(std::isnan(xi)) << "NaN in result x — degenerate-triangle path"; + EXPECT_FALSE(std::isnan(res.grad_inf_norm)) + << "NaN in grad_inf_norm — degenerate-triangle path"; + + // Document the outcome: the sliver has valid cotangent weights (they are + // large but finite), so the Hessian is positive-definite; Newton converges + // (possibly via SparseQR for numerical stability). + // We tolerate both converged and non-converged outcomes; what matters is + // that the result is finite and the status is meaningful. + EXPECT_NE(res.status, NewtonStatus::LinearSolverFailed) + << "A sliver triangle should not cause both LDLT and SparseQR to fail;" + " the system is still consistent (just ill-conditioned)."; +} + +TEST(NewtonSolver, Euclidean_ExactDegenerateTriangle_NoCrash) +{ + // Build a degenerate triangle: all three vertices collinear → area = 0. + // v0=(0,0), v1=(1,0), v2=(2,0). This forces kahan <= 0 in + // euclidean_cot_weights → {0,0,0,false}. The assembled Hessian is the + // zero matrix → both LDLT and SparseQR fall through gracefully. + ConformalMesh mesh; + auto v0 = mesh.add_vertex(Point3(0.0, 0.0, 0.0)); + auto v1 = mesh.add_vertex(Point3(1.0, 0.0, 0.0)); + auto v2 = mesh.add_vertex(Point3(2.0, 0.0, 0.0)); + mesh.add_face(v0, v1, v2); + + auto maps = setup_euclidean_maps(mesh); + compute_euclidean_lambda0_from_mesh(mesh, maps); + + maps.v_idx[v0] = -1; + maps.v_idx[v1] = 0; + maps.v_idx[v2] = 1; + const int n = 2; + + // Use zero theta (not natural theta) — we just want to verify no crash. + std::vector x0(n, 0.0); + + // H5 acceptance criterion: no crash, no UB, result struct populated. + NewtonResult res; + ASSERT_NO_THROW(res = newton_euclidean(mesh, x0, maps, /*tol=*/1e-8, /*max_iter=*/5)); + + EXPECT_EQ(static_cast(res.x.size()), n); + // A zero Hessian cannot be solved → either solver fails → LinearSolverFailed, + // OR SparseQR finds a trivially-zero step and the loop exits via MaxIterations. + // Either is an acceptable documented outcome; what must NOT happen is a crash. + EXPECT_TRUE(res.status == NewtonStatus::LinearSolverFailed + || res.status == NewtonStatus::MaxIterations + || res.status == NewtonStatus::LineSearchStalled) + << "Exact-degenerate triangle: expected documented failure status, got " + << to_string(res.status); +} diff --git a/code/tests/cgal/test_phase6.cpp b/code/tests/cgal/test_phase6.cpp index 3111b6a..b4dc921 100644 --- a/code/tests/cgal/test_phase6.cpp +++ b/code/tests/cgal/test_phase6.cpp @@ -137,6 +137,72 @@ TEST(GaussBonnet, ManuallySetAnalyticalTheta_PassesCheck) EXPECT_NO_THROW(check_gauss_bonnet(m, maps)); } +// ════════════════════════════════════════════════════════════════════════════ +// H3 (test-coverage audit, 2026-06-01) +// +// Finding H3: enforce_gauss_bonnet was silent about the magnitude of the +// correction it applied. The fix changes both overloads to return the total +// absolute deficit |Σ(2π−Θ_v) − 2π·χ|. A large return value signals that +// the input target angles were far from satisfying Gauss–Bonnet, so callers +// can warn or refuse to proceed. +// +// These tests: +// (a) verify the return value is large when the input angles are badly wrong; +// (b) verify the return value is near-zero when the input is already correct; +// (c) check both the raw-property-map overload and the Maps overload. +// ════════════════════════════════════════════════════════════════════════════ + +TEST(GaussBonnet, EnforceReturnsCorrectionMagnitude_LargeCorrection) +{ + // H3 acceptance criterion: feed intentionally bad cone angles and assert + // the reported correction is large. + // + // Tetrahedron (χ=2, V=4). Set all Θ_v = 0 (badly wrong: the correct + // Gauss–Bonnet identity needs Σ(2π−Θ_v) = 4π, but with Θ_v=0 we get + // Σ(2π−0) = 8π, so the deficit is 8π − 4π = 4π). + auto m = make_tetrahedron(); + auto maps = setup_euclidean_maps(m); + for (auto v : m.vertices()) maps.theta_v[v] = 0.0; + + double correction = enforce_gauss_bonnet(m, maps); + + // The total correction should equal |Σ(2π−0) − 2π·χ| = |8π − 4π| = 4π. + EXPECT_NEAR(correction, 4.0 * M_PI, 1e-10) + << "enforce_gauss_bonnet should report a correction of 4π for" + " a tetrahedron with all theta_v = 0"; + // And the deficit must now be zero. + EXPECT_NEAR(gauss_bonnet_deficit(m, maps), 0.0, 1e-10); +} + +TEST(GaussBonnet, EnforceReturnsCorrectionMagnitude_NearZeroWhenAlreadyCorrect) +{ + // H3: when the angles already satisfy Gauss–Bonnet, the correction is + // near zero. + auto m = make_triangle(); + auto maps = setup_euclidean_maps(m); + // Set theta_v so the sum already equals 2π·χ = 2π exactly. + // Triangle has 3 vertices; setting each to 4π/3 gives Σ(2π−4π/3)=3·(2π/3)=2π. + for (auto v : m.vertices()) maps.theta_v[v] = 4.0 * M_PI / 3.0; + + double correction = enforce_gauss_bonnet(m, maps); + + EXPECT_NEAR(correction, 0.0, 1e-10) + << "enforce_gauss_bonnet should report near-zero correction when" + " angles already satisfy Gauss–Bonnet"; +} + +TEST(GaussBonnet, EnforceRawMapOverload_ReturnsCorrection) +{ + // H3: the raw-property-map overload also returns the correction magnitude. + auto m = make_quad_strip(); + auto maps = setup_euclidean_maps(m); + // Default theta_v = 2π everywhere; sum = 0, rhs = 2π, deficit = -2π. + // |deficit| = 2π. + double correction = enforce_gauss_bonnet(m, maps.theta_v); + EXPECT_NEAR(correction, 2.0 * M_PI, 1e-10) + << "Raw-map overload of enforce_gauss_bonnet should return |deficit|"; +} + // ════════════════════════════════════════════════════════════════════════════ // GaussBonnet — HyperIdeal API guard (Finding-B from external-audit-2026-05-30) // diff --git a/code/tests/cgal/test_phase7.cpp b/code/tests/cgal/test_phase7.cpp index 79d45a3..d13b573 100644 --- a/code/tests/cgal/test_phase7.cpp +++ b/code/tests/cgal/test_phase7.cpp @@ -315,6 +315,22 @@ TEST(PeriodMatrix, ReduceToFD_ThrowsForNonUpperHalfPlane) EXPECT_THROW(reduce_to_fundamental_domain(tau), std::domain_error); } +// H4 (test-coverage audit, 2026-06-01): the guard is `Im(τ) <= 0.0`, so +// the exact boundary Im(τ) == 0.0 (the real axis) must also throw. +// The previous test only checked Im(τ) < 0; this covers the boundary. +TEST(PeriodMatrix, ReduceToFD_ThrowsForRealAxisBoundary) +{ + // Im(τ) == 0.0 exactly — on the real axis, not in the upper half-plane. + C tau_real_axis(1.0, 0.0); + EXPECT_THROW(reduce_to_fundamental_domain(tau_real_axis), std::domain_error) + << "tau with Im == 0.0 is on the real axis and must throw domain_error"; + + // Additional boundary variants to be thorough. + EXPECT_THROW(reduce_to_fundamental_domain(C(0.0, 0.0)), std::domain_error); + EXPECT_THROW(reduce_to_fundamental_domain(C(-0.5, 0.0)), std::domain_error); + EXPECT_THROW(reduce_to_fundamental_domain(C(0.5, 0.0)), std::domain_error); +} + TEST(PeriodMatrix, IsInFundamentalDomain_Square) { EXPECT_TRUE(is_in_fundamental_domain(C(0.0, 1.0))); // i