// Copyright (c) 2024-2026 Tarik Moussa. // SPDX-License-Identifier: MIT // test_layout.cpp // // Phase 5 — Layout / embedding tests. // // Strategy: a conformal map that is the identity (natural equilibrium x*=0) // should reproduce the mesh's own edge lengths. We verify: // 1. Euclidean layout preserves edge lengths (to solver tolerance). // 2. Spherical layout preserves arc-lengths on S². // 3. Layout2D has correct size (one entry per vertex). // 4. Serialisation round-trip: save JSON → load → DOF vector unchanged. // 5. Serialisation round-trip: save XML → load → DOF vector unchanged. // 6. HyperIdeal layout returns success and finite positions. #include "conformal_mesh.hpp" #include "mesh_builder.hpp" #include "euclidean_functional.hpp" #include "spherical_functional.hpp" #include "hyper_ideal_functional.hpp" #include "newton_solver.hpp" #include "layout.hpp" #include "serialization.hpp" #include #include #include #include #include using namespace conformallab; // ──────────────────────────────────────────────────────────────────────────── // Helpers // ──────────────────────────────────────────────────────────────────────────── // Euclidean edge length from layout (2D) static double layout_edge_len(const Layout2D& lay, ConformalMesh& mesh, Halfedge_index h) { auto vi = mesh.source(h).idx(); auto vj = mesh.target(h).idx(); return (lay.uv[vi] - lay.uv[vj]).norm(); } // Spherical arc-length from layout (3D) static double layout_arc_len(const Layout3D& lay, ConformalMesh& mesh, Halfedge_index h) { auto& a = lay.pos[mesh.source(h).idx()]; auto& b = lay.pos[mesh.target(h).idx()]; double c = std::max(-1.0, std::min(1.0, a.dot(b))); return std::acos(c); } // Euclidean edge length from maps + x (the "true" target) static double maps_edge_len(const EuclideanMaps& m, ConformalMesh& mesh, Halfedge_index h, const std::vector& x) { auto get_u = [&](Vertex_index v) -> double { int iv = m.v_idx[v]; return (iv >= 0) ? x[static_cast(iv)] : 0.0; }; Edge_index e = mesh.edge(h); double lam = m.lambda0[e] + get_u(mesh.source(h)) + get_u(mesh.target(h)); return std::exp(lam * 0.5); } // Spherical arc-length from maps + x static double maps_arc_len(const SphericalMaps& m, ConformalMesh& mesh, Halfedge_index h, const std::vector& x) { auto get_u = [&](Vertex_index v) -> double { int iv = m.v_idx[v]; return (iv >= 0) ? x[static_cast(iv)] : 0.0; }; Edge_index e = mesh.edge(h); double lam = m.lambda0[e] + get_u(mesh.source(h)) + get_u(mesh.target(h)); return 2.0 * std::asin(std::min(std::exp(lam * 0.5), 1.0)); } // ════════════════════════════════════════════════════════════════════════════ // Test 1 — Euclidean layout preserves edge lengths (identity map) // ════════════════════════════════════════════════════════════════════════════ TEST(Layout, Euclidean_PreservesEdgeLengths) { auto mesh = make_quad_strip(); auto maps = setup_euclidean_maps(mesh); compute_euclidean_lambda0_from_mesh(mesh, maps); // Pin first vertex; natural equilibrium so x* = 0 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)]; } // Solve (identity map) auto res = newton_euclidean(mesh, x0, maps, 1e-10, 100); ASSERT_TRUE(res.converged); auto layout = euclidean_layout(mesh, res.x, maps); ASSERT_TRUE(layout.success); EXPECT_EQ(layout.uv.size(), mesh.number_of_vertices()); // Every edge: |layout_len - expected_len| < 1e-8 for (auto e : mesh.edges()) { Halfedge_index h = mesh.halfedge(e, 0); double expected = maps_edge_len(maps, mesh, h, res.x); double actual = layout_edge_len(layout, mesh, h); EXPECT_NEAR(actual, expected, 1e-7) << "Edge " << e << ": expected " << expected << " got " << actual; } } // ════════════════════════════════════════════════════════════════════════════ // Test 2 — Euclidean layout: correct vertex count // ════════════════════════════════════════════════════════════════════════════ TEST(Layout, Euclidean_CorrectVertexCount) { 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 x(static_cast(n), 0.0); auto layout = euclidean_layout(mesh, x, maps); EXPECT_TRUE(layout.success); EXPECT_EQ(static_cast(layout.uv.size()), static_cast(mesh.number_of_vertices())); } // ════════════════════════════════════════════════════════════════════════════ // Test 3 — Euclidean triangle layout: root face is a valid triangle // ════════════════════════════════════════════════════════════════════════════ TEST(Layout, Euclidean_TriangleIsNonDegenerate) { auto mesh = make_triangle(); auto maps = setup_euclidean_maps(mesh); compute_euclidean_lambda0_from_mesh(mesh, maps); for (auto v : mesh.vertices()) maps.v_idx[v] = static_cast(v.idx()); std::vector x(mesh.number_of_vertices(), 0.0); auto layout = euclidean_layout(mesh, x, maps); ASSERT_TRUE(layout.success); // Area of layout triangle > 0 const auto& A = layout.uv[0]; const auto& B = layout.uv[1]; const auto& C = layout.uv[2]; double area = std::abs((B - A).x() * (C - A).y() - (C - A).x() * (B - A).y()) * 0.5; EXPECT_GT(area, 1e-10) << "Layout triangle must be non-degenerate"; } // ════════════════════════════════════════════════════════════════════════════ // Test 4 — Spherical layout: arc-lengths preserved (identity map) // ════════════════════════════════════════════════════════════════════════════ TEST(Layout, Spherical_PreservesArcLengths) { auto mesh = make_spherical_tetrahedron(); auto maps = setup_spherical_maps(mesh); compute_spherical_lambda0_from_mesh(mesh, maps); int n = assign_spherical_vertex_dof_indices(mesh, maps); // Solve to identity std::vector x0(static_cast(n), 0.0); auto G0 = spherical_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_spherical(mesh, x0, maps, 1e-10, 100); ASSERT_TRUE(res.converged); auto layout = spherical_layout(mesh, res.x, maps); ASSERT_TRUE(layout.success); EXPECT_EQ(layout.pos.size(), mesh.number_of_vertices()); for (auto e : mesh.edges()) { Halfedge_index h = mesh.halfedge(e, 0); double expected = maps_arc_len(maps, mesh, h, res.x); double actual = layout_arc_len(layout, mesh, h); EXPECT_NEAR(actual, expected, 1e-6) << "Spherical edge " << e << ": expected " << expected << " got " << actual; } } // ════════════════════════════════════════════════════════════════════════════ // Test 5 — Spherical layout: all positions on unit sphere // ════════════════════════════════════════════════════════════════════════════ TEST(Layout, Spherical_PositionsOnUnitSphere) { auto mesh = make_spherical_tetrahedron(); auto maps = setup_spherical_maps(mesh); compute_spherical_lambda0_from_mesh(mesh, maps); int n = assign_spherical_vertex_dof_indices(mesh, maps); std::vector x(static_cast(n), 0.0); auto layout = spherical_layout(mesh, x, maps); ASSERT_TRUE(layout.success); for (auto v : mesh.vertices()) { double r = layout.pos[v.idx()].norm(); EXPECT_NEAR(r, 1.0, 1e-12) << "Vertex " << v << " not on unit sphere, |p| = " << r; } } // ════════════════════════════════════════════════════════════════════════════ // Test 6 — HyperIdeal layout returns success and finite positions // ════════════════════════════════════════════════════════════════════════════ TEST(Layout, HyperIdeal_SuccessAndFinitePositions) { auto mesh = make_triangle(); auto maps = setup_hyper_ideal_maps(mesh); int n = assign_hyper_ideal_all_dof_indices(mesh, maps); // Natural equilibrium at (b=1, a=0.5) std::vector xbase(static_cast(n)); for (auto v : mesh.vertices()) { int iv = maps.v_idx[v]; if (iv >= 0) xbase[iv] = 1.0; } for (auto e : mesh.edges()) { int ie = maps.e_idx[e]; if (ie >= 0) xbase[ie] = 0.5; } auto G0 = evaluate_hyper_ideal(mesh, xbase, maps, false).gradient; for (auto v : mesh.vertices()) { int iv = maps.v_idx[v]; if (iv >= 0) maps.theta_v[v] += G0[iv]; } for (auto e : mesh.edges()) { int ie = maps.e_idx[e]; if (ie >= 0) maps.theta_e[e] += G0[ie]; } auto res = newton_hyper_ideal(mesh, xbase, maps, 1e-9, 100); ASSERT_TRUE(res.converged); auto layout = hyper_ideal_layout(mesh, res.x, maps); EXPECT_TRUE(layout.success); ASSERT_EQ(layout.uv.size(), mesh.number_of_vertices()); for (auto v : mesh.vertices()) { auto& p = layout.uv[v.idx()]; EXPECT_FALSE(std::isnan(p.x())) << "NaN in layout x for v" << v; EXPECT_FALSE(std::isnan(p.y())) << "NaN in layout y for v" << v; // Poincaré disk: all points within the unit disk EXPECT_LE(p.norm(), 1.0 + 1e-9) << "Point outside Poincaré disk for v" << v; } } // ════════════════════════════════════════════════════════════════════════════ // Test 7 — JSON serialisation round-trip // ════════════════════════════════════════════════════════════════════════════ TEST(Serialization, JSON_RoundTrip) { 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.05); auto G0 = euclidean_gradient(mesh, std::vector(n, 0.0), 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); auto layout = euclidean_layout(mesh, res.x, maps); const std::string path = "/tmp/conformallab_test_round_trip.json"; ASSERT_NO_THROW(save_result_json(path, res, "euclidean", static_cast(mesh.number_of_vertices()), static_cast(mesh.number_of_faces()), &layout)); ASSERT_TRUE(std::filesystem::exists(path)); NewtonResult res2; std::string geom; Layout2D layout2; auto x2 = load_result_json(path, &res2, &geom, &layout2); EXPECT_EQ(geom, "euclidean"); EXPECT_EQ(res2.converged, res.converged); EXPECT_EQ(res2.iterations, res.iterations); 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-14); EXPECT_TRUE(layout2.success); ASSERT_EQ(layout2.uv.size(), layout.uv.size()); for (std::size_t i = 0; i < layout2.uv.size(); ++i) { EXPECT_NEAR(layout2.uv[i].x(), layout.uv[i].x(), 1e-12); EXPECT_NEAR(layout2.uv[i].y(), layout.uv[i].y(), 1e-12); } std::filesystem::remove(path); } // ════════════════════════════════════════════════════════════════════════════ // Test 8 — XML serialisation round-trip // ════════════════════════════════════════════════════════════════════════════ TEST(Serialization, XML_RoundTrip) { 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); auto layout = euclidean_layout(mesh, res.x, maps); const std::string path = "/tmp/conformallab_test_round_trip.xml"; ASSERT_NO_THROW(save_result_xml(path, res, "euclidean", static_cast(mesh.number_of_vertices()), static_cast(mesh.number_of_faces()), &layout)); ASSERT_TRUE(std::filesystem::exists(path)); NewtonResult res2; std::string geom; Layout2D layout2; auto x2 = load_result_xml(path, &res2, &geom, &layout2); EXPECT_EQ(geom, "euclidean"); EXPECT_EQ(res2.converged, res.converged); 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); EXPECT_TRUE(layout2.success); ASSERT_EQ(layout2.uv.size(), layout.uv.size()); std::filesystem::remove(path); } // ════════════════════════════════════════════════════════════════════════════ // I2: serialization throws on malformed / schema-violating input // // These tests cover the throw paths in load_result_json / load_result_xml // that were previously untested (I2 in the test-coverage audit). // ════════════════════════════════════════════════════════════════════════════ TEST(Serialization, LoadResultJson_ThrowsOnMissingFile) { EXPECT_THROW(load_result_json("/tmp/does_not_exist_conflab.json"), std::runtime_error); } TEST(Serialization, LoadResultJson_ThrowsOnMalformedJson) { const std::string path = "/tmp/conflab_bad.json"; { std::ofstream ofs(path); ofs << "{not valid json!!!"; } EXPECT_THROW(load_result_json(path), std::runtime_error); std::filesystem::remove(path); } TEST(Serialization, LoadResultJson_ThrowsOnMissingDofVector) { // V4: a valid JSON object but without the required "dof_vector" key. const std::string path = "/tmp/conflab_nodof.json"; { std::ofstream ofs(path); ofs << R"({"geometry":"euclidean"})"; } EXPECT_THROW(load_result_json(path), std::runtime_error); std::filesystem::remove(path); } TEST(Serialization, LoadResultJson_ThrowsOnMissingSolverField) { // V4: has dof_vector but solver block is absent when res != nullptr. const std::string path = "/tmp/conflab_nosolver.json"; { std::ofstream ofs(path); ofs << R"({"dof_vector":[0.1,0.2]})"; } NewtonResult res; EXPECT_THROW(load_result_json(path, &res), std::runtime_error); std::filesystem::remove(path); } TEST(Serialization, LoadResultXml_ThrowsOnMissingFile) { EXPECT_THROW(load_result_xml("/tmp/does_not_exist_conflab.xml"), std::runtime_error); } TEST(Serialization, LoadResultXml_ThrowsOnMalformedSolverAttribute) { // V2: non-numeric attribute causes stoi/stod error that must surface // as runtime_error, not std::invalid_argument. const std::string path = "/tmp/conflab_badxml.xml"; { std::ofstream ofs(path); ofs << R"( 0.0 )"; } NewtonResult res; 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)"; } }