Compare commits
13 Commits
fix/merge-
...
feat/updat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35302154ae | ||
|
|
be7f1d9221 | ||
|
|
10b5c9d584 | ||
|
|
ee839c23b8 | ||
|
|
67f825c305 | ||
| 440df2a9be | |||
| b2c6fc2ebc | |||
|
|
62906b0269 | ||
| 99bc7629e7 | |||
| 1f7dae4f11 | |||
| 1d7e4e2eef | |||
|
|
0e5a259d14 | ||
|
|
bfb2728f8d |
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)).
|
||||
|
||||
Submodule mods/silo updated: fcb0a214e2...45e803402d
@@ -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
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
#include "OriginManager.h"
|
||||
#include "FileOrigin.h"
|
||||
#include "BitmapFactory.h"
|
||||
#include "Application.h"
|
||||
#include "Command.h"
|
||||
|
||||
|
||||
namespace Gui {
|
||||
@@ -176,10 +178,7 @@ FileOrigin* OriginManagerDialog::selectedOrigin() const
|
||||
|
||||
void OriginManagerDialog::onAddSilo()
|
||||
{
|
||||
// TODO: Open SiloConfigDialog for adding new instance
|
||||
QMessageBox::information(this, tr("Add Silo"),
|
||||
tr("Silo configuration dialog not yet implemented.\n\n"
|
||||
"To add a Silo instance, configure it in the Silo workbench preferences."));
|
||||
Application::Instance->commandManager().runCommandByName("Silo_Settings");
|
||||
}
|
||||
|
||||
void OriginManagerDialog::onEditOrigin()
|
||||
@@ -189,10 +188,7 @@ void OriginManagerDialog::onEditOrigin()
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Open SiloConfigDialog for editing
|
||||
QMessageBox::information(this, tr("Edit Origin"),
|
||||
tr("Origin editing not yet implemented.\n\n"
|
||||
"To edit this origin, modify settings in the Silo workbench preferences."));
|
||||
Application::Instance->commandManager().runCommandByName("Silo_Settings");
|
||||
}
|
||||
|
||||
void OriginManagerDialog::onRemoveOrigin()
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
# Kindred Create core module
|
||||
# Handles auto-loading of ztools and Silo addons
|
||||
|
||||
# Generate version.py from template with Kindred Create version
|
||||
configure_file(
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/version.py.in
|
||||
${CMAKE_CURRENT_BINARY_DIR}/version.py
|
||||
@ONLY
|
||||
)
|
||||
|
||||
# Install Python init files
|
||||
install(
|
||||
FILES
|
||||
Init.py
|
||||
InitGui.py
|
||||
update_checker.py
|
||||
${CMAKE_CURRENT_BINARY_DIR}/version.py
|
||||
DESTINATION
|
||||
Mod/Create
|
||||
)
|
||||
|
||||
@@ -148,6 +148,16 @@ def _setup_silo_activity_panel():
|
||||
FreeCAD.Console.PrintLog(f"Create: Silo activity panel skipped: {e}\n")
|
||||
|
||||
|
||||
def _check_for_updates():
|
||||
"""Check for application updates in the background."""
|
||||
try:
|
||||
from update_checker import _run_update_check
|
||||
|
||||
_run_update_check()
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintLog(f"Create: Update check skipped: {e}\n")
|
||||
|
||||
|
||||
# Defer enhancements until the GUI event loop is running
|
||||
try:
|
||||
from PySide.QtCore import QTimer
|
||||
@@ -156,5 +166,6 @@ try:
|
||||
QTimer.singleShot(2000, _setup_silo_auth_panel)
|
||||
QTimer.singleShot(3000, _check_silo_first_start)
|
||||
QTimer.singleShot(4000, _setup_silo_activity_panel)
|
||||
QTimer.singleShot(10000, _check_for_updates)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
165
src/Mod/Create/update_checker.py
Normal file
165
src/Mod/Create/update_checker.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""Kindred Create update checker.
|
||||
|
||||
Queries the Gitea releases API to determine if a newer version is
|
||||
available. Designed to run in the background on startup without
|
||||
blocking the UI.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import FreeCAD
|
||||
|
||||
_RELEASES_URL = "https://git.kindred-systems.com/api/v1/repos/kindred/create/releases"
|
||||
_PREF_PATH = "User parameter:BaseApp/Preferences/Mod/KindredCreate/Update"
|
||||
_TIMEOUT = 5
|
||||
|
||||
|
||||
def _parse_version(tag):
|
||||
"""Parse a version tag like 'v0.1.3' into a comparable tuple.
|
||||
|
||||
Returns None if the tag doesn't match the expected pattern.
|
||||
"""
|
||||
m = re.match(r"^v?(\d+)\.(\d+)\.(\d+)$", tag)
|
||||
if not m:
|
||||
return None
|
||||
return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
||||
|
||||
|
||||
def check_for_update(current_version):
|
||||
"""Check if a newer release is available on Gitea.
|
||||
|
||||
Args:
|
||||
current_version: Version string like "0.1.3".
|
||||
|
||||
Returns:
|
||||
Dict with update info if a newer version exists, None otherwise.
|
||||
Dict keys: version, tag, release_url, assets, body.
|
||||
"""
|
||||
current = _parse_version(current_version)
|
||||
if current is None:
|
||||
return None
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{_RELEASES_URL}?limit=10",
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp:
|
||||
releases = json.loads(resp.read())
|
||||
|
||||
best = None
|
||||
best_version = current
|
||||
|
||||
for release in releases:
|
||||
if release.get("draft"):
|
||||
continue
|
||||
if release.get("prerelease"):
|
||||
continue
|
||||
|
||||
tag = release.get("tag_name", "")
|
||||
# Skip the rolling 'latest' tag
|
||||
if tag == "latest":
|
||||
continue
|
||||
|
||||
ver = _parse_version(tag)
|
||||
if ver is None:
|
||||
continue
|
||||
|
||||
if ver > best_version:
|
||||
best_version = ver
|
||||
best = release
|
||||
|
||||
if best is None:
|
||||
return None
|
||||
|
||||
assets = []
|
||||
for asset in best.get("assets", []):
|
||||
assets.append(
|
||||
{
|
||||
"name": asset.get("name", ""),
|
||||
"url": asset.get("browser_download_url", ""),
|
||||
"size": asset.get("size", 0),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"version": ".".join(str(x) for x in best_version),
|
||||
"tag": best["tag_name"],
|
||||
"release_url": best.get("html_url", ""),
|
||||
"assets": assets,
|
||||
"body": best.get("body", ""),
|
||||
}
|
||||
|
||||
|
||||
def _should_check(param):
|
||||
"""Determine whether an update check should run now.
|
||||
|
||||
Args:
|
||||
param: FreeCAD parameter group for update preferences.
|
||||
|
||||
Returns:
|
||||
True if a check should be performed.
|
||||
"""
|
||||
if not param.GetBool("CheckEnabled", True):
|
||||
return False
|
||||
|
||||
last_check = param.GetString("LastCheckTimestamp", "")
|
||||
if not last_check:
|
||||
return True
|
||||
|
||||
interval_days = param.GetInt("CheckIntervalDays", 1)
|
||||
if interval_days <= 0:
|
||||
return True
|
||||
|
||||
try:
|
||||
last_dt = datetime.fromisoformat(last_check)
|
||||
now = datetime.now(timezone.utc)
|
||||
elapsed = (now - last_dt).total_seconds()
|
||||
return elapsed >= interval_days * 86400
|
||||
except (ValueError, TypeError):
|
||||
return True
|
||||
|
||||
|
||||
def _run_update_check():
|
||||
"""Entry point called from the deferred startup timer."""
|
||||
param = FreeCAD.ParamGet(_PREF_PATH)
|
||||
|
||||
if not _should_check(param):
|
||||
return
|
||||
|
||||
try:
|
||||
from version import VERSION
|
||||
except ImportError:
|
||||
FreeCAD.Console.PrintLog(
|
||||
"Create: update check skipped — version module not available\n"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
result = check_for_update(VERSION)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintLog(f"Create: update check failed: {e}\n")
|
||||
return
|
||||
|
||||
# Record that we checked
|
||||
param.SetString(
|
||||
"LastCheckTimestamp",
|
||||
datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
if result is None:
|
||||
FreeCAD.Console.PrintLog("Create: application is up to date\n")
|
||||
return
|
||||
|
||||
skipped = param.GetString("SkippedVersion", "")
|
||||
if result["version"] == skipped:
|
||||
FreeCAD.Console.PrintLog(
|
||||
f"Create: update {result['version']} available but skipped by user\n"
|
||||
)
|
||||
return
|
||||
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"Kindred Create {result['version']} is available (current: {VERSION})\n"
|
||||
)
|
||||
1
src/Mod/Create/version.py.in
Normal file
1
src/Mod/Create/version.py.in
Normal file
@@ -0,0 +1 @@
|
||||
VERSION = "@KINDRED_CREATE_VERSION@"
|
||||
@@ -4,6 +4,7 @@
|
||||
add_executable(Gui_tests_run
|
||||
Assistant.cpp
|
||||
Camera.cpp
|
||||
OriginManager.cpp
|
||||
StyleParameters/StyleParametersApplicationTest.cpp
|
||||
StyleParameters/ParserTest.cpp
|
||||
StyleParameters/ParameterManagerTest.cpp
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user