Files
create/tests/test_kindred_pure.py
forbes-0023 e73c5fc750
All checks were successful
Build and Test / build (pull_request) Successful in 29m58s
feat: add QuickNav addon — Phase 1 core infrastructure (#320)
Add quicknav submodule and create-side integration for keyboard-driven
command navigation.

Submodule: mods/quicknav (https://git.kindred-systems.com/kindred/quicknav)

Create-side changes:
- CMakeLists.txt: add quicknav install rules
- test_kindred_pure.py: add 16 workbench_map validation tests
- docs/src/quicknav/SPEC.md: QuickNav specification

QuickNav provides numbered-key access to workbenches (Ctrl+1-5),
command groupings (Shift+1-9), and individual commands (1-9), with
a navigation bar toolbar and input-widget safety guards.
2026-02-23 14:12:02 -06:00

676 lines
22 KiB
Python

"""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" / "sdk"))
sys.path.insert(0, str(_REPO_ROOT / "mods" / "ztools" / "ztools"))
sys.path.insert(0, str(_REPO_ROOT / "mods" / "silo" / "freecad"))
sys.path.insert(0, str(_REPO_ROOT / "mods" / "quicknav"))
# ---------------------------------------------------------------------------
# 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
from quicknav.workbench_map import ( # noqa: E402
WORKBENCH_SLOTS,
WORKBENCH_GROUPINGS,
get_workbench_slot,
get_groupings,
get_grouping,
get_command,
)
# ===================================================================
# 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)
# ===================================================================
# Test: quicknav workbench_map
# ===================================================================
class TestWorkbenchMap(unittest.TestCase):
"""Tests for quicknav.workbench_map data and helpers."""
def test_all_slots_defined(self):
for n in range(1, 6):
slot = WORKBENCH_SLOTS.get(n)
self.assertIsNotNone(slot, f"Slot {n} missing from WORKBENCH_SLOTS")
def test_slot_keys(self):
for n, slot in WORKBENCH_SLOTS.items():
self.assertIn("key", slot)
self.assertIn("class_name", slot)
self.assertIn("display", slot)
self.assertIsInstance(slot["key"], str)
self.assertIsInstance(slot["class_name"], str)
self.assertIsInstance(slot["display"], str)
def test_each_slot_has_groupings(self):
for n, slot in WORKBENCH_SLOTS.items():
groupings = WORKBENCH_GROUPINGS.get(slot["key"])
self.assertIsNotNone(groupings, f"No groupings for workbench key '{slot['key']}'")
self.assertGreater(len(groupings), 0, f"Empty groupings for slot {n}")
def test_max_nine_groupings_per_workbench(self):
for wb_key, groupings in WORKBENCH_GROUPINGS.items():
self.assertLessEqual(len(groupings), 9, f"More than 9 groupings for '{wb_key}'")
def test_max_nine_commands_per_grouping(self):
for wb_key, groupings in WORKBENCH_GROUPINGS.items():
for i, grp in enumerate(groupings):
self.assertLessEqual(
len(grp["commands"]),
9,
f"More than 9 commands in '{wb_key}' grouping {i}",
)
def test_command_tuples_are_str_str(self):
for wb_key, groupings in WORKBENCH_GROUPINGS.items():
for i, grp in enumerate(groupings):
self.assertIn("name", grp)
self.assertIn("commands", grp)
for j, cmd in enumerate(grp["commands"]):
self.assertIsInstance(cmd, tuple, f"{wb_key}[{i}][{j}] not tuple")
self.assertEqual(len(cmd), 2, f"{wb_key}[{i}][{j}] not length 2")
self.assertIsInstance(cmd[0], str, f"{wb_key}[{i}][{j}][0] not str")
self.assertIsInstance(cmd[1], str, f"{wb_key}[{i}][{j}][1] not str")
def test_get_workbench_slot_valid(self):
for n in range(1, 6):
slot = get_workbench_slot(n)
self.assertIsNotNone(slot)
self.assertEqual(slot, WORKBENCH_SLOTS[n])
def test_get_workbench_slot_invalid(self):
self.assertIsNone(get_workbench_slot(0))
self.assertIsNone(get_workbench_slot(6))
self.assertIsNone(get_workbench_slot(99))
def test_get_groupings_valid(self):
for slot in WORKBENCH_SLOTS.values():
result = get_groupings(slot["key"])
self.assertIsNotNone(result)
self.assertIsInstance(result, list)
def test_get_groupings_invalid(self):
self.assertEqual(get_groupings("nonexistent"), [])
def test_get_grouping_valid(self):
for wb_key, groupings in WORKBENCH_GROUPINGS.items():
for i in range(len(groupings)):
grp = get_grouping(wb_key, i)
self.assertIsNotNone(grp)
self.assertEqual(grp, groupings[i])
def test_get_grouping_invalid_index(self):
wb_key = WORKBENCH_SLOTS[1]["key"]
self.assertIsNone(get_grouping(wb_key, 99))
self.assertIsNone(get_grouping(wb_key, -1))
def test_get_grouping_invalid_key(self):
self.assertIsNone(get_grouping("nonexistent", 0))
def test_get_command_valid(self):
for wb_key, groupings in WORKBENCH_GROUPINGS.items():
for gi, grp in enumerate(groupings):
for ci in range(len(grp["commands"])):
cmd_id = get_command(wb_key, gi, ci + 1)
self.assertIsNotNone(cmd_id, f"None for {wb_key}[{gi}][{ci + 1}]")
self.assertEqual(cmd_id, grp["commands"][ci][0])
def test_get_command_invalid_number(self):
wb_key = WORKBENCH_SLOTS[1]["key"]
self.assertIsNone(get_command(wb_key, 0, 0))
self.assertIsNone(get_command(wb_key, 0, 99))
def test_get_command_invalid_workbench(self):
self.assertIsNone(get_command("nonexistent", 0, 1))
# ===================================================================
if __name__ == "__main__":
unittest.main()