diff --git a/src/ifcviewer-wgpu/CMakeLists.txt b/src/ifcviewer-wgpu/CMakeLists.txt
index 899fac90f3..3c21817fe0 100644
--- a/src/ifcviewer-wgpu/CMakeLists.txt
+++ b/src/ifcviewer-wgpu/CMakeLists.txt
@@ -156,3 +156,7 @@ if(NOT WIN32)
else()
install(FILES "${wgpu_native_SOURCE_DIR}/lib/${_wgpu_runtime}" DESTINATION bin)
endif()
+
+if(BUILD_BONSAIVIEWER_TESTS)
+ add_subdirectory(tests)
+endif()
diff --git a/src/ifcviewer-wgpu/tests/CMakeLists.txt b/src/ifcviewer-wgpu/tests/CMakeLists.txt
new file mode 100644
index 0000000000..65de72e9d9
--- /dev/null
+++ b/src/ifcviewer-wgpu/tests/CMakeLists.txt
@@ -0,0 +1,35 @@
+################################################################################
+# #
+# 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 unit tests for the wgpu backend's pure-CPU state machines (selection,
+# visibility). Header-only subjects — the test binaries compile against the
+# class definitions directly, no link to IfcViewerWgpu needed and no
+# wgpu-native runtime involvement.
+
+set(IFCVIEWER_WGPU_SRC ${CMAKE_CURRENT_SOURCE_DIR}/..)
+
+function(add_ifcviewer_wgpu_unit_test name)
+ add_executable(${name} ${name}.cpp)
+ target_include_directories(${name} PRIVATE ${IFCVIEWER_WGPU_SRC})
+ target_link_libraries(${name} PRIVATE Catch2::Catch2WithMain)
+ catch_discover_tests(${name})
+endfunction()
+
+add_ifcviewer_wgpu_unit_test(test_wgpu_selection)
+add_ifcviewer_wgpu_unit_test(test_wgpu_visibility)
diff --git a/src/ifcviewer-wgpu/tests/test_wgpu_selection.cpp b/src/ifcviewer-wgpu/tests/test_wgpu_selection.cpp
new file mode 100644
index 0000000000..a53ce0132c
--- /dev/null
+++ b/src/ifcviewer-wgpu/tests/test_wgpu_selection.cpp
@@ -0,0 +1,256 @@
+/********************************************************************************
+ * *
+ * 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 WgpuSelectionState — the CPU-side selection set + active
+// id used by the wgpu viewport. The class is pure stdlib (no Qt, no QObject),
+// so the test exercises the state machine directly. The GPU-side flags SSBO
+// is filled via fillFlagsArray; that pure-function path is also covered.
+
+#include "WgpuSelectionState.h"
+
+#include
+
+#include
+
+TEST_CASE("WgpuSelectionState starts empty with no active id", "[wgpu-selection]") {
+ WgpuSelectionState sel;
+ REQUIRE(sel.count() == 0);
+ REQUIRE(sel.activeId() == 0);
+ REQUIRE_FALSE(sel.contains(1));
+ REQUIRE_FALSE(sel.dirty());
+}
+
+TEST_CASE("replace(id) selects a single id and makes it active",
+ "[wgpu-selection]") {
+ WgpuSelectionState sel;
+
+ sel.replace(5);
+ REQUIRE(sel.count() == 1);
+ REQUIRE(sel.contains(5));
+ REQUIRE(sel.activeId() == 5);
+ REQUIRE(sel.dirty());
+}
+
+TEST_CASE("replace(0) clears the selection", "[wgpu-selection]") {
+ WgpuSelectionState sel;
+ sel.replace(5);
+ sel.markClean();
+
+ sel.replace(0);
+ REQUIRE(sel.count() == 0);
+ REQUIRE(sel.activeId() == 0);
+ REQUIRE(sel.dirty());
+}
+
+TEST_CASE("add(id) appends to the set and steals active", "[wgpu-selection]") {
+ WgpuSelectionState sel;
+ sel.replace(1);
+ sel.markClean();
+
+ sel.add(2);
+ REQUIRE(sel.count() == 2);
+ REQUIRE(sel.contains(1));
+ REQUIRE(sel.contains(2));
+ // Each click should drive the properties panel to the most recently
+ // touched object, so active follows the last add — distinct from GL's
+ // addToSelection (which kept the prior active).
+ REQUIRE(sel.activeId() == 2);
+ REQUIRE(sel.dirty());
+}
+
+TEST_CASE("add(0) is ignored", "[wgpu-selection]") {
+ WgpuSelectionState sel;
+ sel.replace(1);
+ sel.markClean();
+
+ sel.add(0);
+ REQUIRE(sel.count() == 1);
+ REQUIRE(sel.activeId() == 1);
+ REQUIRE_FALSE(sel.dirty()); // no-op didn't flip the flag
+}
+
+TEST_CASE("remove(non-active) keeps active", "[wgpu-selection]") {
+ WgpuSelectionState sel;
+ sel.replace(1);
+ sel.add(2);
+ sel.add(3);
+ REQUIRE(sel.activeId() == 3);
+ sel.markClean();
+
+ sel.remove(1);
+ REQUIRE(sel.count() == 2);
+ REQUIRE_FALSE(sel.contains(1));
+ REQUIRE(sel.activeId() == 3); // still the most recently touched
+ REQUIRE(sel.dirty());
+}
+
+TEST_CASE("remove(active) falls back to some remaining id", "[wgpu-selection]") {
+ WgpuSelectionState sel;
+ sel.replace(1);
+ sel.add(2);
+ sel.add(3);
+ REQUIRE(sel.activeId() == 3);
+
+ sel.remove(3);
+ REQUIRE(sel.count() == 2);
+ REQUIRE_FALSE(sel.contains(3));
+ // Active falls back to *some* remaining id (implementation picks the
+ // unordered_set's first element; documenting non-determinism rather
+ // than the specific choice).
+ const uint32_t a = sel.activeId();
+ REQUIRE((a == 1 || a == 2));
+ REQUIRE(sel.contains(a));
+}
+
+TEST_CASE("remove(last id) clears active", "[wgpu-selection]") {
+ WgpuSelectionState sel;
+ sel.replace(7);
+ REQUIRE(sel.activeId() == 7);
+
+ sel.remove(7);
+ REQUIRE(sel.count() == 0);
+ REQUIRE(sel.activeId() == 0);
+}
+
+TEST_CASE("remove(non-existent) is a no-op for state, no dirty flag",
+ "[wgpu-selection]") {
+ WgpuSelectionState sel;
+ sel.replace(1);
+ sel.markClean();
+
+ sel.remove(99);
+ REQUIRE(sel.count() == 1);
+ REQUIRE(sel.activeId() == 1);
+ REQUIRE_FALSE(sel.dirty());
+}
+
+TEST_CASE("remove(0) is ignored", "[wgpu-selection]") {
+ WgpuSelectionState sel;
+ sel.replace(1);
+ sel.markClean();
+
+ sel.remove(0);
+ REQUIRE(sel.count() == 1);
+ REQUIRE_FALSE(sel.dirty());
+}
+
+TEST_CASE("toggle adds when absent, removes when present", "[wgpu-selection]") {
+ WgpuSelectionState sel;
+
+ sel.toggle(5);
+ REQUIRE(sel.contains(5));
+ REQUIRE(sel.activeId() == 5);
+
+ sel.toggle(6);
+ REQUIRE(sel.contains(6));
+ REQUIRE(sel.activeId() == 6); // last add steals active
+
+ sel.toggle(5); // remove non-active — active unchanged
+ REQUIRE_FALSE(sel.contains(5));
+ REQUIRE(sel.activeId() == 6);
+
+ sel.toggle(6); // remove active — fallback (set empty → 0)
+ REQUIRE(sel.count() == 0);
+ REQUIRE(sel.activeId() == 0);
+}
+
+TEST_CASE("toggle(0) is ignored", "[wgpu-selection]") {
+ WgpuSelectionState sel;
+ sel.markClean();
+
+ sel.toggle(0);
+ REQUIRE(sel.count() == 0);
+ REQUIRE_FALSE(sel.dirty());
+}
+
+TEST_CASE("clear empties; no-op when already empty", "[wgpu-selection]") {
+ WgpuSelectionState sel;
+
+ sel.clear(); // already empty
+ REQUIRE_FALSE(sel.dirty());
+
+ sel.replace(1);
+ sel.markClean();
+ sel.clear();
+ REQUIRE(sel.count() == 0);
+ REQUIRE(sel.activeId() == 0);
+ REQUIRE(sel.dirty());
+}
+
+TEST_CASE("markClean clears the dirty flag", "[wgpu-selection]") {
+ WgpuSelectionState sel;
+ sel.replace(5);
+ REQUIRE(sel.dirty());
+
+ sel.markClean();
+ REQUIRE_FALSE(sel.dirty());
+
+ // Subsequent mutation re-arms the flag.
+ sel.add(6);
+ REQUIRE(sel.dirty());
+}
+
+TEST_CASE("selectionIds returns the live set", "[wgpu-selection]") {
+ WgpuSelectionState sel;
+ sel.add(1);
+ sel.add(2);
+ sel.add(3);
+
+ const auto& ids = sel.selectionIds();
+ REQUIRE(ids.size() == 3);
+ REQUIRE(ids.count(1) == 1);
+ REQUIRE(ids.count(2) == 1);
+ REQUIRE(ids.count(3) == 1);
+}
+
+TEST_CASE("fillFlagsArray packs selected/active bits per object_id",
+ "[wgpu-selection]") {
+ WgpuSelectionState sel;
+ sel.add(1);
+ sel.add(3); // active is now 3
+
+ std::vector flags;
+ sel.fillFlagsArray(flags, 8);
+
+ REQUIRE(flags.size() == 8);
+ REQUIRE(flags[0] == 0u); // sentinel
+ REQUIRE(flags[1] == 1u); // selected, not active
+ REQUIRE(flags[2] == 0u);
+ REQUIRE(flags[3] == (1u | 2u)); // selected + active
+ REQUIRE(flags[4] == 0u);
+ REQUIRE(flags[5] == 0u);
+ REQUIRE(flags[6] == 0u);
+ REQUIRE(flags[7] == 0u);
+}
+
+TEST_CASE("fillFlagsArray drops ids past the entries cap",
+ "[wgpu-selection]") {
+ WgpuSelectionState sel;
+ sel.add(1);
+ sel.add(100); // active is 100
+
+ std::vector flags;
+ sel.fillFlagsArray(flags, 4);
+
+ REQUIRE(flags.size() == 4);
+ REQUIRE(flags[1] == 1u);
+ // id 100 is out of range — should not write to flags[2]/[3]/etc.
+ REQUIRE(flags[2] == 0u);
+ REQUIRE(flags[3] == 0u);
+}
diff --git a/src/ifcviewer-wgpu/tests/test_wgpu_visibility.cpp b/src/ifcviewer-wgpu/tests/test_wgpu_visibility.cpp
new file mode 100644
index 0000000000..39307ebf5a
--- /dev/null
+++ b/src/ifcviewer-wgpu/tests/test_wgpu_visibility.cpp
@@ -0,0 +1,118 @@
+/********************************************************************************
+ * *
+ * 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 WgpuVisibilityState — the per-element hidden-id set
+// consulted in cull. The class is pure stdlib; this test exercises its
+// primitives directly. Bulk hide/isolate/show-all semantics live in
+// WgpuViewportWindow (which composes WgpuVisibilityState + the model
+// instance lists) and would need an integration test, not a Tier-1 unit.
+
+#include "WgpuVisibilityState.h"
+
+#include
+
+TEST_CASE("WgpuVisibilityState starts empty", "[wgpu-visibility]") {
+ WgpuVisibilityState vis;
+ REQUIRE(vis.hiddenCount() == 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("hide(id) records the id; isHidden reflects it",
+ "[wgpu-visibility]") {
+ WgpuVisibilityState vis;
+
+ vis.hide(1);
+ vis.hide(2);
+ vis.hide(3);
+
+ REQUIRE(vis.hiddenCount() == 3);
+ REQUIRE(vis.isHidden(1));
+ REQUIRE(vis.isHidden(2));
+ REQUIRE(vis.isHidden(3));
+ REQUIRE_FALSE(vis.isHidden(4));
+}
+
+TEST_CASE("hide is idempotent", "[wgpu-visibility]") {
+ WgpuVisibilityState vis;
+
+ vis.hide(5);
+ vis.hide(5);
+ vis.hide(5);
+
+ REQUIRE(vis.hiddenCount() == 1);
+ REQUIRE(vis.isHidden(5));
+}
+
+TEST_CASE("hide(0) is ignored", "[wgpu-visibility]") {
+ WgpuVisibilityState vis;
+
+ vis.hide(0);
+ REQUIRE(vis.hiddenCount() == 0);
+ REQUIRE_FALSE(vis.isHidden(0));
+}
+
+TEST_CASE("show(id) removes a previously hidden id",
+ "[wgpu-visibility]") {
+ WgpuVisibilityState vis;
+ vis.hide(1);
+ vis.hide(2);
+
+ vis.show(1);
+ REQUIRE(vis.hiddenCount() == 1);
+ REQUIRE_FALSE(vis.isHidden(1));
+ REQUIRE(vis.isHidden(2));
+}
+
+TEST_CASE("show(non-hidden) is a no-op", "[wgpu-visibility]") {
+ WgpuVisibilityState vis;
+ vis.hide(1);
+
+ vis.show(99); // never hidden
+ REQUIRE(vis.hiddenCount() == 1);
+ REQUIRE(vis.isHidden(1));
+}
+
+TEST_CASE("clear() drops every hidden id", "[wgpu-visibility]") {
+ WgpuVisibilityState vis;
+ vis.hide(1);
+ vis.hide(2);
+ vis.hide(3);
+
+ vis.clear();
+ REQUIRE(vis.hiddenCount() == 0);
+ REQUIRE_FALSE(vis.isHidden(1));
+ REQUIRE_FALSE(vis.isHidden(2));
+ REQUIRE_FALSE(vis.isHidden(3));
+}
+
+TEST_CASE("hiddenIds returns the live set", "[wgpu-visibility]") {
+ WgpuVisibilityState vis;
+ vis.hide(10);
+ vis.hide(20);
+ vis.hide(30);
+
+ const auto& ids = vis.hiddenIds();
+ REQUIRE(ids.size() == 3);
+ REQUIRE(ids.count(10) == 1);
+ REQUIRE(ids.count(20) == 1);
+ REQUIRE(ids.count(30) == 1);
+ REQUIRE(ids.count(40) == 0);
+}