Compare commits

...

11 Commits

Author SHA1 Message Date
forbes
ee839c23b8 fix(ci): add error handling to release creation API calls
Some checks failed
Build and Test / build (push) Has been cancelled
Release Build / build-linux (push) Successful in 1h26m59s
Release Build / publish-release (push) Has been cancelled
Print HTTP status and response body on failure instead of crashing
with a cryptic KeyError when the Gitea API returns an error.
2026-02-08 10:22:26 -06:00
forbes
67f825c305 fix(ci): filter dereferenced tag refs from git ls-remote output
Some checks failed
Build and Test / build (push) Successful in 1h7m46s
Release Build / build-linux (push) Successful in 1h25m16s
Release Build / publish-release (push) Failing after 5m17s
2026-02-07 22:22:55 -06:00
440df2a9be Merge pull request 'fix(gui): merge Silo toolbar into File toolbar via origin system (#65)' (#27) from fix/merge-silo-toolbar into main
Some checks failed
Build and Test / build (push) Failing after 1m33s
Release Build / build-linux (push) Failing after 1m58s
Release Build / publish-release (push) Has been skipped
2026-02-08 03:29:24 +00:00
forbes
1750949afd fix(gui): merge Silo toolbar into File toolbar via origin system (#65)
Some checks failed
Build and Test / build (pull_request) Failing after 1m41s
- Remove separate Silo workbench toolbar (now redundant)
- Remove SiloMenuManipulator (Std commands already delegate to origins)
- Remove Silo_ToggleMode (origin selector handles mode switching)
- Register Silo origin at startup via Create module
- Update docs to reflect unified origin architecture
2026-02-07 21:29:05 -06:00
b2c6fc2ebc Merge pull request 'fix(silo): workbench bug fixes and submodule updates' (#26) from fix/silo-workbench-bugs into main
Some checks failed
Build and Test / build (push) Failing after 1m52s
2026-02-08 03:11:07 +00:00
forbes
62906b0269 test(gui): add unit tests for OriginManager and LocalFileOrigin
Some checks failed
Build and Test / build (push) Has been cancelled
Add 20 GTest cases covering the Kindred Origin system:

- LocalFileOriginTest: identity methods and capability flags
- OriginManagerTest: registration lifecycle, current origin
  selection, signal emissions, and fallback behavior
- OriginManagerDocTest: document ownership via SiloItemId
  property detection, origin association, and null safety

Uses MockOrigin stub for testing OriginManager without real
PLM backends.
2026-02-07 20:56:08 -06:00
99bc7629e7 Merge pull request 'fix(gui): widen origin selector widget and update silo submodule' (#25) from fix/silo-workbench-bugs into main
All checks were successful
Build and Test / build (push) Successful in 1h3m36s
Reviewed-on: #25
2026-02-07 20:34:56 +00:00
1f7dae4f11 Merge pull request 'art: update kindred icon set' (#24) from art/update-kindred-icons into main
All checks were successful
Build and Test / build (push) Successful in 1h5m8s
Reviewed-on: #24
2026-02-07 16:45:30 +00:00
1d7e4e2eef Merge pull request 'fix(ci): fetch only latest tag, add patchelf dep, update docs' (#23) from docs/update-ci-and-overview into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #23
2026-02-07 16:45:20 +00:00
forbes
0e5a259d14 fix(ci): fetch only latest tag and add patchelf build dep
Some checks failed
Build and Test / build (pull_request) Has been cancelled
- Change both build.yml and release.yml to fetch only the latest v* tag
  via git ls-remote instead of fetching all tags
- Add patchelf as a Linux build dependency in recipe.yaml to fix
  rattler-build packaging failure
2026-02-07 10:42:48 -06:00
forbes
bfb2728f8d docs: update OVERVIEW.md and CI_CD.md to match current repo state
- Fix stale submodule URLs (gitea.kindred.internal -> git.kindred-systems.com)
- Update silo submodule name and commit, add note about repo split
- Document actual release mechanism (curl API, not release-action)
- Mark macOS/Windows builds as disabled in platform matrix
- Update ccache docs: date-based key rotation, rattler-build env forwarding
- Document disk cleanup steps and runner cleanup daemon
- Update appimagetool extraction note (FUSE unavailable in containers)
2026-02-07 09:34:41 -06:00
13 changed files with 500 additions and 74 deletions

View File

@@ -47,8 +47,12 @@ jobs:
submodules: recursive
fetch-depth: 1
- name: Fetch tags (for git describe)
run: git fetch --no-recurse-submodules --force --depth=1 origin '+refs/tags/*:refs/tags/*'
- name: Fetch latest tag (for git describe)
run: |
latest_tag=$(git ls-remote --tags --sort=-v:refname origin 'refs/tags/v*' | grep -v '\^{}' | head -n1 | awk '{print $2}')
if [ -n "$latest_tag" ]; then
git fetch --no-recurse-submodules --force --depth=1 origin "+${latest_tag}:${latest_tag}"
fi
- name: Install pixi
run: |

View File

@@ -57,8 +57,12 @@ jobs:
submodules: recursive
fetch-depth: 1
- name: Fetch tags
run: git fetch --tags --force --no-recurse-submodules origin
- name: Fetch latest tag (for git describe)
run: |
latest_tag=$(git ls-remote --tags --sort=-v:refname origin 'refs/tags/v*' | grep -v '\^{}' | head -n1 | awk '{print $2}')
if [ -n "$latest_tag" ]; then
git fetch --no-recurse-submodules --force --depth=1 origin "+${latest_tag}:${latest_tag}"
fi
- name: Install pixi
run: |
@@ -389,21 +393,34 @@ jobs:
fi
# Create release
release_id=$(curl -s -X POST \
response=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases" | \
python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
"${GITEA_URL}/api/v1/repos/${REPO}/releases")
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | sed '$d')
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "::error::Failed to create release (HTTP ${http_code}): ${body}"
exit 1
fi
release_id=$(echo "$body" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo "Created release ${release_id}"
# Upload assets
for file in release/*; do
filename=$(basename "$file")
echo "Uploading ${filename}..."
curl -s -X POST \
upload_resp=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${file}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${release_id}/assets?name=${filename}"
echo " done."
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${release_id}/assets?name=${filename}")
upload_code=$(echo "$upload_resp" | tail -1)
if [ "$upload_code" -lt 200 ] || [ "$upload_code" -ge 300 ]; then
echo "::warning::Failed to upload ${filename} (HTTP ${upload_code})"
else
echo " done."
fi
done

View File

@@ -17,8 +17,8 @@ FreeCAD startup
│ └─ exec(mods/silo/freecad/InitGui.py)
│ └─ registers SiloWorkbench
└─ Deferred setup (QTimer):
├─ 1500ms: _setup_silo_auth_panel() → "Database Auth" dock
├─ 2000ms: _setup_silo_menu() → SiloMenuManipulator
├─ 1500ms: _register_silo_origin() registers Silo FileOrigin
├─ 2000ms: _setup_silo_auth_panel() → "Database Auth" dock
├─ 3000ms: _check_silo_first_start() → settings prompt
└─ 4000ms: _setup_silo_activity_panel() → "Database Activity" dock
```

View File

@@ -7,7 +7,7 @@ Kindred Create uses Gitea Actions for continuous integration and release builds.
| Workflow | Trigger | Purpose | Artifacts |
|----------|---------|---------|-----------|
| `build.yml` | Push to `main`, pull requests | Build + test | Linux tarball |
| `release.yml` | Tags matching `v*` | Multi-platform release | AppImage, .deb, .dmg, .exe, .7z |
| `release.yml` | Tags matching `v*` or `latest` | Release build | AppImage, .deb |
All builds run on public runners in dockerized mode. No host-mode or internal infrastructure is required.
@@ -34,14 +34,16 @@ Runs on every push to `main` and on pull requests. Builds the project in an Ubun
### Caching
ccache is persisted between builds using `actions/cache`. Cache keys are scoped by branch and commit SHA, with fallback to the branch key then `main`.
ccache is persisted between builds using `actions/cache`. Cache keys use a date suffix so entries rotate daily (one save per day per branch). Saves are skipped when the exact key already exists, preventing duplicate entries that fill runner storage.
```
Key: ccache-build-{branch}-{sha}
Key: ccache-build-{branch}-{YYYYMMDD}
Fallback: ccache-build-{branch}-
Fallback: ccache-build-main-
```
Release builds use a separate key namespace (`ccache-release-linux-{YYYYMMDD}`) because they compile with different optimization flags (`-O3`). The rattler-build script (`build.sh`) explicitly sets `CCACHE_DIR` and `CCACHE_BASEDIR` since rattler-build does not forward environment variables from the parent process.
ccache configuration: 4 GB max, zlib compression level 6, sloppy mode for include timestamps and PCH.
---
@@ -63,17 +65,19 @@ Tags containing `rc`, `beta`, or `alpha` are marked as pre-releases.
### Platform matrix
| Job | Runner | Container | Preset | Output |
|-----|--------|-----------|--------|--------|
| `build-linux` | `ubuntu-latest` | `ubuntu:24.04` | `conda-linux-release` | AppImage, .deb |
| `build-macos` (Intel) | `macos-13` | native | `conda-macos-release` | .dmg (x86_64) |
| `build-macos` (Apple Silicon) | `macos-14` | native | `conda-macos-release` | .dmg (arm64) |
| `build-windows` | `windows-latest` | native | `conda-windows-release` | .exe (NSIS), .7z |
| Job | Runner | Container | Preset | Output | Status |
|-----|--------|-----------|--------|--------|--------|
| `build-linux` | `ubuntu-latest` | `ubuntu:24.04` | `conda-linux-release` | AppImage, .deb | Active |
| `build-macos` (Intel) | `macos-13` | native | `conda-macos-release` | .dmg (x86_64) | Disabled |
| `build-macos` (Apple Silicon) | `macos-14` | native | `conda-macos-release` | .dmg (arm64) | Disabled |
| `build-windows` | `windows-latest` | native | `conda-windows-release` | .exe (NSIS), .7z | Disabled |
All four jobs run concurrently. After all succeed, `publish-release` collects artifacts and creates the Gitea release.
Only the Linux build is currently active. macOS and Windows jobs are defined but commented out pending runner availability or cross-compilation support. After `build-linux` succeeds, `publish-release` collects artifacts and creates the Gitea release.
### Linux build
Both workflows start with a disk cleanup step that removes pre-installed bloat (dotnet, Android SDK, etc.) to free space for the build.
Uses the rattler-build packaging pipeline:
1. `pixi install` in `package/rattler-build/`
@@ -81,9 +85,10 @@ Uses the rattler-build packaging pipeline:
3. The bundle script:
- Copies the pixi conda environment to an AppDir
- Strips unnecessary files (includes, static libs, cmake files, `__pycache__`)
- Downloads `appimagetool` and creates a squashfs AppImage (zstd compressed)
- Generates SHA256 checksums
4. `package/debian/build-deb.sh` builds a .deb from the AppDir
- Downloads `appimagetool`, extracts it with `--appimage-extract` (FUSE unavailable in containers), and runs via `squashfs-root/AppRun`
- Creates a squashfs AppImage (zstd compressed) with SHA256 checksums
4. Intermediate build files are cleaned up to free space for the .deb step
5. `package/debian/build-deb.sh` builds a .deb from the AppDir
- Installs to `/opt/kindred-create/` with wrapper scripts in `/usr/bin/`
- Sets up LD_LIBRARY_PATH, QT_PLUGIN_PATH, PYTHONPATH in wrappers
- Creates desktop entry, MIME types, AppStream metainfo
@@ -123,10 +128,15 @@ Builds natively on Windows runner:
`publish-release` runs after all platform builds succeed:
1. Downloads all artifacts from `build-linux`, `build-macos`, `build-windows`
2. Collects release files (AppImage, .deb, .dmg, .7z, .exe, checksums)
3. Creates a Gitea release via `gitea.com/actions/release-action`
4. Requires `RELEASE_TOKEN` secret with repository write permissions
1. Downloads all artifacts from completed build jobs
2. Collects release files (AppImage, .deb, checksums) into a `release/` directory
3. Deletes any existing Gitea release for the same tag (allows re-running)
4. Creates a new Gitea release via the REST API (`/api/v1/repos/{owner}/{repo}/releases`)
5. Uploads each artifact as a release attachment via the API
The release payload (tag name, body, prerelease flag) is built entirely in Python to avoid shell/Python type mismatches. Tags containing `rc`, `beta`, or `alpha` are automatically marked as pre-releases.
Requires `RELEASE_TOKEN` secret with repository write permissions.
---
@@ -174,6 +184,27 @@ container:
network: bridge
```
### Runner cleanup daemon
A cleanup script at `.gitea/runner/cleanup.sh` prevents disk exhaustion on self-hosted runners. It uses a tiered approach based on disk usage thresholds:
| Threshold | Action |
|-----------|--------|
| 70% | Docker cleanup (stopped containers, dangling images, build cache) |
| 80% | Purge act_runner cache entries older than 7 days, clean inactive workspaces |
| 90% | System cleanup (apt cache, old logs, journal vacuum to 100 MB) |
| 95% | Emergency: remove all act_runner cache entries and Docker images |
Install via the provided systemd units (`.gitea/runner/cleanup.service` and `.gitea/runner/cleanup.timer`) to run every 30 minutes:
```bash
sudo cp .gitea/runner/cleanup.sh /usr/local/bin/runner-cleanup.sh
sudo cp .gitea/runner/cleanup.service /etc/systemd/system/
sudo cp .gitea/runner/cleanup.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now cleanup.timer
```
---
## Secrets
@@ -214,11 +245,12 @@ Defined in `CMakePresets.json`. Release builds use:
### ccache
Compiler cache is used across all builds to speed up incremental compilation. Cache is persisted between CI runs via `actions/cache`. Configuration:
Compiler cache is used across all builds to speed up incremental compilation. Cache is persisted between CI runs via `actions/cache` with date-based key rotation. Configuration:
- Max size: 4 GB
- Compression: zlib level 6
- Sloppy mode: include timestamps, PCH defines, time macros
- `CCACHE_BASEDIR`: set to workspace root (build workflow) or `$SRC_DIR` (rattler-build) for path normalization across runs
---
@@ -243,9 +275,12 @@ The Docker container installs only minimal dependencies. If a new dependency is
ccache misses spike when:
- The compiler version changes (pixi update)
- CMake presets change configuration flags
- The cache key doesn't match (new branch, force-pushed SHA)
- First build of the day (date-based key rotates daily)
- New branch without a prior cache (falls back to `main` cache)
Check `pixi run ccache -s` output for hit/miss ratios.
For release builds, ensure `build.sh` is correctly setting `CCACHE_DIR=/tmp/ccache-kindred-create` -- rattler-build does not forward environment variables from the workflow, so ccache config must be set inside the script.
Check `pixi run ccache -s` output (printed in the "Show ccache statistics" step) for hit/miss ratios. The "Prepare ccache" step also prints the full ccache configuration via `ccache -p`.
### Submodule checkout fails

View File

@@ -62,12 +62,12 @@ These appear in the File menu and "Origin Tools" toolbar across all workbenches
| `Silo_Rollback` | Rollback to previous revision |
| `Silo_SetStatus` | Set revision status (draft/review/released/obsolete) |
| `Silo_Settings` | Configure API URL, projects dir, SSL certificates |
| `Silo_ToggleMode` | Swap Ctrl+O/S/N between FreeCAD and Silo commands |
| `Silo_Auth` | Login/logout authentication panel |
**Global integration** via `SiloMenuManipulator` in `src/Mod/Create/InitGui.py`:
- File menu: Silo_New, Silo_Open, Silo_Save, Silo_Commit, Silo_Pull, Silo_Push, Silo_BOM
- File toolbar: Silo_ToggleMode button
**Global integration** via the origin system in `src/Gui/`:
- File toolbar: `Std_Origin` selector widget + `Std_New`/`Std_Open`/`Std_Save` (delegate to current origin)
- Origin Tools toolbar: `Origin_Commit`/`Origin_Pull`/`Origin_Push`/`Origin_Info`/`Origin_BOM` (auto-disable by capability)
- Silo origin registered at startup by `src/Mod/Create/InitGui.py`
**Server architecture:** Go REST API (38+ routes) + PostgreSQL + MinIO S3. Authentication via local (bcrypt), LDAP, or OIDC backends. See `mods/silo/docs/` for server documentation.

View File

@@ -58,11 +58,11 @@ The Python API provides everything needed for feature creation, command registra
The Create module is a thin Python loader that:
- Adds `mods/` addon paths to `sys.path` and executes their `Init.py`/`InitGui.py` files
- Installs `SiloMenuManipulator` for global File menu/toolbar injection
- Registers the Silo FileOrigin so the origin selector can offer it at startup
- Sets up deferred Silo dock panels (auth, activity) via `QTimer`
- Handles first-start configuration
This layer does not contain C++ code. It uses FreeCAD's `WorkbenchManipulator` API for menu/toolbar injection.
This layer does not contain C++ code.
### Layer 4: Kindred Workbenches -- `mods/`
@@ -116,9 +116,9 @@ Pure Python workbenches following FreeCAD's addon pattern. Self-contained with `
**Goal:** Silo commands available globally, not just in the Silo workbench.
**Implementation:** `SiloMenuManipulator` in `src/Mod/Create/InitGui.py` uses `FreeCADGui.addWorkbenchManipulator()` to inject Silo commands into the File menu and toolbar across all workbenches. `Silo_ToggleMode` provides a one-click swap of Ctrl+O/S/N between standard FreeCAD and Silo file commands.
**Implementation:** The unified origin system (`FileOrigin`, `OriginManager`, `OriginSelectorWidget`) in `src/Gui/` delegates all file operations (New/Open/Save) to the selected origin. Standard commands (`Std_New`, `Std_Open`, `Std_Save`) and origin commands (`Origin_Commit`, `Origin_Pull`, `Origin_Push`, `Origin_Info`, `Origin_BOM`) are built into the File toolbar and menu. The Silo workbench no longer has its own toolbar — it only provides a menu with admin/management commands.
**Dock panels:** Database Auth (1500ms) and Database Activity (4000ms) panels are created via deferred QTimers and docked in the right panel area.
**Dock panels:** Database Auth (2000ms) and Database Activity (4000ms) panels are created via deferred QTimers and docked in the right panel area.
### Phase 6: Build system integration -- PARTIAL

View File

@@ -8,7 +8,7 @@
2. **WorkbenchManipulator timing.** The `_ZToolsPartDesignManipulator` appends commands by name. If ZToolsWorkbench hasn't been activated when the user switches to PartDesign, the commands may not be registered. The manipulator API tolerates missing commands silently, but buttons won't appear.
3. **Silo shortcut persistence.** `Silo_ToggleMode` stores original shortcuts in a module-level dict. If FreeCAD crashes with Silo mode on, original shortcuts are lost on next launch.
3. ~~**Silo shortcut persistence.**~~ Resolved. `Silo_ToggleMode` removed; file operations now delegate to the selected origin via the unified origin system.
### High

View File

@@ -1,7 +1,7 @@
# Kindred Create
**Last updated:** 2026-02-06
**Branch:** main @ `c858706d480`
**Last updated:** 2026-02-07
**Branch:** main @ `7bec3d5c3b2`
**Kindred Create:** v0.1.0
**FreeCAD base:** v1.0.0
@@ -19,11 +19,13 @@
| Submodule | Path | Source | Pinned commit |
|-----------|------|--------|---------------|
| ztools | `mods/ztools` | `gitea.kindred.internal/kindred/ztools-0065` | `d2f94c3` |
| silo | `mods/silo` | `gitea.kindred.internal/kindred/silo-0062` | `27e112e` |
| OndselSolver | `src/3rdParty/OndselSolver` | `gitea.kindred.internal/kindred/ondsel` | `5d1988b` |
| ztools | `mods/ztools` | `git.kindred-systems.com/forbes/ztools` | `d2f94c3` |
| silo-mod | `mods/silo` | `git.kindred-systems.com/kindred/silo-mod` | `bf0b843` |
| OndselSolver | `src/3rdParty/OndselSolver` | `git.kindred-systems.com/kindred/solver` | `5d1988b` |
| GSL | `src/3rdParty/GSL` | `github.com/microsoft/GSL` | `756c91a` |
| AddonManager | `src/Mod/AddonManager` | `github.com/FreeCAD/AddonManager` | `01e242e` |
| googletest | `tests/lib` | `github.com/google/googletest` | `56efe39` |
The silo submodule was split from a monorepo into three repos: `silo-client` (shared Python API client), `silo-mod` (FreeCAD workbench, used as Create's submodule), and `silo-calc` (LibreOffice Calc extension). The `silo-mod` repo includes `silo-client` as its own submodule.
OndselSolver is forked from `github.com/FreeCAD/OndselSolver` to carry a Newton-Raphson convergence fix (see [KNOWN_ISSUES.md](KNOWN_ISSUES.md#12)).

View File

@@ -25,6 +25,10 @@ requirements:
- qt6-main>=6.8,<6.9
- swig >=4.0,<4.4
- if: linux
then:
- patchelf
- if: linux and x86_64
then:
- clang

View File

@@ -65,34 +65,15 @@ def _check_silo_first_start():
FreeCAD.Console.PrintLog(f"Create: Silo first-start check skipped: {e}\n")
def _setup_silo_menu():
"""Inject Silo commands into the File menu and toolbar across all workbenches."""
def _register_silo_origin():
"""Register Silo as a file origin so the origin selector can offer it."""
try:
# Import silo_commands eagerly so commands are registered before the
# manipulator tries to add them to toolbars/menus.
import silo_commands # noqa: F401
import silo_commands # noqa: F401 - registers Silo commands
import silo_origin
class SiloMenuManipulator:
def modifyMenuBar(self):
return [
{"insert": "Silo_New", "menuItem": "Std_New", "after": ""},
{"insert": "Silo_Open", "menuItem": "Std_Open", "after": ""},
{"insert": "Silo_Save", "menuItem": "Std_Save", "after": ""},
{"insert": "Silo_Commit", "menuItem": "Silo_Save", "after": ""},
{"insert": "Silo_Pull", "menuItem": "Silo_Commit", "after": ""},
{"insert": "Silo_Push", "menuItem": "Silo_Pull", "after": ""},
{"insert": "Silo_BOM", "menuItem": "Silo_Push", "after": ""},
]
def modifyToolBars(self):
return [
{"append": "Silo_ToggleMode", "toolBar": "File"},
]
FreeCADGui.addWorkbenchManipulator(SiloMenuManipulator())
FreeCAD.Console.PrintLog("Create: Silo menu manipulator installed\n")
silo_origin.register_silo_origin()
except Exception as e:
FreeCAD.Console.PrintLog(f"Create: Silo menu setup skipped: {e}\n")
FreeCAD.Console.PrintLog(f"Create: Silo origin registration skipped: {e}\n")
def _setup_silo_auth_panel():
@@ -171,8 +152,8 @@ def _setup_silo_activity_panel():
try:
from PySide.QtCore import QTimer
QTimer.singleShot(1500, _setup_silo_auth_panel)
QTimer.singleShot(2000, _setup_silo_menu)
QTimer.singleShot(1500, _register_silo_origin)
QTimer.singleShot(2000, _setup_silo_auth_panel)
QTimer.singleShot(3000, _check_silo_first_start)
QTimer.singleShot(4000, _setup_silo_activity_panel)
except Exception:

View File

@@ -4,6 +4,7 @@
add_executable(Gui_tests_run
Assistant.cpp
Camera.cpp
OriginManager.cpp
StyleParameters/StyleParametersApplicationTest.cpp
StyleParameters/ParserTest.cpp
StyleParameters/ParameterManagerTest.cpp

View 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);
}