fix: add missing Kindred test files from origin/main
This commit is contained in:
103
tests/run_kindred_tests.py
Normal file
103
tests/run_kindred_tests.py
Normal file
@@ -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()
|
||||
382
tests/src/Gui/OriginManager.cpp
Normal file
382
tests/src/Gui/OriginManager.cpp
Normal file
@@ -0,0 +1,382 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
#include <App/Application.h>
|
||||
#include <App/Document.h>
|
||||
#include <App/DocumentObject.h>
|
||||
#include <App/PropertyStandard.h>
|
||||
#include <Gui/FileOrigin.h>
|
||||
#include <Gui/OriginManager.h>
|
||||
|
||||
#include <src/App/InitApplication.h>
|
||||
|
||||
|
||||
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>();
|
||||
}
|
||||
|
||||
Gui::LocalFileOrigin* origin() { return _origin.get(); }
|
||||
|
||||
private:
|
||||
std::unique_ptr<Gui::LocalFileOrigin> _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<App::PropertyString*>(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);
|
||||
}
|
||||
560
tests/test_kindred_pure.py
Normal file
560
tests/test_kindred_pure.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user