From 8e60dda810b252d72402eeabe38090d6fb3d0557 Mon Sep 17 00:00:00 2001 From: forbes Date: Sat, 14 Feb 2026 10:31:00 -0600 Subject: [PATCH] fix: add missing Kindred test files from origin/main --- tests/run_kindred_tests.py | 103 ++++++ tests/src/Gui/OriginManager.cpp | 382 ++++++++++++++++++++++ tests/test_kindred_pure.py | 560 ++++++++++++++++++++++++++++++++ 3 files changed, 1045 insertions(+) create mode 100644 tests/run_kindred_tests.py create mode 100644 tests/src/Gui/OriginManager.cpp create mode 100644 tests/test_kindred_pure.py diff --git a/tests/run_kindred_tests.py b/tests/run_kindred_tests.py new file mode 100644 index 0000000000..130c3d84e3 --- /dev/null +++ b/tests/run_kindred_tests.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Runner for Kindred Create addon tests. + +Tier 1 (pure logic) tests run with the system Python — no FreeCAD binary +required. Tier 2 (FreeCAD headless) tests are skipped unless FreeCADCmd +is found on PATH. + +Usage: + python3 tests/run_kindred_tests.py # Tier 1 only + python3 tests/run_kindred_tests.py --all # Tier 1 + Tier 2 (needs FreeCADCmd) + +Exit codes: + 0 All tests passed + 1 One or more tests failed +""" + +import os +import shutil +import subprocess +import sys +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def run_pure_tests() -> bool: + """Discover and run Tier 1 pure-logic tests. Returns True on success.""" + loader = unittest.TestLoader() + suite = loader.discover( + start_dir=str(REPO_ROOT / "tests"), + pattern="test_kindred_pure.py", + top_level_dir=str(REPO_ROOT / "tests"), + ) + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + return result.wasSuccessful() + + +def run_freecad_tests() -> bool: + """Run Tier 2 tests inside FreeCADCmd. Returns True on success.""" + freecad_cmd = shutil.which("FreeCADCmd") + if not freecad_cmd: + # Check build directories + for candidate in ( + REPO_ROOT / "build" / "debug" / "bin" / "FreeCADCmd", + REPO_ROOT / "build" / "release" / "bin" / "FreeCADCmd", + ): + if candidate.exists(): + freecad_cmd = str(candidate) + break + + if not freecad_cmd: + print("\n[SKIP] FreeCADCmd not found — skipping Tier 2 tests") + return True # Not a failure, just skipped + + print(f"\n{'=' * 70}") + print(f"Tier 2: FreeCAD headless tests via {freecad_cmd}") + print(f"{'=' * 70}\n") + + # Tier 2 test modules registered via FreeCAD.__unit_test__ + test_modules = ["TestKindredCreate"] + all_ok = True + + for mod in test_modules: + print(f"--- Running {mod} ---") + proc = subprocess.run( + [freecad_cmd, "-t", mod], + cwd=str(REPO_ROOT), + timeout=120, + ) + if proc.returncode != 0: + all_ok = False + + return all_ok + + +def main(): + os.chdir(REPO_ROOT) + + run_all = "--all" in sys.argv + + print(f"{'=' * 70}") + print("Tier 1: Pure-logic tests (no FreeCAD binary required)") + print(f"{'=' * 70}\n") + + tier1_ok = run_pure_tests() + + tier2_ok = True + if run_all: + tier2_ok = run_freecad_tests() + + print(f"\n{'=' * 70}") + print(f" Tier 1 (pure): {'PASS' if tier1_ok else 'FAIL'}") + if run_all: + print(f" Tier 2 (FreeCAD): {'PASS' if tier2_ok else 'FAIL'}") + print(f"{'=' * 70}") + + sys.exit(0 if (tier1_ok and tier2_ok) else 1) + + +if __name__ == "__main__": + main() diff --git a/tests/src/Gui/OriginManager.cpp b/tests/src/Gui/OriginManager.cpp new file mode 100644 index 0000000000..e15f2e4336 --- /dev/null +++ b/tests/src/Gui/OriginManager.cpp @@ -0,0 +1,382 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later + +#include + +#include + +#include +#include +#include +#include +#include +#include + +#include + + +namespace +{ + +/** + * Minimal FileOrigin implementation for testing OriginManager. + * All document operations are stubs -- we only need identity, + * capability, and ownership methods to test the manager. + */ +class MockOrigin : public Gui::FileOrigin +{ +public: + explicit MockOrigin(std::string originId, + Gui::OriginType originType = Gui::OriginType::PLM, + bool ownsAll = false) + : _id(std::move(originId)) + , _type(originType) + , _ownsAll(ownsAll) + {} + + // Identity + std::string id() const override { return _id; } + std::string name() const override { return _id + " Name"; } + std::string nickname() const override { return _id; } + QIcon icon() const override { return {}; } + Gui::OriginType type() const override { return _type; } + + // Characteristics + bool tracksExternally() const override { return _type == Gui::OriginType::PLM; } + bool requiresAuthentication() const override { return _type == Gui::OriginType::PLM; } + + // Capabilities + bool supportsRevisions() const override { return _supportsRevisions; } + bool supportsBOM() const override { return _supportsBOM; } + bool supportsPartNumbers() const override { return _supportsPartNumbers; } + + // Document identity + std::string documentIdentity(App::Document* /*doc*/) const override { return {}; } + std::string documentDisplayId(App::Document* /*doc*/) const override { return {}; } + + bool ownsDocument(App::Document* doc) const override + { + if (!doc) { + return false; + } + return _ownsAll; + } + + // Document operations (stubs) + App::Document* newDocument(const std::string& /*name*/) override { return nullptr; } + App::Document* openDocument(const std::string& /*identity*/) override { return nullptr; } + App::Document* openDocumentInteractive() override { return nullptr; } + bool saveDocument(App::Document* /*doc*/) override { return false; } + bool saveDocumentAs(App::Document* /*doc*/, const std::string& /*id*/) override + { + return false; + } + bool saveDocumentAsInteractive(App::Document* /*doc*/) override { return false; } + + // Test controls + bool _supportsRevisions = true; + bool _supportsBOM = true; + bool _supportsPartNumbers = true; + +private: + std::string _id; + Gui::OriginType _type; + bool _ownsAll; +}; + +} // namespace + + +// ========================================================================= +// LocalFileOrigin identity tests +// ========================================================================= + +class LocalFileOriginTest : public ::testing::Test +{ +protected: + static void SetUpTestSuite() + { + tests::initApplication(); + } + + void SetUp() override + { + _origin = std::make_unique(); + } + + Gui::LocalFileOrigin* origin() { return _origin.get(); } + +private: + std::unique_ptr _origin; +}; + +TEST_F(LocalFileOriginTest, LocalOriginId) +{ + EXPECT_EQ(origin()->id(), "local"); +} + +TEST_F(LocalFileOriginTest, LocalOriginName) +{ + EXPECT_EQ(origin()->name(), "Local Files"); +} + +TEST_F(LocalFileOriginTest, LocalOriginNickname) +{ + EXPECT_EQ(origin()->nickname(), "Local"); +} + +TEST_F(LocalFileOriginTest, LocalOriginType) +{ + EXPECT_EQ(origin()->type(), Gui::OriginType::Local); +} + +TEST_F(LocalFileOriginTest, LocalOriginCapabilities) +{ + EXPECT_FALSE(origin()->tracksExternally()); + EXPECT_FALSE(origin()->requiresAuthentication()); + EXPECT_FALSE(origin()->supportsRevisions()); + EXPECT_FALSE(origin()->supportsBOM()); + EXPECT_FALSE(origin()->supportsPartNumbers()); + EXPECT_FALSE(origin()->supportsAssemblies()); +} + + +// ========================================================================= +// OriginManager tests +// ========================================================================= + +class OriginManagerTest : public ::testing::Test +{ +protected: + static void SetUpTestSuite() + { + tests::initApplication(); + } + + void SetUp() override + { + // Ensure clean singleton state for each test + Gui::OriginManager::destruct(); + _mgr = Gui::OriginManager::instance(); + } + + void TearDown() override + { + Gui::OriginManager::destruct(); + } + + Gui::OriginManager* mgr() { return _mgr; } + +private: + Gui::OriginManager* _mgr = nullptr; +}; + +// --- Registration --- + +TEST_F(OriginManagerTest, LocalOriginAlwaysPresent) +{ + auto* local = mgr()->getOrigin("local"); + ASSERT_NE(local, nullptr); + EXPECT_EQ(local->id(), "local"); +} + +TEST_F(OriginManagerTest, RegisterCustomOrigin) +{ + auto* raw = new MockOrigin("test-plm"); + EXPECT_TRUE(mgr()->registerOrigin(raw)); + EXPECT_EQ(mgr()->getOrigin("test-plm"), raw); +} + +TEST_F(OriginManagerTest, RejectDuplicateId) +{ + mgr()->registerOrigin(new MockOrigin("dup")); + // Second registration with same ID should fail + EXPECT_FALSE(mgr()->registerOrigin(new MockOrigin("dup"))); +} + +TEST_F(OriginManagerTest, RejectNullOrigin) +{ + EXPECT_FALSE(mgr()->registerOrigin(nullptr)); +} + +TEST_F(OriginManagerTest, OriginIdsIncludesAll) +{ + mgr()->registerOrigin(new MockOrigin("alpha")); + mgr()->registerOrigin(new MockOrigin("beta")); + + auto ids = mgr()->originIds(); + EXPECT_EQ(ids.size(), 3u); // local + alpha + beta + + auto has = [&](const std::string& id) { + return std::find(ids.begin(), ids.end(), id) != ids.end(); + }; + EXPECT_TRUE(has("local")); + EXPECT_TRUE(has("alpha")); + EXPECT_TRUE(has("beta")); +} + +// --- Unregistration --- + +TEST_F(OriginManagerTest, UnregisterCustomOrigin) +{ + mgr()->registerOrigin(new MockOrigin("removable")); + EXPECT_TRUE(mgr()->unregisterOrigin("removable")); + EXPECT_EQ(mgr()->getOrigin("removable"), nullptr); +} + +TEST_F(OriginManagerTest, CannotUnregisterLocal) +{ + EXPECT_FALSE(mgr()->unregisterOrigin("local")); + EXPECT_NE(mgr()->getOrigin("local"), nullptr); +} + +TEST_F(OriginManagerTest, UnregisterCurrentSwitchesToLocal) +{ + mgr()->registerOrigin(new MockOrigin("ephemeral")); + mgr()->setCurrentOrigin("ephemeral"); + EXPECT_EQ(mgr()->currentOriginId(), "ephemeral"); + + mgr()->unregisterOrigin("ephemeral"); + EXPECT_EQ(mgr()->currentOriginId(), "local"); +} + +// --- Current origin selection --- + +TEST_F(OriginManagerTest, DefaultCurrentIsLocal) +{ + EXPECT_EQ(mgr()->currentOriginId(), "local"); + EXPECT_NE(mgr()->currentOrigin(), nullptr); +} + +TEST_F(OriginManagerTest, SetCurrentOrigin) +{ + mgr()->registerOrigin(new MockOrigin("plm1")); + + std::string notified; + auto conn = mgr()->signalCurrentOriginChanged.connect([&](const std::string& id) { + notified = id; + }); + + EXPECT_TRUE(mgr()->setCurrentOrigin("plm1")); + EXPECT_EQ(mgr()->currentOriginId(), "plm1"); + EXPECT_EQ(notified, "plm1"); +} + +TEST_F(OriginManagerTest, SetCurrentRejectsUnknown) +{ + EXPECT_FALSE(mgr()->setCurrentOrigin("nonexistent")); + EXPECT_EQ(mgr()->currentOriginId(), "local"); +} + +TEST_F(OriginManagerTest, SetCurrentSameIdNoSignal) +{ + int signalCount = 0; + auto conn = mgr()->signalCurrentOriginChanged.connect([&](const std::string&) { + signalCount++; + }); + + mgr()->setCurrentOrigin("local"); // already current + EXPECT_EQ(signalCount, 0); +} + +// --- Document ownership --- + +class OriginManagerDocTest : public ::testing::Test +{ +protected: + static void SetUpTestSuite() + { + tests::initApplication(); + } + + void SetUp() override + { + Gui::OriginManager::destruct(); + _mgr = Gui::OriginManager::instance(); + _docName = App::GetApplication().getUniqueDocumentName("test"); + _doc = App::GetApplication().newDocument(_docName.c_str(), "testUser"); + } + + void TearDown() override + { + App::GetApplication().closeDocument(_docName.c_str()); + Gui::OriginManager::destruct(); + } + + Gui::OriginManager* mgr() { return _mgr; } + App::Document* doc() { return _doc; } + +private: + Gui::OriginManager* _mgr = nullptr; + std::string _docName; + App::Document* _doc = nullptr; +}; + +TEST_F(OriginManagerDocTest, LocalOwnsPlainDocument) +{ + // A document with no SiloItemId property should be owned by local + auto* local = mgr()->getOrigin("local"); + ASSERT_NE(local, nullptr); + EXPECT_TRUE(local->ownsDocument(doc())); +} + +TEST_F(OriginManagerDocTest, LocalDisownsTrackedDocument) +{ + // Add an object with a SiloItemId property -- local should reject ownership + auto* obj = doc()->addObject("App::FeaturePython", "TrackedPart"); + ASSERT_NE(obj, nullptr); + obj->addDynamicProperty("App::PropertyString", "SiloItemId"); + auto* prop = dynamic_cast(obj->getPropertyByName("SiloItemId")); + ASSERT_NE(prop, nullptr); + prop->setValue("some-uuid"); + + auto* local = mgr()->getOrigin("local"); + EXPECT_FALSE(local->ownsDocument(doc())); +} + +TEST_F(OriginManagerDocTest, FindOwningOriginPrefersNonLocal) +{ + // Register a PLM mock that claims ownership of everything + auto* plm = new MockOrigin("test-plm", Gui::OriginType::PLM, /*ownsAll=*/true); + mgr()->registerOrigin(plm); + + auto* owner = mgr()->findOwningOrigin(doc()); + ASSERT_NE(owner, nullptr); + EXPECT_EQ(owner->id(), "test-plm"); +} + +TEST_F(OriginManagerDocTest, FindOwningOriginFallsBackToLocal) +{ + // No PLM origins registered -- should fall back to local + auto* owner = mgr()->findOwningOrigin(doc()); + ASSERT_NE(owner, nullptr); + EXPECT_EQ(owner->id(), "local"); +} + +TEST_F(OriginManagerDocTest, OriginForNewDocumentReturnsCurrent) +{ + mgr()->registerOrigin(new MockOrigin("plm2")); + mgr()->setCurrentOrigin("plm2"); + + auto* origin = mgr()->originForNewDocument(); + ASSERT_NE(origin, nullptr); + EXPECT_EQ(origin->id(), "plm2"); +} + +TEST_F(OriginManagerDocTest, SetAndClearDocumentOrigin) +{ + auto* local = mgr()->getOrigin("local"); + mgr()->setDocumentOrigin(doc(), local); + EXPECT_EQ(mgr()->originForDocument(doc()), local); + + mgr()->clearDocumentOrigin(doc()); + // After clearing, originForDocument falls back to ownership detection + auto* resolved = mgr()->originForDocument(doc()); + ASSERT_NE(resolved, nullptr); + EXPECT_EQ(resolved->id(), "local"); +} + +TEST_F(OriginManagerDocTest, NullDocumentHandling) +{ + EXPECT_EQ(mgr()->findOwningOrigin(nullptr), nullptr); + EXPECT_EQ(mgr()->originForDocument(nullptr), nullptr); +} diff --git a/tests/test_kindred_pure.py b/tests/test_kindred_pure.py new file mode 100644 index 0000000000..308f078440 --- /dev/null +++ b/tests/test_kindred_pure.py @@ -0,0 +1,560 @@ +"""Tier 1 — Pure-logic tests for Kindred Create addons. + +These tests exercise standalone functions from update_checker, datum_commands, +spreadsheet_commands, silo_commands, silo_start, and silo_origin WITHOUT +requiring a FreeCAD binary, running GUI, or Silo server. + +Run directly: python tests/test_kindred_pure.py +Via runner: python tests/run_kindred_tests.py +Via pixi: pixi run test-kindred +""" + +import math +import os +import sys +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest import mock + +# --------------------------------------------------------------------------- +# Mock the FreeCAD ecosystem BEFORE importing any target modules. +# Every module under test does `import FreeCAD` at the top level. +# --------------------------------------------------------------------------- + +_REPO_ROOT = Path(__file__).resolve().parent.parent + +# Build a FreeCAD mock with a working ParamGet stub +_param_store: dict = {} + + +class _MockParamGroup: + """Minimal stand-in for FreeCAD ParameterGrp.""" + + def __init__(self): + self._data: dict = {} + + # Getters + def GetBool(self, key, default=False): + return self._data.get(key, default) + + def GetString(self, key, default=""): + return self._data.get(key, default) + + def GetInt(self, key, default=0): + return self._data.get(key, default) + + # Setters + def SetBool(self, key, val): + self._data[key] = val + + def SetString(self, key, val): + self._data[key] = val + + def SetInt(self, key, val): + self._data[key] = val + + +def _mock_param_get(path): + if path not in _param_store: + _param_store[path] = _MockParamGroup() + return _param_store[path] + + +_fc = mock.MagicMock() +_fc.ParamGet = _mock_param_get +_fc.Console = mock.MagicMock() + +# Insert mocks for all FreeCAD-related modules +for mod_name in ( + "FreeCAD", + "FreeCADGui", + "Part", + "PySide", + "PySide.QtCore", + "PySide.QtGui", + "PySide.QtWidgets", + "PySide.QtWebEngineWidgets", +): + sys.modules.setdefault(mod_name, mock.MagicMock()) + +# Replace FreeCAD with our richer mock so ParamGet actually works +sys.modules["FreeCAD"] = _fc + +# Mock silo_client with the specific names that silo_commands imports +_silo_client_mock = mock.MagicMock() +_silo_client_mock.CATEGORY_NAMES = { + "EL": "Electrical", + "ME": "Mechanical", + "SW": "Software", +} +_silo_client_mock.parse_part_number = mock.MagicMock(return_value=("ME", "001")) +_silo_client_mock.get_category_folder_name = mock.MagicMock(return_value="ME_Mechanical") +_silo_client_mock.sanitize_filename = mock.MagicMock(side_effect=lambda s: s.replace(" ", "_")) +_silo_client_mock.SiloClient = mock.MagicMock() +_silo_client_mock.SiloSettings = type("SiloSettings", (), {}) +sys.modules["silo_client"] = _silo_client_mock +sys.modules["silo_client._ssl"] = mock.MagicMock() + +# Add addon source paths +sys.path.insert(0, str(_REPO_ROOT / "src" / "Mod" / "Create")) +sys.path.insert(0, str(_REPO_ROOT / "mods" / "ztools" / "ztools")) +sys.path.insert(0, str(_REPO_ROOT / "mods" / "silo" / "freecad")) + +# --------------------------------------------------------------------------- +# Now import the modules under test +# --------------------------------------------------------------------------- + +from update_checker import _parse_version, _should_check # noqa: E402 + + +# For datum_commands, the module registers Gui.addCommand at import time. +# We need Gui.addCommand to be a no-op mock (already is via MagicMock). +from ztools.commands.datum_commands import ( # noqa: E402 + DatumCreatorTaskPanel, + SelectionItem, +) + +from ztools.commands.spreadsheet_commands import column_to_index # noqa: E402 + +from silo_commands import _safe_float # noqa: E402 + +import silo_start # noqa: E402 +import silo_origin # noqa: E402 + + +# =================================================================== +# Test: update_checker._parse_version +# =================================================================== + + +class TestParseVersion(unittest.TestCase): + """Tests for update_checker._parse_version.""" + + def test_standard_version(self): + self.assertEqual(_parse_version("0.1.3"), (0, 1, 3)) + + def test_v_prefix(self): + self.assertEqual(_parse_version("v0.1.3"), (0, 1, 3)) + + def test_major_only_fails(self): + self.assertIsNone(_parse_version("1")) + + def test_two_part_fails(self): + self.assertIsNone(_parse_version("1.2")) + + def test_four_part_fails(self): + self.assertIsNone(_parse_version("1.2.3.4")) + + def test_empty_string(self): + self.assertIsNone(_parse_version("")) + + def test_latest_tag(self): + self.assertIsNone(_parse_version("latest")) + + def test_large_numbers(self): + self.assertEqual(_parse_version("v99.88.77"), (99, 88, 77)) + + def test_zero_version(self): + self.assertEqual(_parse_version("0.0.0"), (0, 0, 0)) + + def test_alpha_suffix_fails(self): + self.assertIsNone(_parse_version("v1.0.0-beta")) + + def test_comparison(self): + v1 = _parse_version("v0.1.0") + v2 = _parse_version("v0.2.0") + v3 = _parse_version("v1.0.0") + self.assertLess(v1, v2) + self.assertLess(v2, v3) + self.assertGreater(v3, v1) + + +# =================================================================== +# Test: update_checker._should_check +# =================================================================== + + +class TestShouldCheck(unittest.TestCase): + """Tests for update_checker._should_check interval logic.""" + + def _make_param(self, **kwargs): + p = _MockParamGroup() + for k, v in kwargs.items(): + p._data[k] = v + return p + + def test_default_enabled_no_timestamp(self): + p = self._make_param() + self.assertTrue(_should_check(p)) + + def test_disabled(self): + p = self._make_param(CheckEnabled=False) + self.assertFalse(_should_check(p)) + + def test_recent_check_skipped(self): + now = datetime.now(timezone.utc) + p = self._make_param( + CheckEnabled=True, + LastCheckTimestamp=now.isoformat(), + CheckIntervalDays=1, + ) + self.assertFalse(_should_check(p)) + + def test_old_check_triggers(self): + old = datetime.now(timezone.utc) - timedelta(days=2) + p = self._make_param( + CheckEnabled=True, + LastCheckTimestamp=old.isoformat(), + CheckIntervalDays=1, + ) + self.assertTrue(_should_check(p)) + + def test_zero_interval_always_checks(self): + now = datetime.now(timezone.utc) + p = self._make_param( + CheckEnabled=True, + LastCheckTimestamp=now.isoformat(), + CheckIntervalDays=0, + ) + self.assertTrue(_should_check(p)) + + def test_invalid_timestamp_triggers(self): + p = self._make_param( + CheckEnabled=True, + LastCheckTimestamp="not-a-date", + CheckIntervalDays=1, + ) + self.assertTrue(_should_check(p)) + + +# =================================================================== +# Test: datum_commands._match_score and _type_matches +# =================================================================== + + +class _StubPanel: + """Minimal stub to access DatumCreatorTaskPanel methods without GUI.""" + + _match_score = DatumCreatorTaskPanel._match_score + _type_matches = DatumCreatorTaskPanel._type_matches + + +class TestTypeMatches(unittest.TestCase): + """Tests for DatumCreatorTaskPanel._type_matches.""" + + def setUp(self): + self.p = _StubPanel() + + def test_exact_face(self): + self.assertTrue(self.p._type_matches("face", "face")) + + def test_exact_edge(self): + self.assertTrue(self.p._type_matches("edge", "edge")) + + def test_exact_vertex(self): + self.assertTrue(self.p._type_matches("vertex", "vertex")) + + def test_cylinder_matches_face(self): + self.assertTrue(self.p._type_matches("cylinder", "face")) + + def test_circle_matches_edge(self): + self.assertTrue(self.p._type_matches("circle", "edge")) + + def test_face_does_not_match_edge(self): + self.assertFalse(self.p._type_matches("face", "edge")) + + def test_vertex_does_not_match_face(self): + self.assertFalse(self.p._type_matches("vertex", "face")) + + def test_face_does_not_match_cylinder(self): + # face is NOT a cylinder (cylinder IS a face, not reverse) + self.assertFalse(self.p._type_matches("face", "cylinder")) + + def test_edge_does_not_match_circle(self): + self.assertFalse(self.p._type_matches("edge", "circle")) + + def test_unknown_matches_nothing(self): + self.assertFalse(self.p._type_matches("unknown", "face")) + self.assertFalse(self.p._type_matches("unknown", "edge")) + + +class TestMatchScore(unittest.TestCase): + """Tests for DatumCreatorTaskPanel._match_score.""" + + def setUp(self): + self.p = _StubPanel() + + def test_exact_single_face(self): + score = self.p._match_score(("face",), ("face",)) + self.assertEqual(score, 101) # 100 + 1 matched, exact count + + def test_exact_two_faces(self): + score = self.p._match_score(("face", "face"), ("face", "face")) + self.assertEqual(score, 102) # 100 + 2 + + def test_exact_three_vertices(self): + score = self.p._match_score( + ("vertex", "vertex", "vertex"), + ("vertex", "vertex", "vertex"), + ) + self.assertEqual(score, 103) + + def test_surplus_selection_lower_score(self): + exact = self.p._match_score(("face",), ("face",)) + surplus = self.p._match_score(("face", "edge"), ("face",)) + self.assertGreater(exact, surplus) + self.assertGreater(surplus, 0) + + def test_not_enough_items_zero(self): + score = self.p._match_score(("face",), ("face", "face")) + self.assertEqual(score, 0) + + def test_wrong_type_zero(self): + score = self.p._match_score(("vertex",), ("face",)) + self.assertEqual(score, 0) + + def test_empty_selection_zero(self): + score = self.p._match_score((), ("face",)) + self.assertEqual(score, 0) + + def test_cylinder_satisfies_face(self): + score = self.p._match_score(("cylinder",), ("face",)) + self.assertEqual(score, 101) + + def test_face_and_edge(self): + score = self.p._match_score(("face", "edge"), ("face", "edge")) + self.assertEqual(score, 102) + + def test_order_independence(self): + # edge,face should match face,edge requirement + score = self.p._match_score(("edge", "face"), ("face", "edge")) + self.assertEqual(score, 102) + + +# =================================================================== +# Test: datum_commands.SelectionItem properties +# =================================================================== + + +class TestSelectionItemProperties(unittest.TestCase): + """Tests for SelectionItem.display_name and type_icon.""" + + def _make_item(self, label, subname, geo_type): + obj = mock.MagicMock() + obj.Label = label + item = SelectionItem.__new__(SelectionItem) + item.obj = obj + item.subname = subname + item.shape = None + item.geo_type = geo_type + return item + + def test_display_name_with_subname(self): + item = self._make_item("Box", "Face1", "face") + self.assertEqual(item.display_name, "Box.Face1") + + def test_display_name_without_subname(self): + item = self._make_item("DatumPlane", "", "plane") + self.assertEqual(item.display_name, "DatumPlane") + + def test_type_icon_face(self): + item = self._make_item("X", "Face1", "face") + self.assertEqual(item.type_icon, "▢") + + def test_type_icon_vertex(self): + item = self._make_item("X", "Vertex1", "vertex") + self.assertEqual(item.type_icon, "•") + + def test_type_icon_unknown(self): + item = self._make_item("X", "", "unknown") + self.assertEqual(item.type_icon, "?") + + +# =================================================================== +# Test: spreadsheet_commands.column_to_index +# =================================================================== + + +class TestColumnToIndex(unittest.TestCase): + """Tests for spreadsheet column_to_index conversion.""" + + def test_a(self): + self.assertEqual(column_to_index("A"), 0) + + def test_b(self): + self.assertEqual(column_to_index("B"), 1) + + def test_z(self): + self.assertEqual(column_to_index("Z"), 25) + + def test_aa(self): + self.assertEqual(column_to_index("AA"), 26) + + def test_ab(self): + self.assertEqual(column_to_index("AB"), 27) + + def test_az(self): + self.assertEqual(column_to_index("AZ"), 51) + + def test_ba(self): + self.assertEqual(column_to_index("BA"), 52) + + +# =================================================================== +# Test: silo_commands._safe_float +# =================================================================== + + +class TestSafeFloat(unittest.TestCase): + """Tests for silo_commands._safe_float.""" + + def test_normal_float(self): + self.assertEqual(_safe_float(3.14), 3.14) + + def test_nan(self): + self.assertEqual(_safe_float(float("nan")), 0.0) + + def test_inf(self): + self.assertEqual(_safe_float(float("inf")), 0.0) + + def test_neg_inf(self): + self.assertEqual(_safe_float(float("-inf")), 0.0) + + def test_zero(self): + self.assertEqual(_safe_float(0.0), 0.0) + + def test_integer_passthrough(self): + self.assertEqual(_safe_float(42), 42) + + def test_string_passthrough(self): + self.assertEqual(_safe_float("hello"), "hello") + + +# =================================================================== +# Test: silo_start._get_silo_base_url +# =================================================================== + + +class TestSiloBaseUrl(unittest.TestCase): + """Tests for silo_start._get_silo_base_url URL construction.""" + + def setUp(self): + # Reset the param store for the silo pref group + _param_store.clear() + + def test_default_strips_api(self): + url = silo_start._get_silo_base_url() + # Default env var is http://localhost:8080/api -> strip /api + self.assertFalse(url.endswith("/api"), f"URL should not end with /api: {url}") + + def test_preference_with_api_suffix(self): + p = _mock_param_get(silo_start._PREF_GROUP) + p.SetString("ApiUrl", "https://silo.example.com/api") + url = silo_start._get_silo_base_url() + self.assertEqual(url, "https://silo.example.com") + + def test_preference_without_api_suffix(self): + p = _mock_param_get(silo_start._PREF_GROUP) + p.SetString("ApiUrl", "https://silo.example.com") + url = silo_start._get_silo_base_url() + self.assertEqual(url, "https://silo.example.com") + + def test_trailing_slash_stripped(self): + p = _mock_param_get(silo_start._PREF_GROUP) + p.SetString("ApiUrl", "https://silo.example.com/api/") + url = silo_start._get_silo_base_url() + # After rstrip("/") and strip /api + self.assertFalse(url.endswith("/")) + + +# =================================================================== +# Test: silo_origin.SiloOrigin capability constants +# =================================================================== + + +class TestSiloOriginCapabilities(unittest.TestCase): + """Tests for SiloOrigin constant methods (no network, no FreeCAD doc).""" + + def setUp(self): + self.origin = silo_origin.SiloOrigin() + + def test_id(self): + self.assertEqual(self.origin.id(), "silo") + + def test_name(self): + self.assertEqual(self.origin.name(), "Kindred Silo") + + def test_nickname(self): + self.assertEqual(self.origin.nickname(), "Silo") + + def test_type_is_plm(self): + self.assertEqual(self.origin.type(), 1) + + def test_tracks_externally(self): + self.assertTrue(self.origin.tracksExternally()) + + def test_requires_authentication(self): + self.assertTrue(self.origin.requiresAuthentication()) + + def test_supports_revisions(self): + self.assertTrue(self.origin.supportsRevisions()) + + def test_supports_bom(self): + self.assertTrue(self.origin.supportsBOM()) + + def test_supports_part_numbers(self): + self.assertTrue(self.origin.supportsPartNumbers()) + + def test_supports_assemblies(self): + self.assertTrue(self.origin.supportsAssemblies()) + + def test_custom_nickname(self): + origin = silo_origin.SiloOrigin(origin_id="prod", nickname="Production") + self.assertEqual(origin.id(), "prod") + self.assertEqual(origin.nickname(), "Production") + + +# =================================================================== +# Test: DatumCreatorTaskPanel.MODES integrity +# =================================================================== + + +class TestDatumModes(unittest.TestCase): + """Verify the MODES table is internally consistent.""" + + def test_all_modes_have_four_fields(self): + for mode in DatumCreatorTaskPanel.MODES: + self.assertEqual(len(mode), 4, f"Mode tuple wrong length: {mode}") + + def test_mode_ids_unique(self): + ids = [m[1] for m in DatumCreatorTaskPanel.MODES] + self.assertEqual(len(ids), len(set(ids)), "Duplicate mode IDs found") + + def test_categories_valid(self): + valid = {"plane", "axis", "point"} + for _, mode_id, _, category in DatumCreatorTaskPanel.MODES: + self.assertIn(category, valid, f"Invalid category for {mode_id}") + + def test_required_types_are_tuples(self): + for _, mode_id, req, _ in DatumCreatorTaskPanel.MODES: + self.assertIsInstance(req, tuple, f"required_types not a tuple: {mode_id}") + + def test_plane_modes_count(self): + planes = [m for m in DatumCreatorTaskPanel.MODES if m[3] == "plane"] + self.assertEqual(len(planes), 7) + + def test_axis_modes_count(self): + axes = [m for m in DatumCreatorTaskPanel.MODES if m[3] == "axis"] + self.assertEqual(len(axes), 4) + + def test_point_modes_count(self): + points = [m for m in DatumCreatorTaskPanel.MODES if m[3] == "point"] + self.assertEqual(len(points), 5) + + +# =================================================================== + + +if __name__ == "__main__": + unittest.main()