C2: Fix coverage.sh — remove || true from lcov capture/extract steps so real
lcov failures are visible; empty coverage.info now exits with code 3.
C3: Add coverage gate to quality-gates CI job (SKIP_COVERAGE_GATE=1 ramp-up
mode until I5 is resolved — fast suite covers ~9.6% not 80%). Thresholds:
80% line / 70% branch / 90% function (agreed 2026-05-31).
V1: Wrap JSON parse + field extraction in try/catch — nlohmann parse_error and
type_error now surface as std::runtime_error with the file path.
V2: Wrap stoi/stod in XML Solver parser — missing/non-numeric attributes throw
std::runtime_error instead of leaking std::invalid_argument.
V4: Validate required JSON keys (dof_vector, solver, solver.*) before access —
missing field produces a clear named-field error message.
I2: 6 serialization negative tests (missing file, malformed JSON, missing
dof_vector, missing solver block, missing XML file, non-numeric XML attr).
I3: load_mesh throws on non-triangulated (quad) mesh — covers the
is_triangle_mesh guard that was previously untested.
I4: spherical_hessian throws on edge DOFs — covers the logic_error guard.
290/290 tests pass (+8 new).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
238 lines
9.5 KiB
C++
238 lines
9.5 KiB
C++
// Copyright (c) 2024-2026 Tarik Moussa.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
// test_mesh_io.cpp
|
|
//
|
|
// Phase 4b — CGAL::IO mesh round-trip tests.
|
|
//
|
|
// Tests:
|
|
// 1. OFF write + read round-trip: vertex count, face count, edge count preserved.
|
|
// 2. OBJ write + read round-trip: same topology checks.
|
|
// 3. load_mesh() throws on non-existent file.
|
|
// 4. Round-trip preserves vertex positions (within floating-point precision).
|
|
|
|
#include "conformal_mesh.hpp"
|
|
#include "mesh_builder.hpp"
|
|
#include "mesh_io.hpp"
|
|
#include <gtest/gtest.h>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <stdexcept>
|
|
|
|
using namespace conformallab;
|
|
|
|
// Helper: temp file path with given extension
|
|
static std::string tmp_path(const char* ext)
|
|
{
|
|
return std::string("/tmp/conformallab_test.") + ext;
|
|
}
|
|
|
|
// Helper: delete file if it exists
|
|
static void rm(const std::string& path)
|
|
{
|
|
std::filesystem::remove(path);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// OFF round-trip: tetrahedron
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
TEST(MeshIO, OFF_RoundTrip_Tetrahedron)
|
|
{
|
|
auto path = tmp_path("off");
|
|
rm(path);
|
|
|
|
auto mesh_out = make_tetrahedron();
|
|
ASSERT_TRUE(write_mesh(path, mesh_out))
|
|
<< "write_mesh should succeed for tetrahedron";
|
|
|
|
ConformalMesh mesh_in;
|
|
ASSERT_TRUE(read_mesh(path, mesh_in))
|
|
<< "read_mesh should succeed for the written OFF file";
|
|
|
|
EXPECT_EQ(mesh_in.number_of_vertices(), mesh_out.number_of_vertices());
|
|
EXPECT_EQ(mesh_in.number_of_faces(), mesh_out.number_of_faces());
|
|
EXPECT_EQ(mesh_in.number_of_edges(), mesh_out.number_of_edges());
|
|
|
|
rm(path);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// OFF round-trip: quad strip
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
TEST(MeshIO, OFF_RoundTrip_QuadStrip)
|
|
{
|
|
auto path = tmp_path("off");
|
|
rm(path);
|
|
|
|
auto mesh_out = make_quad_strip();
|
|
ASSERT_TRUE(write_mesh(path, mesh_out));
|
|
|
|
ConformalMesh mesh_in;
|
|
ASSERT_TRUE(read_mesh(path, mesh_in));
|
|
|
|
EXPECT_EQ(mesh_in.number_of_vertices(), mesh_out.number_of_vertices());
|
|
EXPECT_EQ(mesh_in.number_of_faces(), mesh_out.number_of_faces());
|
|
|
|
rm(path);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// OBJ round-trip: tetrahedron
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
TEST(MeshIO, OBJ_RoundTrip_Tetrahedron)
|
|
{
|
|
auto path = tmp_path("obj");
|
|
rm(path);
|
|
|
|
auto mesh_out = make_tetrahedron();
|
|
ASSERT_TRUE(write_mesh(path, mesh_out))
|
|
<< "write_mesh (OBJ) should succeed";
|
|
|
|
ConformalMesh mesh_in;
|
|
ASSERT_TRUE(read_mesh(path, mesh_in))
|
|
<< "read_mesh (OBJ) should succeed";
|
|
|
|
EXPECT_EQ(mesh_in.number_of_vertices(), mesh_out.number_of_vertices());
|
|
EXPECT_EQ(mesh_in.number_of_faces(), mesh_out.number_of_faces());
|
|
|
|
rm(path);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// load_mesh throws on missing file
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
TEST(MeshIO, LoadMeshThrowsOnMissingFile)
|
|
{
|
|
EXPECT_THROW(load_mesh("/tmp/does_not_exist_conflab.off"), std::runtime_error);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// I3: load_mesh throws on non-triangulated mesh
|
|
//
|
|
// The quad-strip mesh has 4-vertex faces; load_mesh must reject it at the
|
|
// I/O boundary rather than letting the quad faces flow silently into the
|
|
// triangle-only functionals.
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
TEST(MeshIO, LoadMeshThrowsOnNonTriangulatedMesh)
|
|
{
|
|
// Write a minimal OFF quad-face mesh (2 quads, not triangles).
|
|
auto path = tmp_path("quad.off");
|
|
{
|
|
std::ofstream ofs(path);
|
|
ofs << "OFF\n6 2 0\n"
|
|
<< "0 0 0\n1 0 0\n1 1 0\n0 1 0\n"
|
|
<< "2 0 0\n2 1 0\n"
|
|
<< "4 0 1 2 3\n"
|
|
<< "4 1 4 5 2\n";
|
|
}
|
|
EXPECT_THROW(load_mesh(path), std::runtime_error);
|
|
rm(path);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// Vertex positions survive a round-trip (OFF)
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
TEST(MeshIO, OFF_VertexPositionsPreserved)
|
|
{
|
|
auto path = tmp_path("off");
|
|
rm(path);
|
|
|
|
auto mesh_out = make_tetrahedron();
|
|
ASSERT_TRUE(write_mesh(path, mesh_out));
|
|
|
|
ConformalMesh mesh_in;
|
|
ASSERT_TRUE(read_mesh(path, mesh_in));
|
|
|
|
// Collect and sort vertex positions from both meshes to compare
|
|
auto collect_sorted = [](const ConformalMesh& m) {
|
|
std::vector<std::array<double,3>> pts;
|
|
for (auto v : m.vertices()) {
|
|
auto p = m.point(v);
|
|
pts.push_back({CGAL::to_double(p.x()),
|
|
CGAL::to_double(p.y()),
|
|
CGAL::to_double(p.z())});
|
|
}
|
|
std::sort(pts.begin(), pts.end());
|
|
return pts;
|
|
};
|
|
|
|
auto pts_out = collect_sorted(mesh_out);
|
|
auto pts_in = collect_sorted(mesh_in);
|
|
|
|
ASSERT_EQ(pts_out.size(), pts_in.size());
|
|
for (std::size_t i = 0; i < pts_out.size(); ++i) {
|
|
EXPECT_NEAR(pts_out[i][0], pts_in[i][0], 1e-10);
|
|
EXPECT_NEAR(pts_out[i][1], pts_in[i][1], 1e-10);
|
|
EXPECT_NEAR(pts_out[i][2], pts_in[i][2], 1e-10);
|
|
}
|
|
|
|
rm(path);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// save_mesh / load_mesh convenience wrappers
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
TEST(MeshIO, SaveLoadConvenienceWrappers)
|
|
{
|
|
auto path = tmp_path("off");
|
|
rm(path);
|
|
|
|
auto mesh_out = make_quad_strip();
|
|
EXPECT_NO_THROW(save_mesh(path, mesh_out));
|
|
|
|
ConformalMesh mesh_in;
|
|
EXPECT_NO_THROW(mesh_in = load_mesh(path));
|
|
|
|
EXPECT_EQ(mesh_in.number_of_vertices(), mesh_out.number_of_vertices());
|
|
|
|
rm(path);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// V3: load_mesh throws on non-finite vertex coordinate (NaN / Inf)
|
|
//
|
|
// A mesh file containing NaN or Inf vertex coordinates must be rejected at
|
|
// the I/O boundary — before the coordinates can silently poison the solver.
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
TEST(MeshIO, LoadMeshThrowsOnNaNCoordinate)
|
|
{
|
|
// Write a minimal OFF triangle with a NaN in the first vertex.
|
|
auto path = tmp_path("nan.off");
|
|
{
|
|
std::ofstream ofs(path);
|
|
ofs << "OFF\n3 1 0\n"
|
|
<< "nan 0.0 0.0\n"
|
|
<< "1.0 0.0 0.0\n"
|
|
<< "0.0 1.0 0.0\n"
|
|
<< "3 0 1 2\n";
|
|
}
|
|
EXPECT_THROW(load_mesh(path), std::runtime_error);
|
|
rm(path);
|
|
}
|
|
|
|
TEST(MeshIO, LoadMeshThrowsOnInfCoordinate)
|
|
{
|
|
// Write a minimal OFF triangle with Inf in the y-coordinate.
|
|
auto path = tmp_path("inf.off");
|
|
{
|
|
std::ofstream ofs(path);
|
|
ofs << "OFF\n3 1 0\n"
|
|
<< "0.0 0.0 0.0\n"
|
|
<< "1.0 inf 0.0\n"
|
|
<< "0.0 1.0 0.0\n"
|
|
<< "3 0 1 2\n";
|
|
}
|
|
EXPECT_THROW(load_mesh(path), std::runtime_error);
|
|
rm(path);
|
|
}
|