From 102ac551b3f5b94cc68db27f3e66a6350690b134 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 21 May 2026 10:30:57 +1000 Subject: [PATCH] Add VisibilityState/SelectionState tests; test real quantization helpers test_instanced_geometry previously re-implemented vertex quantization inline, with a stale comment claiming the helpers still lived in ViewportWindow.cpp. They now live in VertexQuantization.h, so route the test through the real quantizeVertex/octEncodeNormal and add coverage for the degenerate-axis path, octahedral normal round-trip, the i8 normal error bound (~0.78 deg worst observed), and color passthrough. Add test_visibility and test_selection: Tier-1 coverage of the two per-object viewport state machines. Both are QObjects for their changed() signal but touch no GL on the construction/mutation path, so the tests exercise the pure CPU logic without a context. Suite goes from 39 to 61 cases. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer/tests/CMakeLists.txt | 42 +++- .../tests/test_instanced_geometry.cpp | 206 +++++++++++++--- src/ifcviewer/tests/test_selection.cpp | 230 ++++++++++++++++++ src/ifcviewer/tests/test_visibility.cpp | 180 ++++++++++++++ 4 files changed, 626 insertions(+), 32 deletions(-) create mode 100644 src/ifcviewer/tests/test_selection.cpp create mode 100644 src/ifcviewer/tests/test_visibility.cpp diff --git a/src/ifcviewer/tests/CMakeLists.txt b/src/ifcviewer/tests/CMakeLists.txt index 58095a814d..2a74ef0c55 100644 --- a/src/ifcviewer/tests/CMakeLists.txt +++ b/src/ifcviewer/tests/CMakeLists.txt @@ -50,10 +50,11 @@ add_ifcviewer_unit_test(test_sidecar_cache add_ifcviewer_unit_test(test_instanced_geometry) -# Federation is Qt-derived (QObject + signals + QVector3D + QJson*). Unlike -# the other Tier-1 tests it has to pull Qt6::Core/Gui/Test in directly and -# enable AUTOMOC for the Q_OBJECT moc-generation. -find_package(Qt${QT_VERSION} COMPONENTS Core Gui Test REQUIRED PATHS ${QT_DIR}) +# Federation, Visibility and Selection are Qt-derived (QObject + signals). +# Unlike the other Tier-1 tests they have to pull Qt6 in directly and enable +# AUTOMOC for the Q_OBJECT moc-generation. OpenGL is needed only by the +# Selection test (Selection.cpp references QOpenGLFunctions_4_5_Core). +find_package(Qt${QT_VERSION} COMPONENTS Core Gui Test OpenGL REQUIRED PATHS ${QT_DIR}) find_package(Eigen3 REQUIRED) @@ -81,3 +82,36 @@ target_link_libraries(test_federation PRIVATE IfcParse # Unit.cpp uses express::Base / file APIs ) catch_discover_tests(test_federation) + +# VisibilityState / SelectionState are QObjects (for their changed() signal) +# but their construction + mutation API touch no GL, so the tests exercise +# the pure CPU state machine without ever creating a context. Selection.cpp +# still references QOpenGLFunctions_4_5_Core, so test_selection has to link +# Qt6::OpenGL even though no GL call is reached at runtime. +add_executable(test_visibility + test_visibility.cpp + ${IFCVIEWER_SRC}/Visibility.cpp +) +set_target_properties(test_visibility PROPERTIES AUTOMOC ON) +target_include_directories(test_visibility PRIVATE ${IFCVIEWER_SRC}) +target_link_libraries(test_visibility PRIVATE + Catch2::Catch2WithMain + Qt${QT_VERSION}::Core + Qt${QT_VERSION}::Test # QSignalSpy +) +catch_discover_tests(test_visibility) + +add_executable(test_selection + test_selection.cpp + ${IFCVIEWER_SRC}/Selection.cpp +) +set_target_properties(test_selection PROPERTIES AUTOMOC ON) +target_include_directories(test_selection PRIVATE ${IFCVIEWER_SRC}) +target_link_libraries(test_selection PRIVATE + Catch2::Catch2WithMain + Qt${QT_VERSION}::Core + Qt${QT_VERSION}::Gui # QtOpenGL depends on QtGui + Qt${QT_VERSION}::OpenGL # Selection.cpp: QOpenGLFunctions_4_5_Core + Qt${QT_VERSION}::Test # QSignalSpy +) +catch_discover_tests(test_selection) diff --git a/src/ifcviewer/tests/test_instanced_geometry.cpp b/src/ifcviewer/tests/test_instanced_geometry.cpp index 2903811706..267fe2de20 100644 --- a/src/ifcviewer/tests/test_instanced_geometry.cpp +++ b/src/ifcviewer/tests/test_instanced_geometry.cpp @@ -17,23 +17,58 @@ * * ********************************************************************************/ -// Tier-1 coverage of the instanced-geometry GPU/sidecar layout. +// Tier-1 coverage of the instanced-geometry GPU/sidecar layout and the +// vertex quantization used to fill it. // -// The production quantization helpers currently live inside -// ViewportWindow.cpp (see uploadMeshChunk). Once they're factored out into a -// reusable header (planned tier-3 prerequisite) this test will exercise the -// real implementation directly. For now we cover: +// quantizeVertex / octEncodeNormal (VertexQuantization.h) are the shared +// production helpers: ViewportWindow::uploadMeshChunk and SidecarBuilder both +// route through them so the rendered VBO and the on-disk .ifcview record are +// byte-identical. The tests exercise that real implementation directly: // - runtime size/alignment assertions (defense in depth for the static_asserts) -// - documented constants form a self-consistent layout -// - a reference position-quantization round-trip that pins the error bound -// declared in InstancedGeometry.h ("dequant to mix(aabb_min, aabb_max, t)") +// - documented INSTANCED_VERTEX_* constants form a self-consistent layout +// - position quantization round-trips within the u16-grid error bound +// - octahedral normal encode/decode round-trips, and the i8-packed normal +// written by quantizeVertex stays within its documented angular error #include "InstancedGeometry.h" +#include "VertexQuantization.h" #include #include #include +#include + +namespace { + +// Inverse of octEncodeNormal: square [-1,1]^2 -> unit sphere. The test owns +// the decode (the production header only ships the encoder, since the GPU +// shader does the decode); it is the standard Meyer et al. octahedral unfold. +void octDecodeNormal(const float e[2], float out[3]) { + float x = e[0]; + float y = e[1]; + float z = 1.0f - std::fabs(x) - std::fabs(y); + if (z < 0.0f) { + float ox = (1.0f - std::fabs(y)) * (x >= 0.0f ? 1.0f : -1.0f); + float oy = (1.0f - std::fabs(x)) * (y >= 0.0f ? 1.0f : -1.0f); + x = ox; + y = oy; + } + float len = std::sqrt(x * x + y * y + z * z); + out[0] = x / len; + out[1] = y / len; + out[2] = z / len; +} + +// Angle (degrees) between two unit-ish vectors. +float angleDeg(const float a[3], const float b[3]) { + float dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + if (dot > 1.0f) dot = 1.0f; + if (dot < -1.0f) dot = -1.0f; + return std::acos(dot) * (180.0f / 3.14159265358979323846f); +} + +} // namespace TEST_CASE("Instanced GPU/CPU struct sizes match the wire format", "[instgeom]") { REQUIRE(sizeof(MeshGpu) == 32); @@ -53,41 +88,47 @@ TEST_CASE("INSTANCED_VERTEX_* constants are self-consistent", "[instgeom]") { REQUIRE(INSTANCED_VERTEX_STRIDE_FLOATS == 7); } -TEST_CASE("Position quantization round-trips within the documented error bound", "[instgeom]") { +TEST_CASE("quantizeVertex round-trips position within the documented error bound", + "[instgeom]") { // The quantization basis is per-mesh: t = (p - min) / (max - min) packed - // into u16, and dequantized as p' = min + t * (max - min). The round-trip - // error per axis is at most (max - min) / 65535 (one ulp of the u16 grid). - const float aabb_min[3] = {-3.5f, 100.25f, -1000.0f}; - const float aabb_max[3] = { 7.5f, 200.25f, 1000.0f}; - const float extent[3] = { + // into u16, dequantized as p' = min + (q / 65535) * (max - min). The + // round-trip error per axis is at most (max - min) / 65535 (one ulp of the + // u16 grid). + const float aabb_min[3] = {-3.5f, 100.25f, -1000.0f}; + const float aabb_max[3] = { 7.5f, 200.25f, 1000.0f}; + const float extent[3] = { aabb_max[0] - aabb_min[0], aabb_max[1] - aabb_min[1], aabb_max[2] - aabb_min[2], }; + const float extent_recip[3] = { + 1.0f / extent[0], 1.0f / extent[1], 1.0f / extent[2], + }; constexpr int kSamples = 65; float worst_err = 0.0f; for (int s = 0; s <= kSamples; ++s) { float t = float(s) / float(kSamples); + + // A streamer-format vertex: pos3 + normal3 + color-as-float. + float src[INSTANCED_VERTEX_STRIDE_FLOATS] = {0}; + for (int a = 0; a < 3; ++a) src[a] = aabb_min[a] + t * extent[a]; + src[5] = 1.0f; // arbitrary valid normal (0,0,1) + + uint8_t dst[INSTANCED_VERTEX_STRIDE_BYTES]; + quantizeVertex(src, aabb_min, extent_recip, dst); + + const uint16_t* q = + reinterpret_cast(dst + INSTANCED_VERTEX_POS_OFFSET); for (int a = 0; a < 3; ++a) { - float p = aabb_min[a] + t * extent[a]; - - // Pack (the same formula buildLods/the streamer use against an AABB). - float tt = (p - aabb_min[a]) / extent[a]; - if (tt < 0.0f) tt = 0.0f; - if (tt > 1.0f) tt = 1.0f; - uint16_t q = uint16_t(tt * 65535.0f + 0.5f); - - // Unpack (matches the dequant in LodBuilder.cpp). - float pp = aabb_min[a] + (q / 65535.0f) * extent[a]; - - float err = std::fabs(pp - p); + float pp = aabb_min[a] + (q[a] / 65535.0f) * extent[a]; + float err = std::fabs(pp - src[a]); if (err > worst_err) worst_err = err; } } - // The round-trip error must stay within one u16 ulp of the largest extent, - // with a small float-rounding margin. + // Worst error must stay within one u16 ulp of the largest extent, with a + // small float-rounding margin. float ulp = 0.0f; for (int a = 0; a < 3; ++a) { ulp = std::max(ulp, extent[a] / 65535.0f); @@ -95,6 +136,115 @@ TEST_CASE("Position quantization round-trips within the documented error bound", REQUIRE(worst_err <= ulp * 1.01f); } +TEST_CASE("quantizeVertex handles a degenerate (zero-extent) axis", "[instgeom]") { + // A planar mesh has a flat axis: extent_recip is 0 there (see the header + // contract). Every vertex on that axis must quantize to 0, not NaN. + const float aabb_min[3] = {0.0f, 0.0f, 5.0f}; + const float extent_recip[3] = {1.0f, 1.0f, 0.0f}; // Z is degenerate + + float src[INSTANCED_VERTEX_STRIDE_FLOATS] = {0}; + src[0] = 0.5f; src[1] = 0.25f; src[2] = 5.0f; + src[5] = 1.0f; + + uint8_t dst[INSTANCED_VERTEX_STRIDE_BYTES]; + quantizeVertex(src, aabb_min, extent_recip, dst); + + const uint16_t* q = + reinterpret_cast(dst + INSTANCED_VERTEX_POS_OFFSET); + REQUIRE(q[2] == 0); // degenerate axis collapses to the grid origin +} + +TEST_CASE("octEncodeNormal / octDecodeNormal round-trip unit normals", "[instgeom]") { + // The float-precision oct map is a bijection on the sphere — encode then + // decode must recover the original direction tightly (the i8 packing, + // which adds the real error, is covered separately below). + const float normals[][3] = { + { 1, 0, 0}, {-1, 0, 0}, {0, 1, 0}, {0, -1, 0}, + { 0, 0, 1}, { 0, 0,-1}, // axis-aligned + { 0.5773503f, 0.5773503f, 0.5773503f}, // +++ diagonal + {-0.5773503f, -0.5773503f, -0.5773503f}, // --- diagonal (z < 0 fold) + { 0.7071068f, 0.0f, -0.7071068f}, // z < 0 fold + { 0.2672612f, 0.5345225f, 0.8017837f}, // arbitrary + }; + + for (const auto& n : normals) { + float e[2]; + octEncodeNormal(n, e); + REQUIRE(e[0] >= -1.0f); + REQUIRE(e[0] <= 1.0f); + REQUIRE(e[1] >= -1.0f); + REQUIRE(e[1] <= 1.0f); + + float decoded[3]; + octDecodeNormal(e, decoded); + INFO("normal (" << n[0] << ", " << n[1] << ", " << n[2] << ")"); + REQUIRE(angleDeg(n, decoded) < 0.01f); + } +} + +TEST_CASE("quantizeVertex packs the normal within its documented i8 error bound", + "[instgeom]") { + // quantizeVertex stores the octahedral normal as i8 x 2. The header + // documents "~1.4 deg worst-case error" for that packing; sweep a dense + // set of directions and pin the worst observed error well below a 3 deg + // regression ceiling (a broken encoder is off by tens of degrees). + const float aabb_min[3] = {0, 0, 0}; + const float extent_recip[3] = {1, 1, 1}; + + float worst_err = 0.0f; + constexpr int kSteps = 40; + for (int i = 0; i <= kSteps; ++i) { + for (int j = 0; j <= kSteps; ++j) { + // Spherical sweep over the full sphere. + float theta = 3.14159265f * float(i) / float(kSteps); // polar + float phi = 2.0f * 3.14159265f * float(j) / float(kSteps); // azimuth + float n[3] = { + std::sin(theta) * std::cos(phi), + std::sin(theta) * std::sin(phi), + std::cos(theta), + }; + + float src[INSTANCED_VERTEX_STRIDE_FLOATS] = {0}; + src[3] = n[0]; src[4] = n[1]; src[5] = n[2]; + + uint8_t dst[INSTANCED_VERTEX_STRIDE_BYTES]; + quantizeVertex(src, aabb_min, extent_recip, dst); + + // Decode the stored i8 oct pair back to a direction. + const int8_t* packed = + reinterpret_cast(dst + INSTANCED_VERTEX_NORMAL_OFFSET); + float e[2] = {packed[0] / 127.0f, packed[1] / 127.0f}; + float decoded[3]; + octDecodeNormal(e, decoded); + + worst_err = std::max(worst_err, angleDeg(n, decoded)); + } + } + + INFO("worst i8 octahedral normal error: " << worst_err << " deg"); + REQUIRE(worst_err > 0.0f); // sanity: quantization is actually lossy + REQUIRE(worst_err < 3.0f); // regression ceiling around the documented ~1.4 deg +} + +TEST_CASE("quantizeVertex passes the packed color through unchanged", "[instgeom]") { + // The streamer packs an rgba8 into the 7th float slot; quantizeVertex + // memcpy's those 4 bytes straight into the VBO color field. + const uint8_t rgba[4] = {0x11, 0x22, 0x33, 0x44}; + float color_as_float; + std::memcpy(&color_as_float, rgba, 4); + + const float aabb_min[3] = {0, 0, 0}; + const float extent_recip[3] = {1, 1, 1}; + float src[INSTANCED_VERTEX_STRIDE_FLOATS] = {0}; + src[5] = 1.0f; // valid normal + src[6] = color_as_float; // color slot + + uint8_t dst[INSTANCED_VERTEX_STRIDE_BYTES]; + quantizeVertex(src, aabb_min, extent_recip, dst); + + REQUIRE(std::memcmp(dst + INSTANCED_VERTEX_COLOR_OFFSET, rgba, 4) == 0); +} + TEST_CASE("MeshChunk and InstanceChunk default-init to zeroed metadata", "[instgeom]") { MeshChunk mc; REQUIRE(mc.model_id == 0); diff --git a/src/ifcviewer/tests/test_selection.cpp b/src/ifcviewer/tests/test_selection.cpp new file mode 100644 index 0000000000..c244d2b30c --- /dev/null +++ b/src/ifcviewer/tests/test_selection.cpp @@ -0,0 +1,230 @@ +/******************************************************************************** + * * + * This file is part of IfcOpenShell. * + * * + * IfcOpenShell is free software: you can redistribute it and/or modify * + * it under the terms of the Lesser GNU General Public License as published by * + * the Free Software Foundation, either version 3.0 of the License, or * + * (at your option) any later version. * + * * + * IfcOpenShell is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * Lesser GNU General Public License for more details. * + * * + * You should have received a copy of the Lesser GNU General Public License * + * along with this program. If not, see . * + * * + ********************************************************************************/ + +// Tier-1 coverage of SelectionState — the viewport's multi-selection state +// machine. The class also owns a GL flags SSBO, but every GL path guards on +// a context that initializeGl() has wired up; the test never calls +// initializeGl(), so the CPU-side selection set / active-id logic runs in +// full isolation (gl_ stays null and bindForRender is simply not exercised). + +#include "Selection.h" + +#include + +#include +#include + +namespace { + +// Catch2 owns main(), so QCoreApplication can't live in a TU constructor. +// Lazily construct it (intentionally leaked) the first time any test asks. +void ensureQApp() { + if (QCoreApplication::instance()) return; + static int argc = 1; + static char arg0[] = "test_selection"; + static char* argv[] = {arg0, nullptr}; + new QCoreApplication(argc, argv); +} + +// First arg of the most recent changed(active_id) signal. +uint32_t lastActive(QSignalSpy& spy) { + REQUIRE(spy.count() > 0); + return spy.takeLast().at(0).toUInt(); +} + +} // namespace + +TEST_CASE("SelectionState starts empty with no active id", "[selection]") { + ensureQApp(); + SelectionState sel; + REQUIRE(sel.empty()); + REQUIRE(sel.size() == 0); + REQUIRE(sel.activeObjectId() == 0); + REQUIRE_FALSE(sel.isSelected(1)); +} + +TEST_CASE("setSelectedObjectId selects a single id and makes it active", + "[selection]") { + ensureQApp(); + SelectionState sel; + QSignalSpy spy(&sel, &SelectionState::changed); + + sel.setSelectedObjectId(5); + REQUIRE(spy.count() == 1); + REQUIRE(sel.size() == 1); + REQUIRE(sel.isSelected(5)); + REQUIRE(sel.activeObjectId() == 5); + REQUIRE(lastActive(spy) == 5); +} + +TEST_CASE("setSelectedObjectId(0) clears the selection", "[selection]") { + ensureQApp(); + SelectionState sel; + sel.setSelectedObjectId(5); + + QSignalSpy spy(&sel, &SelectionState::changed); + sel.setSelectedObjectId(0); + REQUIRE(spy.count() == 1); + REQUIRE(sel.empty()); + REQUIRE(sel.activeObjectId() == 0); + REQUIRE(lastActive(spy) == 0); +} + +TEST_CASE("setSelection coerces active to 0 when it is not in the set", + "[selection]") { + ensureQApp(); + SelectionState sel; + + sel.setSelection({1, 2, 3}, /*active=*/9); // 9 not in the set + REQUIRE(sel.size() == 3); + REQUIRE(sel.activeObjectId() == 0); + + sel.setSelection({1, 2, 3}, /*active=*/2); // 2 is in the set + REQUIRE(sel.activeObjectId() == 2); +} + +TEST_CASE("setSelection drops object_id 0 and no-ops on identical state", + "[selection]") { + ensureQApp(); + SelectionState sel; + QSignalSpy spy(&sel, &SelectionState::changed); + + sel.setSelection({0, 1, 2}, /*active=*/1); + REQUIRE(spy.count() == 1); + REQUIRE(sel.size() == 2); // id 0 stripped + REQUIRE_FALSE(sel.isSelected(0)); + REQUIRE(sel.activeObjectId() == 1); + + // Same set + same active — no churn. + sel.setSelection({1, 2}, /*active=*/1); + REQUIRE(spy.count() == 1); +} + +TEST_CASE("addToSelection adds ids, keeps active, ignores 0 and no-ops", + "[selection]") { + ensureQApp(); + SelectionState sel; + sel.setSelectedObjectId(1); + + QSignalSpy spy(&sel, &SelectionState::changed); + sel.addToSelection({2, 3}); + REQUIRE(spy.count() == 1); + REQUIRE(sel.size() == 3); + REQUIRE(sel.activeObjectId() == 1); // active unchanged by add + REQUIRE(lastActive(spy) == 1); + + // Nothing new -> no signal. + sel.addToSelection({2}); + REQUIRE(spy.count() == 0); + + // id 0 is never added. + sel.addToSelection({0}); + REQUIRE(spy.count() == 0); + REQUIRE_FALSE(sel.isSelected(0)); + REQUIRE(sel.size() == 3); +} + +TEST_CASE("removeFromSelection clears active when the active id is removed", + "[selection]") { + ensureQApp(); + SelectionState sel; + sel.setSelection({1, 2, 3}, /*active=*/2); + + QSignalSpy spy(&sel, &SelectionState::changed); + + // Removing a non-active id keeps the active id. + sel.removeFromSelection({1}); + REQUIRE(spy.count() == 1); + REQUIRE(sel.size() == 2); + REQUIRE(sel.activeObjectId() == 2); + + // Removing the active id drops the active. + sel.removeFromSelection({2}); + REQUIRE(spy.count() == 2); + REQUIRE(sel.activeObjectId() == 0); + REQUIRE(sel.isSelected(3)); + + // Removing something absent -> no signal. + sel.removeFromSelection({99}); + REQUIRE(spy.count() == 2); +} + +TEST_CASE("toggleInSelection adds-as-active then removes-and-clears-active", + "[selection]") { + ensureQApp(); + SelectionState sel; + QSignalSpy spy(&sel, &SelectionState::changed); + + sel.toggleInSelection(5); // add + REQUIRE(sel.isSelected(5)); + REQUIRE(sel.activeObjectId() == 5); // last-toggled becomes active + + sel.toggleInSelection(6); // add — active follows the click + REQUIRE(sel.isSelected(6)); + REQUIRE(sel.activeObjectId() == 6); + + sel.toggleInSelection(5); // remove non-active — active unchanged + REQUIRE_FALSE(sel.isSelected(5)); + REQUIRE(sel.activeObjectId() == 6); + + sel.toggleInSelection(6); // remove the active — active cleared + REQUIRE(sel.empty()); + REQUIRE(sel.activeObjectId() == 0); + + REQUIRE(spy.count() == 4); + + // id 0 is never toggled. + spy.clear(); + sel.toggleInSelection(0); + REQUIRE(spy.count() == 0); + REQUIRE(sel.empty()); +} + +TEST_CASE("clearSelection empties the set; no-op when already empty", + "[selection]") { + ensureQApp(); + SelectionState sel; + QSignalSpy spy(&sel, &SelectionState::changed); + + sel.clearSelection(); // already empty + REQUIRE(spy.count() == 0); + + sel.setSelectedObjectId(1); + REQUIRE(spy.count() == 1); + sel.clearSelection(); + REQUIRE(spy.count() == 2); + REQUIRE(sel.empty()); + REQUIRE(sel.activeObjectId() == 0); +} + +TEST_CASE("reset clears state and emits only when state existed", "[selection]") { + ensureQApp(); + SelectionState sel; + QSignalSpy spy(&sel, &SelectionState::changed); + + sel.reset(); // nothing to clear + REQUIRE(spy.count() == 0); + + sel.setSelectedObjectId(7); + REQUIRE(spy.count() == 1); + sel.reset(); + REQUIRE(spy.count() == 2); + REQUIRE(sel.empty()); + REQUIRE(sel.activeObjectId() == 0); +} diff --git a/src/ifcviewer/tests/test_visibility.cpp b/src/ifcviewer/tests/test_visibility.cpp new file mode 100644 index 0000000000..821875bd36 --- /dev/null +++ b/src/ifcviewer/tests/test_visibility.cpp @@ -0,0 +1,180 @@ +/******************************************************************************** + * * + * This file is part of IfcOpenShell. * + * * + * IfcOpenShell is free software: you can redistribute it and/or modify * + * it under the terms of the Lesser GNU General Public License as published by * + * the Free Software Foundation, either version 3.0 of the License, or * + * (at your option) any later version. * + * * + * IfcOpenShell is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * Lesser GNU General Public License for more details. * + * * + * You should have received a copy of the Lesser GNU General Public License * + * along with this program. If not, see . * + * * + ********************************************************************************/ + +// Tier-1 coverage of VisibilityState — the per-element hidden-set tracker. +// It is a QObject (for the changed() signal) but touches no GL, so the test +// exercises the full state machine directly and asserts the hot-path +// isHidden() flag mirror stays consistent with the canonical hidden set. + +#include "Visibility.h" + +#include + +#include +#include + +namespace { + +// Catch2 owns main(), so QCoreApplication can't live in a TU constructor. +// Lazily construct it (intentionally leaked) the first time any test asks. +void ensureQApp() { + if (QCoreApplication::instance()) return; + static int argc = 1; + static char arg0[] = "test_visibility"; + static char* argv[] = {arg0, nullptr}; + new QCoreApplication(argc, argv); +} + +} // namespace + +TEST_CASE("VisibilityState starts empty", "[visibility]") { + ensureQApp(); + VisibilityState vis; + REQUIRE(vis.empty()); + REQUIRE(vis.size() == 0); + REQUIRE_FALSE(vis.isHidden(0)); // 0 is the "no object" sentinel + REQUIRE_FALSE(vis.isHidden(1)); + REQUIRE_FALSE(vis.isHidden(1u << 20)); +} + +TEST_CASE("hideObjects unions ids, emits changed, and isHidden reflects it", + "[visibility]") { + ensureQApp(); + VisibilityState vis; + QSignalSpy spy(&vis, &VisibilityState::changed); + + vis.hideObjects({1, 2, 3}); + REQUIRE(spy.count() == 1); + REQUIRE(vis.size() == 3); + REQUIRE(vis.isHidden(1)); + REQUIRE(vis.isHidden(2)); + REQUIRE(vis.isHidden(3)); + REQUIRE_FALSE(vis.isHidden(4)); + + // Unioning in a fresh id signals once more and grows the set. + vis.hideObjects({2, 4}); // 2 already hidden, 4 is new + REQUIRE(spy.count() == 2); + REQUIRE(vis.size() == 4); + REQUIRE(vis.isHidden(4)); +} + +TEST_CASE("hideObjects ignores object_id 0 and is idempotent", "[visibility]") { + ensureQApp(); + VisibilityState vis; + QSignalSpy spy(&vis, &VisibilityState::changed); + + vis.hideObjects({0}); + REQUIRE(vis.empty()); + REQUIRE(spy.count() == 0); // nothing changed + REQUIRE_FALSE(vis.isHidden(0)); + + vis.hideObjects({7}); + REQUIRE(spy.count() == 1); + vis.hideObjects({7}); // already hidden — no-op + REQUIRE(spy.count() == 1); +} + +TEST_CASE("showObjects subtracts and emits only when something changes", + "[visibility]") { + ensureQApp(); + VisibilityState vis; + vis.hideObjects({1, 2, 3}); + + QSignalSpy spy(&vis, &VisibilityState::changed); + vis.showObjects({2}); + REQUIRE(spy.count() == 1); + REQUIRE_FALSE(vis.isHidden(2)); + REQUIRE(vis.isHidden(1)); + REQUIRE(vis.isHidden(3)); + REQUIRE(vis.size() == 2); + + // Showing an id that was never hidden changes nothing. + vis.showObjects({99}); + REQUIRE(spy.count() == 1); +} + +TEST_CASE("setHidden replaces the set wholesale and drops id 0", "[visibility]") { + ensureQApp(); + VisibilityState vis; + vis.hideObjects({1, 2}); + + QSignalSpy spy(&vis, &VisibilityState::changed); + vis.setHidden({3, 4, 0}); + REQUIRE(spy.count() == 1); + REQUIRE(vis.size() == 2); // id 0 was stripped + REQUIRE_FALSE(vis.isHidden(0)); + REQUIRE_FALSE(vis.isHidden(1)); // old members cleared + REQUIRE_FALSE(vis.isHidden(2)); + REQUIRE(vis.isHidden(3)); + REQUIRE(vis.isHidden(4)); + + // Replacing with an identical set is a no-op. + vis.setHidden({3, 4}); + REQUIRE(spy.count() == 1); +} + +TEST_CASE("showAll clears the set; no-op when already empty", "[visibility]") { + ensureQApp(); + VisibilityState vis; + vis.hideObjects({1, 2}); + + QSignalSpy spy(&vis, &VisibilityState::changed); + vis.showAll(); + REQUIRE(spy.count() == 1); + REQUIRE(vis.empty()); + REQUIRE_FALSE(vis.isHidden(1)); + REQUIRE_FALSE(vis.isHidden(2)); + + vis.showAll(); // already empty + REQUIRE(spy.count() == 1); +} + +TEST_CASE("reset clears state and emits only when state existed", "[visibility]") { + ensureQApp(); + VisibilityState vis; + + QSignalSpy spy(&vis, &VisibilityState::changed); + vis.reset(); // nothing to clear + REQUIRE(spy.count() == 0); + + vis.hideObjects({5}); + REQUIRE(spy.count() == 1); + vis.reset(); + REQUIRE(spy.count() == 2); + REQUIRE(vis.empty()); + REQUIRE_FALSE(vis.isHidden(5)); +} + +TEST_CASE("hideObjects grows the flag mirror for large object ids", "[visibility]") { + ensureQApp(); + VisibilityState vis; + + // A high id forces the cpu_flags_ vector (used by the hot-path isHidden) + // to resize. isHidden must report it correctly without going out of + // bounds, and neighbouring ids must stay visible. + const uint32_t big = 1'000'000; + vis.hideObjects({big}); + REQUIRE(vis.isHidden(big)); + REQUIRE_FALSE(vis.isHidden(big - 1)); + REQUIRE_FALSE(vis.isHidden(big + 1)); + REQUIRE(vis.hiddenIds().count(big) == 1); + + vis.noteObjectId(big * 2); // pre-grow only — id stays visible + REQUIRE_FALSE(vis.isHidden(big * 2)); +}