mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-22 00:12:34 +00:00
Merge remote-tracking branch 'origin/feature/texture_displacement' into feature/texture_displacement
# Conflicts: # src/libslic3r/TextureDisplacement.cpp
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
# Design-tab scripts
|
||||
|
||||
Everything here supports the parametric Design tab (`src/libslic3r/CAD/`,
|
||||
`src/slic3r/GUI/CAD/`). Nothing here is needed to build or run OrcaSlicer — these
|
||||
are the development and verification tools for that one feature.
|
||||
|
||||
The verb in the name is the role:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `build-…` | produce a binary |
|
||||
| `start-…` | bring something up and leave it running |
|
||||
| `run-…` | run a suite and report pass/fail |
|
||||
| `check-…` | one specific assertion, usually driving a live app |
|
||||
|
||||
## Verification
|
||||
|
||||
| Script | What it proves | Needs |
|
||||
|---|---|---|
|
||||
| `run-kernel-tests.sh` | The CAD kernel builds and the Catch2 `[CadDocument]` tags pass — every case builds a document, recomputes it and asserts on real geometry. **Exit 0 is the verification contract.** | Docker only. No display. |
|
||||
| `run-all-checks.sh` | Every check below, in one command. The gate before pushing a Design-tab change. | Docker + the GUI container |
|
||||
| `check-sketch-engine.py` | A ladder of 2D sketches of increasing complexity, judged on loop count, closure and void attribution rather than on area. | Kernel only |
|
||||
| `check-sketch-engine-corpus.py` | The same ladder graded against a systematic sample of real drawings instead of shapes we chose. | Kernel + corpus |
|
||||
| `check-gui-sketching.py` | The same profiles drawn the way a person draws them — synthetic mouse gestures and typed values. | Headless GUI |
|
||||
| `check-gui-context-menu.py` | That right-click is the pivot of the design gesture, and adapts to what was clicked. | Headless GUI |
|
||||
| `check-mcp-sketch.py` | The sketch layer driven over the MCP socket, asserting what decides whether a profile is buildable. | Headless GUI + `ORCA_CAD_MCP` |
|
||||
|
||||
**`run-kernel-tests.sh` is the only one CI can run.** The rest need a live
|
||||
application with an OpenGL canvas and synthetic input, which hosted runners do not
|
||||
have. The kernel suite itself is already in CI by an ordinary route: the cases are
|
||||
registered in `tests/libslic3r/CMakeLists.txt` under `if (SLIC3R_CAD)`, so they are
|
||||
part of `libslic3r_tests` and run under `ctest` on every platform like any other
|
||||
unit test. This script exists for the local loop, where it is a two-minute round
|
||||
trip instead of a full application build.
|
||||
|
||||
## Build and run
|
||||
|
||||
| Script | Purpose |
|
||||
|---|---|
|
||||
| `build-gui.sh` | Build the GUI binary in a throwaway container, writing into the build-cache volume the long-lived GUI container reads. |
|
||||
| `build-gui-incremental.sh` | Incremental build against the deps-baked image, for a fast edit/compile loop. |
|
||||
| `start-headless-gui.sh` | Bring the app up on a headless X display (Xvfb + a window manager), ready to drive or attach to over VNC. |
|
||||
|
||||
Two constraints that are not obvious and have each cost a session:
|
||||
|
||||
- **Never build inside the GUI container.** Its baked source tree silently
|
||||
reconfigures the shared build directory and this fork's targets vanish.
|
||||
- **A window manager is required.** Without one, windows are never focused, and an
|
||||
unfocused GTK app ignores synthetic keys — which looks exactly like a code bug.
|
||||
|
||||
`docs/rig_build_traps.md` documents these and three more, with symptoms and exact
|
||||
recovery commands. Read it before debugging a configure or link failure one of
|
||||
these scripts reports.
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# Incremental slicer build against the orcacad-deps base image.
|
||||
#
|
||||
# The deps-baked image (built from scripts/Dockerfile.deps) carries the pinned
|
||||
# dependencies at /OrcaSlicer/deps/build/destdir. This script mounts the LIVE source
|
||||
# tree and resources over the baked copy so code/CMake edits apply immediately, and
|
||||
# persists /OrcaSlicer/build in a named volume so ninja recompiles only what changed.
|
||||
#
|
||||
# Result: edit -> rebuild in seconds-to-minutes instead of a full Docker rebuild.
|
||||
#
|
||||
# Usage (run on the build host, e.g. behemoth, from anywhere):
|
||||
# scripts/CAD/build-gui-incremental.sh
|
||||
# IMAGE=orcacad-deps scripts/CAD/build-gui-incremental.sh
|
||||
#
|
||||
# On success the binary is inside the persistent volume at
|
||||
# /OrcaSlicer/build/package/bin/orca-slicer (copy it out with a follow-up
|
||||
# `docker run --rm -v orcacad_buildcache:/b alpine cp ...` or via this script's tail).
|
||||
# Rig build traps already paid for once each (stale project, NLopt cache, pybind11, OCCT_LIBS, SLIC3R_CAD gate): docs/rig_build_traps.md
|
||||
set -euo pipefail
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
# orcacad-deps, NOT snapmaker-deps: see the note in run-kernel-tests.sh — the wrong image
|
||||
# fails at CMake configure, not at link time.
|
||||
IMAGE="${IMAGE:-orcacad-deps}"
|
||||
BUILD_VOL="${BUILD_VOL:-orcacad_buildcache}"
|
||||
|
||||
echo "REPO=$REPO IMAGE=$IMAGE BUILD_VOL=$BUILD_VOL"
|
||||
|
||||
# The root CMakeLists.txt and cmake/ must be mounted too, not taken from the baked image:
|
||||
# they carry the build-time gates (e.g. SLIC3R_CAD -> add_definitions(-DSLIC3R_CAD)) that the
|
||||
# mounted headers are compiled against. With a stale baked copy the gate silently stays off and
|
||||
# the build fails with "class GLCanvas3D has no member named set_design_sketch_tool".
|
||||
#
|
||||
# build_linux.sh must be mounted for the same reason, and here the stale copy is guaranteed
|
||||
# wrong rather than merely risky: orcacad-deps is layered on snapmaker-deps, so the baked script
|
||||
# is the OTHER fork's and builds `--target Snapmaker_Orca`. This fork's target is `OrcaSlicer`,
|
||||
# so without this mount configure succeeds and then ninja dies on "unknown target".
|
||||
# scripts/ likewise: build_linux.sh's packaging step sources scripts/appimage_lib_policy.sh,
|
||||
# which the baked Snapmaker tree does not have, so a fully successful link still exited
|
||||
# non-zero with "missing AppImage helper" and the binary check never ran.
|
||||
|
||||
# ---- OOM guard (2026-08-21) -------------------------------------------------------------
|
||||
# Two of these builds ran at once on 2026-08-21, each with ninja -j$(nproc)=16: ~36 cc1plus
|
||||
# holding 42 GB of a 62 GB box -> global OOM at 21:05, a 2h28m kill storm, ssh unreachable,
|
||||
# lightdm destroyed. Neither build produced a single object. scripts/CAD/build-gui.sh grew the
|
||||
# bounds first; every script that starts a compile needs the same three, or the guard is only
|
||||
# as strong as the script you happened not to use.
|
||||
# flock — the lock path is SHARED with build-gui.sh and the other fork on purpose, so
|
||||
# concurrent builds serialise instead of summing.
|
||||
# -j — bounded parallelism; ~1.17 GB per cc1plus was the measured average.
|
||||
# --memory — the actual guarantee: a runaway build dies in its own cgroup instead of taking
|
||||
# the host down. --memory-swap equal to --memory forbids swap, which is what made
|
||||
# ssh hang.
|
||||
JOBS="${JOBS:-12}"
|
||||
MEM="${MEM:-40g}"
|
||||
LOCK=/tmp/orca-rig-build.lock
|
||||
|
||||
exec 9>"$LOCK"
|
||||
if ! flock -n 9; then
|
||||
echo "another build holds $LOCK — waiting (this is the OOM guard, not a hang)"
|
||||
flock 9
|
||||
fi
|
||||
|
||||
docker run --rm \
|
||||
--memory="$MEM" --memory-swap="$MEM" \
|
||||
-v "$REPO/src":/OrcaSlicer/src \
|
||||
-v "$REPO/resources":/OrcaSlicer/resources \
|
||||
-v "$REPO/CMakeLists.txt":/OrcaSlicer/CMakeLists.txt \
|
||||
-v "$REPO/cmake":/OrcaSlicer/cmake \
|
||||
-v "$REPO/deps_src":/OrcaSlicer/deps_src \
|
||||
-v "$REPO/build_linux.sh":/OrcaSlicer/build_linux.sh \
|
||||
-v "$REPO/scripts":/OrcaSlicer/scripts \
|
||||
-v "$BUILD_VOL":/OrcaSlicer/build \
|
||||
"$IMAGE" \
|
||||
bash -lc "cd /OrcaSlicer && ./build_linux.sh -sr -j $JOBS"
|
||||
|
||||
# src/CMakeLists.txt:151 renames the OrcaSlicer target's output to "orca-slicer" — not
|
||||
# "snapmaker-orca", which is the other fork's binary name.
|
||||
echo "=== build finished; checking for binary ==="
|
||||
docker run --rm -v "$BUILD_VOL":/b "$IMAGE" \
|
||||
bash -lc 'ls -lh /b/package/bin/orca-slicer 2>/dev/null && file /b/package/bin/orca-slicer || echo "NO BINARY"'
|
||||
Executable
+98
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env bash
|
||||
# Rebuild the GUI binary the design rig launches — in a THROWAWAY container, writing into the
|
||||
# same build-cache volume the rig's long-lived GUI container reads from.
|
||||
#
|
||||
# NEVER build inside the GUI container (snapmaker-gui / orcacad-gui). Its baked /OrcaSlicer tree
|
||||
# is the Jun-13 Snapmaker-derived source, so a `cmake .` in there silently reconfigures the
|
||||
# shared build dir as project(Snapmaker_Orca) and this fork's targets vanish. That is Trap 1 of
|
||||
# five; all of them, with symptoms and exact recovery commands, are in docs/rig_build_traps.md.
|
||||
# Read that file before debugging a configure or link failure this script reports.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/CAD/build-gui.sh # configure + build the fork's GUI target
|
||||
# DRY_RUN=1 scripts/CAD/build-gui.sh # print the resolved fork identity and exit, no container
|
||||
set -euo pipefail
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
|
||||
# Fork identity is DERIVED from the repo, never hardcoded, so this file is byte-identical in
|
||||
# both forks and cannot be mirrored into the wrong one. Pointing a fork at the other fork's
|
||||
# image or volume is not a slow failure: with the wrong image CMake dies at configure, and with
|
||||
# the wrong volume the two forks silently trade build artefacts.
|
||||
PROJECT="$(sed -n 's/^project(\([A-Za-z_0-9]*\)).*/\1/p' "$REPO/CMakeLists.txt" | head -1)"
|
||||
case "$PROJECT" in
|
||||
Snapmaker_Orca) PREFIX=snapmaker; BIN=snapmaker-orca ;;
|
||||
OrcaSlicer) PREFIX=orcacad; BIN=orca-slicer ;;
|
||||
*) echo "FATAL: unrecognised project($PROJECT) in $REPO/CMakeLists.txt" >&2; exit 2 ;;
|
||||
esac
|
||||
TARGET="$PROJECT"
|
||||
IMAGE="${PREFIX}-deps"
|
||||
BUILD_VOL="${PREFIX}_buildcache"
|
||||
|
||||
echo "REPO=$REPO PROJECT=$PROJECT IMAGE=$IMAGE BUILD_VOL=$BUILD_VOL TARGET=$TARGET BIN=$BIN"
|
||||
|
||||
if [ -n "${DRY_RUN:-}" ]; then
|
||||
echo "DRY_RUN: resolution only, no container started"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Three memory bounds. On 2026-08-21 both forks ran this script at the same time, each with
|
||||
# ninja -j$(nproc)=16: ~36 cc1plus holding 42 GB of a 62 GB box -> global OOM at 21:05, a
|
||||
# 2h28m kill storm, ssh unreachable, lightdm destroyed, 2946 session kill events. Neither
|
||||
# build produced a single object. The bounds, weakest to strongest:
|
||||
# flock — the lock path is shared by both forks on purpose, so they SERIALISE instead of
|
||||
# summing. Peak is one build's worth no matter who else starts one.
|
||||
# -j12 — measured 1.17 GB average per cc1plus in the incident dump, so 12 in flight
|
||||
# is ~14 GB typical and leaves the box usable. JOBS=n overrides.
|
||||
# --memory — the actual guarantee. A runaway build hits its own cgroup limit and dies alone;
|
||||
# the host never reaches global OOM again, whatever -j or flock do.
|
||||
# --memory-swap equal to --memory forbids swap, which is what made ssh hang.
|
||||
JOBS="${JOBS:-12}"
|
||||
MEM="${MEM:-40g}"
|
||||
LOCK=/tmp/orca-rig-build.lock
|
||||
|
||||
exec 9>"$LOCK"
|
||||
if ! flock -n 9; then
|
||||
echo "another fork's build-gui holds $LOCK — waiting (this is the OOM guard, not a hang)"
|
||||
flock 9
|
||||
fi
|
||||
|
||||
# Every one of these mounts covers a trap, none is decorative:
|
||||
# CMakeLists.txt + cmake/ carry the SLIC3R_CAD gate — inherit the baked copies and the cache
|
||||
# says SLIC3R_CAD=ON while -DSLIC3R_CAD is never defined, so every #ifdef block compiles out.
|
||||
# deps_src/ carries pybind11, which the image predates.
|
||||
# src/, resources/, localization/, version.inc are the code under test.
|
||||
rc=0
|
||||
docker run --rm \
|
||||
--memory="$MEM" --memory-swap="$MEM" \
|
||||
-v "$REPO/src":/OrcaSlicer/src \
|
||||
-v "$REPO/resources":/OrcaSlicer/resources \
|
||||
-v "$REPO/cmake":/OrcaSlicer/cmake \
|
||||
-v "$REPO/deps_src":/OrcaSlicer/deps_src \
|
||||
-v "$REPO/localization":/OrcaSlicer/localization \
|
||||
-v "$REPO/CMakeLists.txt":/OrcaSlicer/CMakeLists.txt \
|
||||
-v "$REPO/version.inc":/OrcaSlicer/version.inc \
|
||||
-v "$BUILD_VOL":/OrcaSlicer/build \
|
||||
"$IMAGE" bash -lc "
|
||||
cd /OrcaSlicer/build || exit 1
|
||||
cmake . > /tmp/cfg.log 2>&1 || { echo 'CONFIGURE FAILED'; tail -25 /tmp/cfg.log; exit 1; }
|
||||
# Twice, deliberately. src/libslic3r/CMakeLists.txt publishes OCCT_LIBS as CACHE INTERNAL
|
||||
# at the END of its own configure, so a first pass after that list changes links the
|
||||
# PREVIOUS one and drops TKBool/TKOffset — a wall of TopOpeBRepBuild undefined references
|
||||
# that reads as a broken OCCT install and is not. Trap 4.
|
||||
cmake . > /tmp/cfg2.log 2>&1 || { echo 'RECONFIGURE FAILED'; tail -25 /tmp/cfg2.log; exit 1; }
|
||||
ninja -f build-Release.ninja -j$JOBS $TARGET > /tmp/bld.log 2>&1
|
||||
rc=\$?
|
||||
echo \"EXIT=\$rc\"
|
||||
grep -n 'error:' /tmp/bld.log | head -20
|
||||
tail -4 /tmp/bld.log
|
||||
ls -la /OrcaSlicer/build/src/Release/$BIN 2>/dev/null
|
||||
exit \$rc
|
||||
" || rc=$?
|
||||
|
||||
# A target-only build writes src/Release/, but this fork's start-headless-gui.sh may default BIN to the
|
||||
# PACKAGED path that only build_linux.sh refreshes — launching with the default would then run a
|
||||
# stale binary. Pass BIN explicitly. See docs/rig_build_traps.md.
|
||||
echo "=== launch the rig on the binary just built ==="
|
||||
echo " docker exec -e BIN=/OrcaSlicer/build/src/Release/$BIN ${PREFIX}-gui /OrcaSlicer/scripts/CAD/start-headless-gui.sh"
|
||||
exit "$rc"
|
||||
Executable
+658
@@ -0,0 +1,658 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The click-edit contract: a value field that opens must accept what is TYPED into it.
|
||||
|
||||
WHY THIS EXISTS SEPARATELY FROM check-gui-sketching.py. That ladder draws geometry and grades the
|
||||
result, and to make its values land it calls focus_field() — one synthetic click INTO the field
|
||||
before typing. Its own docstring says why:
|
||||
|
||||
WITHOUT THIS THE TYPED VALUE IS SILENTLY DISCARDED. The field is shown and raised but the
|
||||
window manager does not give it the keyboard, so xdotool's digits go to the canvas and Return
|
||||
commits the value the field opened with — the pre-filled as-drawn number.
|
||||
|
||||
That click is a workaround for a defect, and a suite that performs it can never see the defect
|
||||
again. A user cannot be told to click the field first; when they do not, they get the as-drawn
|
||||
number and report "the label value is not editable". So this ladder types IMMEDIATELY after the
|
||||
field opens, exactly as a person does, and fails if the prefill is what gets committed.
|
||||
|
||||
WHAT IT GRADES. The app emits one line per event under ORCA_CAD_UXTRACE=1:
|
||||
|
||||
[UX] open title=Length prefill=154.76
|
||||
[UX] commit title=Length typed=80 value=80.0000
|
||||
[UX] refused title=Length typed=8O
|
||||
[UX] cancel title=Length
|
||||
|
||||
For every field the driver opens it asserts: a commit arrived, what the field received is what we
|
||||
typed, the parsed value equals it, and it differs from the prefill. The last clause is the one
|
||||
that matters — a field that is on screen but deaf commits its prefill, and every other signal
|
||||
(the field is visible, a constraint is created, the solve succeeds) looks perfectly healthy.
|
||||
|
||||
scripts/CAD/check-gui-click-edit.py --display :10 --bin build/src/Release/orca-slicer
|
||||
|
||||
With --attach it drives an already-running app instead of launching one; the app must have been
|
||||
started with ORCA_CAD_UXTRACE=1 and its stderr redirected to --trace.
|
||||
Exit 0 = every field took what was typed.
|
||||
"""
|
||||
import argparse, json, os, re, shutil, signal, socket, subprocess, sys, tempfile, time
|
||||
|
||||
AP = argparse.ArgumentParser()
|
||||
AP.add_argument("--display", default=os.environ.get("DISPLAY", ":10"))
|
||||
AP.add_argument("--bin", default="build/src/Release/orca-slicer")
|
||||
AP.add_argument("--datadir", default="")
|
||||
AP.add_argument("--trace", default="")
|
||||
AP.add_argument("--sock", default="/tmp/mcp-uxcheck.sock",
|
||||
help="the app's MCP socket: the oracle for whether a sketch is really open")
|
||||
AP.add_argument("--attach", action="store_true", help="drive a running app; do not launch one")
|
||||
AP.add_argument("--keep", action="store_true", help="leave the app running afterwards")
|
||||
AP.add_argument("--no-defocus", action="store_true",
|
||||
help="do NOT take focus off the field before typing (weakens the gate; see below)")
|
||||
AP.add_argument("--seed-from", default=os.path.expanduser("~/.config/OrcaCAD/OrcaSlicer.conf"),
|
||||
help="an existing OrcaSlicer.conf to copy presets/settings from")
|
||||
A = AP.parse_args()
|
||||
|
||||
DISP = A.display
|
||||
TRACE = A.trace or os.path.join(tempfile.gettempdir(), "ux-click-edit.log")
|
||||
_fail = 0
|
||||
_checks = 0
|
||||
|
||||
|
||||
_n = 0
|
||||
|
||||
|
||||
def call(method, **params):
|
||||
"""One MCP request over the app's unix socket. The socket is the only witness that cannot
|
||||
lie about sketch state: the keytrace says a key ARRIVED, a screenshot says something is on
|
||||
screen, and neither distinguishes an open sketch from sketch mode with the plane offer up."""
|
||||
global _n
|
||||
_n += 1
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(30)
|
||||
s.connect(A.sock)
|
||||
s.sendall((json.dumps({"jsonrpc": "2.0", "id": _n, "method": method,
|
||||
"params": params}) + "\n").encode())
|
||||
buf = b""
|
||||
while b"\n" not in buf:
|
||||
d = s.recv(65536)
|
||||
if not d:
|
||||
break
|
||||
buf += d
|
||||
r = json.loads(buf.decode().strip())
|
||||
if "error" in r:
|
||||
raise RuntimeError(f"{method}: {r['error']['message']}")
|
||||
return r["result"]
|
||||
|
||||
|
||||
def try_call(method, **params):
|
||||
try:
|
||||
return call(method, **params)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def sh(cmd):
|
||||
# bash -c, NOT -lc: a login shell sources the profile on every xdotool call, and this driver
|
||||
# makes hundreds. On a GNOME box that meant im-config running per call, thousands of journal
|
||||
# lines, and a window poll slow enough to time out before the app had finished starting.
|
||||
return subprocess.run(["bash", "-c", cmd], capture_output=True, text=True).stdout
|
||||
|
||||
|
||||
def xdo(args):
|
||||
sh(f"DISPLAY={DISP} xdotool {args}")
|
||||
|
||||
|
||||
def key(k, pause=0.35, window=None):
|
||||
xdo(f"key {'--window ' + str(window) + ' ' if window else ''}{k}")
|
||||
time.sleep(pause)
|
||||
|
||||
|
||||
def typ(s, pause=0.35, window=None):
|
||||
# --clearmodifiers so a modifier left down by an earlier synthetic key cannot turn digits
|
||||
# into something else; --delay 60 because ImGui reads one character per frame.
|
||||
#
|
||||
# `window` targets a specific window with XSendEvent instead of following the input focus.
|
||||
# That is the whole gate: see type_into_open_field.
|
||||
tgt = f"--window {window} " if window else ""
|
||||
xdo(f"type {tgt}--clearmodifiers --delay 60 -- '{s}'")
|
||||
time.sleep(pause)
|
||||
|
||||
|
||||
def die(msg):
|
||||
print(f"FATAL {msg}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the app
|
||||
|
||||
_proc = None
|
||||
|
||||
|
||||
def seed_datadir(datadir):
|
||||
"""The Design tab does not exist unless enable_cad_feature is on, and it needs a RESTART.
|
||||
|
||||
A fresh datadir has it off, so a driver that just points the app at an empty directory gets
|
||||
Prepare/Preview/Device/Project, no Design tab, and every rung fails for a reason that has
|
||||
nothing to do with what is being tested. Seed the flag before the first launch.
|
||||
"""
|
||||
os.makedirs(datadir, exist_ok=True)
|
||||
conf = os.path.join(datadir, "OrcaSlicer.conf")
|
||||
data = {}
|
||||
if os.path.exists(A.seed_from):
|
||||
try:
|
||||
with open(A.seed_from) as f:
|
||||
data = json.load(f)
|
||||
except Exception:
|
||||
data = {}
|
||||
app = data.setdefault("app", {})
|
||||
app["enable_cad_feature"] = True
|
||||
# Deterministic starting state for the rungs that follow: the bed drawn, loops welded as the
|
||||
# ~90% case expects. A ladder whose result depends on the developer's own preferences is not
|
||||
# a gate.
|
||||
app["auto_close_sketch_loops"] = True
|
||||
# SILENCE THE NETWORK PLUGIN PROMPT. Without this, GUI_App::post_init() re-raises "Bambu
|
||||
# Network Plug-in Required" from an IDLE event — after any modal sweep this driver does at
|
||||
# startup — and ShowModal() then runs a nested event loop. The app is alive, its window is
|
||||
# there, and the MCP socket answers nothing: indistinguishable from a hang, and it was
|
||||
# investigated as one, with gdb, twice. `installed_networking` false stops the whole
|
||||
# networking-plugin path, so m_networking_need_update is never set and the dialog never
|
||||
# exists to be swept.
|
||||
app["installed_networking"] = False
|
||||
with open(conf, "w") as f:
|
||||
json.dump(data, f, indent=1)
|
||||
for sub in ("user", "system", "presets", "vendor"):
|
||||
src = os.path.join(os.path.dirname(A.seed_from), sub)
|
||||
dst = os.path.join(datadir, sub)
|
||||
if os.path.isdir(src) and not os.path.exists(dst):
|
||||
shutil.copytree(src, dst)
|
||||
|
||||
|
||||
def launch():
|
||||
global _proc
|
||||
datadir = A.datadir or os.path.join(tempfile.gettempdir(), "orcacad-uxcheck")
|
||||
seed_datadir(datadir)
|
||||
env = dict(os.environ)
|
||||
# WAYLAND_DISPLAY MUST GO, and GDK_BACKEND must say x11. GTK prefers Wayland whenever
|
||||
# WAYLAND_DISPLAY is set and ignores DISPLAY entirely, so a driver launched from a systemd
|
||||
# user unit (which inherits it) started the app on the DESKTOP session instead of the rig:
|
||||
# the process was alive, `xdotool search` on the rig display found nothing, and the window
|
||||
# was sitting on the user's own screen. Silent, and it drives a stray app at someone's face.
|
||||
env.pop("WAYLAND_DISPLAY", None)
|
||||
env.update(DISPLAY=DISP, GDK_BACKEND="x11", ORCA_CAD_UXTRACE="1",
|
||||
LIBGL_ALWAYS_SOFTWARE="1", GALLIUM_DRIVER="llvmpipe",
|
||||
# The rig's Xvfb has no input-method daemon, and a dead ibus context makes a
|
||||
# GtkEntry drop every character while the app looks fine. It cannot affect the
|
||||
# in-canvas field (ImGui needs no IM) but the app has other text fields, and a
|
||||
# display full of IBUS warnings has cost a whole misdiagnosis before.
|
||||
GTK_IM_MODULE="gtk-im-context-simple", XMODIFIERS="@im=none",
|
||||
# The key tracer is this driver's only positive signal that a keystroke reached
|
||||
# the Design panel at all. Without it "the field never opened" is indistinguishable
|
||||
# from "we never got into sketch mode", and the first run of this ladder reported
|
||||
# seven product failures that were really one driver racing a still-loading app.
|
||||
ORCA_CAD_KEYTRACE="1", ORCA_CAD_MCP=A.sock,
|
||||
SSL_CERT_FILE="/etc/ssl/certs/ca-certificates.crt",
|
||||
WEBKIT_DISABLE_DMABUF_RENDERER="1", WEBKIT_DISABLE_COMPOSITING_MODE="1")
|
||||
if os.path.exists(A.sock):
|
||||
os.unlink(A.sock) # a stale socket from a dead run answers nothing, slowly
|
||||
log = open(TRACE, "wb")
|
||||
_proc = subprocess.Popen([A.bin, "--datadir", datadir], env=env,
|
||||
stdout=subprocess.DEVNULL, stderr=log)
|
||||
for _ in range(120):
|
||||
if win_id():
|
||||
return
|
||||
time.sleep(1)
|
||||
die("the app never showed a window on " + DISP)
|
||||
|
||||
|
||||
def window_pid(w):
|
||||
"""_NET_WM_PID for a window, or 0. The property is how we tell a live app from its ghost."""
|
||||
out = sh(f"DISPLAY={DISP} xprop -id {w} _NET_WM_PID 2>/dev/null")
|
||||
m = re.search(r"= *(\d+)", out)
|
||||
return int(m.group(1)) if m else 0
|
||||
|
||||
|
||||
def pid_alive(pid):
|
||||
return pid > 0 and os.path.isdir(f"/proc/{pid}")
|
||||
|
||||
|
||||
def win_id():
|
||||
"""The main window: OURS if we launched it, otherwise the biggest LIVE top-level.
|
||||
|
||||
Two rules here, each paid for.
|
||||
|
||||
By PID, not by size, whenever we launched the app. An X window outlives its client if the
|
||||
connection is not torn down cleanly, and a killed OrcaSlicer can leave a full-screen ghost
|
||||
mapped on the display. It answers geometry queries exactly like the real thing, it wins "the
|
||||
biggest window" every time, and every synthetic keystroke sent to it goes nowhere. That is
|
||||
indistinguishable, from the driver's side, from an app that ignores the keyboard — which is
|
||||
the very defect this ladder exists to measure. One run reported the entire contract broken
|
||||
while the real app sat beside the ghost, untouched.
|
||||
|
||||
Never by title: a saved project renames the main window.
|
||||
"""
|
||||
if _proc is not None:
|
||||
for w in sh(f"DISPLAY={DISP} xdotool search --pid {_proc.pid} --onlyvisible --name '.'").split():
|
||||
g = dict(l.split("=", 1) for l in
|
||||
sh(f"DISPLAY={DISP} xdotool getwindowgeometry --shell {w}").strip().splitlines()
|
||||
if "=" in l)
|
||||
if "WIDTH" in g and int(g["WIDTH"]) * int(g["HEIGHT"]) > 400 * 400:
|
||||
return (w, int(g["X"]), int(g["Y"]), int(g["WIDTH"]), int(g["HEIGHT"]))
|
||||
return None
|
||||
best = None
|
||||
for w in sh(f"DISPLAY={DISP} xdotool search --onlyvisible --name '.'").split():
|
||||
g = dict(l.split("=", 1) for l in
|
||||
sh(f"DISPLAY={DISP} xdotool getwindowgeometry --shell {w}").strip().splitlines()
|
||||
if "=" in l)
|
||||
if "WIDTH" not in g:
|
||||
continue
|
||||
if not pid_alive(window_pid(w)): # a ghost: no client is behind it any more
|
||||
continue
|
||||
a = int(g["WIDTH"]) * int(g["HEIGHT"])
|
||||
if a > 400 * 400 and (best is None or a > best[0]):
|
||||
best = (a, w, int(g["X"]), int(g["Y"]), int(g["WIDTH"]), int(g["HEIGHT"]))
|
||||
return best[1:] if best else None
|
||||
|
||||
|
||||
_win = None
|
||||
|
||||
|
||||
def win():
|
||||
global _win
|
||||
if _win is None:
|
||||
w = win_id()
|
||||
if w is None:
|
||||
die("no app window on " + DISP)
|
||||
sh(f"DISPLAY={DISP} xdotool windowactivate --sync {w[0]}")
|
||||
sh(f"DISPLAY={DISP} xdotool windowsize {w[0]} 1920 1080")
|
||||
sh(f"DISPLAY={DISP} xdotool windowmove {w[0]} 0 0")
|
||||
time.sleep(1.0)
|
||||
_win = (w[0], 0, 0, 1920, 1080)
|
||||
return _win
|
||||
|
||||
|
||||
def click(px, py, pause=0.5, btn=1):
|
||||
_, X, Y, _, _ = win()
|
||||
xdo(f"mousemove {X+int(px)} {Y+int(py)} click --delay 120 {btn}")
|
||||
time.sleep(pause)
|
||||
|
||||
|
||||
def visible_windows():
|
||||
"""(id, name, w, h) for every MAPPED top-level, main window included.
|
||||
|
||||
`--onlyvisible` is what makes this usable. Without it xdotool also returns the app's unmapped
|
||||
helper windows — a 10x10 and a 200x200 that exist for the whole session — and a caller that
|
||||
tries to reason about "extra windows" from that list is reasoning about furniture.
|
||||
"""
|
||||
out = []
|
||||
for w in sh(f"DISPLAY={DISP} xdotool search --onlyvisible --name '.'").split():
|
||||
g = dict(l.split("=", 1) for l in
|
||||
sh(f"DISPLAY={DISP} xdotool getwindowgeometry --shell {w}").strip().splitlines()
|
||||
if "=" in l)
|
||||
if "WIDTH" not in g:
|
||||
continue
|
||||
if not pid_alive(window_pid(w)): # see win_id(): a ghost cannot be closed, only ignored
|
||||
continue
|
||||
n = sh(f"DISPLAY={DISP} xdotool getwindowname {w}").strip()
|
||||
out.append((w, n, int(g["WIDTH"]), int(g["HEIGHT"])))
|
||||
return out
|
||||
|
||||
|
||||
def dismiss_modals(timeout=30):
|
||||
"""Close every modal over the main window, and PROVE none is left.
|
||||
|
||||
This is the rung that decides whether any of the others mean anything. A fresh datadir opens
|
||||
"Bambu Network Plug-in Required" — 440x259, centred at 742,450 — which sits exactly on top of
|
||||
the point every drawing gesture in TOOLS starts from. The whole ladder then reports eleven
|
||||
product failures, all of them the driver clicking a dialog.
|
||||
|
||||
The old version pressed Escape and moved on. This dialog ignores Escape, so it "dismissed"
|
||||
nothing and said so to no one; the run that found this was red for a reason that had nothing
|
||||
to do with the contract under test. Escape is still tried first because it is the gentlest
|
||||
thing that works on the wizard, then WM_DELETE_WINDOW, and then the function asserts what it
|
||||
was supposed to have achieved instead of assuming it.
|
||||
"""
|
||||
# NEVER run without knowing which window to spare. The first version took `keep = main[0] if
|
||||
# main else None`, so a win_id() that raced the app's mapping made keep None and every visible
|
||||
# window a modal — this function then sent WM_DELETE to the app's own main window. The app
|
||||
# survived as a process, printed "GdkWindow unexpectedly destroyed", and answered nothing
|
||||
# afterwards; the ladder reported "no sketch opened" for 180s. Losing the main window is not a
|
||||
# state to recover from silently.
|
||||
deadline = time.time() + timeout
|
||||
keep = None
|
||||
while keep is None and time.time() < deadline:
|
||||
main = win_id()
|
||||
keep = main[0] if main else None
|
||||
if keep is None:
|
||||
time.sleep(0.5)
|
||||
if keep is None:
|
||||
die(f"no main window to protect after {timeout}s — refusing to close anything")
|
||||
scr = sh(f"DISPLAY={DISP} xdotool getdisplaygeometry").split()
|
||||
full = int(scr[0]) * int(scr[1]) if len(scr) == 2 else 1920 * 1080
|
||||
while time.time() < deadline:
|
||||
# A modal is small. Anything covering half the screen is the app, whatever id win_id()
|
||||
# happened to return this instant — a second belt on the rule above, because the cost of
|
||||
# being wrong here is an app that looks alive and answers nothing.
|
||||
extra = [x for x in visible_windows() if x[0] != keep and x[2] * x[3] < full * 0.5]
|
||||
if not extra:
|
||||
return
|
||||
for (w, n, _, _) in extra:
|
||||
sh(f"DISPLAY={DISP} xdotool windowactivate {w}")
|
||||
time.sleep(0.4)
|
||||
key("Escape", 0.4)
|
||||
if any(x[0] == w for x in visible_windows()):
|
||||
sh(f"DISPLAY={DISP} xdotool windowclose {w}")
|
||||
time.sleep(0.6)
|
||||
time.sleep(0.5)
|
||||
left = [f"{n!r} ({w}x{h})" for (i, n, w, h) in visible_windows()
|
||||
if i != keep and w * h < full * 0.5]
|
||||
die("a modal is still covering the canvas after " + str(timeout) + "s: " + ", ".join(left) +
|
||||
" — every drawing gesture would land in it, so nothing below this line could be trusted")
|
||||
|
||||
|
||||
def dismiss_first_run():
|
||||
dismiss_modals()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the trace
|
||||
|
||||
def trace_lines():
|
||||
try:
|
||||
with open(TRACE, "r", errors="replace") as f:
|
||||
return [l.strip() for l in f if l.startswith("[UX] ")]
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def trace_mark():
|
||||
return len(trace_lines())
|
||||
|
||||
|
||||
def parse(line):
|
||||
m = re.match(r"\[UX\] (\w+) title=(.*?) (.*)$", line)
|
||||
if not m:
|
||||
return None
|
||||
ev, title, rest = m.group(1), m.group(2), m.group(3)
|
||||
kv = dict(re.findall(r"(\w+)=(\S*)", rest))
|
||||
return ev, title, kv
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- grading
|
||||
|
||||
def check(cond, what):
|
||||
"""Returns the verdict so a caller can abandon a rung whose precondition failed."""
|
||||
global _fail, _checks
|
||||
_checks += 1
|
||||
if cond:
|
||||
print(f" ok {what}")
|
||||
else:
|
||||
print(f" FAIL {what}", file=sys.stderr)
|
||||
_fail += 1
|
||||
return bool(cond)
|
||||
|
||||
|
||||
def type_into_open_field(value, mark):
|
||||
"""Type `value` into whatever field is open, WITHOUT clicking it first, and grade the pair.
|
||||
|
||||
No click: the click is the workaround this ladder exists to refuse. If the field cannot take
|
||||
the keyboard on its own, `typed` will be the prefill and this fails — which is the report.
|
||||
"""
|
||||
# POLL for the field. It opens from a CallAfter that runs after a re-solve, so on llvmpipe it
|
||||
# is simply not there yet when a fast driver looks — and "no field opened" is the same message
|
||||
# whether the product never opened one or the driver asked too early. Wait, then decide.
|
||||
opens = []
|
||||
deadline = time.time() + 8.0
|
||||
while time.time() < deadline:
|
||||
opens = [e for e in (parse(l) for l in trace_lines()[mark:]) if e and e[0] == "open"]
|
||||
if opens:
|
||||
break
|
||||
time.sleep(0.25)
|
||||
if not opens:
|
||||
check(False, f"a value field opened (nothing did; cannot type {value})")
|
||||
return mark
|
||||
title = opens[-1][1]
|
||||
prefill = opens[-1][2].get("prefill", "")
|
||||
m2 = trace_mark()
|
||||
# TYPE NORMALLY. NOTHING TO DEFOCUS ANY MORE.
|
||||
#
|
||||
# The value field is drawn INSIDE the GL canvas by ImGui, so it is not a window: there is no
|
||||
# second toplevel for a window manager to grant or refuse the keyboard, and the keystrokes go
|
||||
# to the app's one window exactly as a person's would. That is the entire point of the design
|
||||
# — the WM has no say — and it is why this ladder no longer tries to manufacture the failing
|
||||
# condition.
|
||||
#
|
||||
# When the field WAS a floating wxFrame, this spot held two attempts to reproduce
|
||||
# "field open, keyboard elsewhere", and both are recorded here so neither is tried again:
|
||||
# - XSetInputFocus onto the main window (`xdotool windowfocus`): the field's own re-focus
|
||||
# CallAfter wins the race every time; four retries all lost, and the ladder passed twice
|
||||
# against a binary with the fix compiled out.
|
||||
# - XSendEvent at the main window (`xdotool type --window`): GTK discards synthetic key
|
||||
# events, so NEITHER build received anything and every run was red regardless of the code.
|
||||
# A run that used the second of those is what produced "the app never saw a digit" — a
|
||||
# property of xdotool, not of the product.
|
||||
#
|
||||
# For the in-canvas field the honest gate is simply: type, and see whether the value the app
|
||||
# commits is the value that was typed.
|
||||
diag = sh(f"DISPLAY={DISP} xdotool getwindowfocus").strip()
|
||||
typ(str(value), 0.4)
|
||||
key("Return", 0.9)
|
||||
after, commits, refused, commit_at = [], [], [], None
|
||||
deadline = time.time() + 5.0
|
||||
while time.time() < deadline:
|
||||
after = [parse(l) for l in trace_lines()[m2:]]
|
||||
commits = [(i, e) for i, e in enumerate(after) if e and e[0] == "commit"]
|
||||
refused = [e for e in after if e and e[0] == "refused"]
|
||||
if commits:
|
||||
commit_at = m2 + commits[-1][0]
|
||||
commits = [e for _, e in commits]
|
||||
if commits or refused:
|
||||
break
|
||||
time.sleep(0.25)
|
||||
if refused and not commits:
|
||||
check(False, f"{title}: field REFUSED {value!r} (typed={refused[-1][2].get('typed')!r})")
|
||||
key("Escape", 0.5)
|
||||
return trace_mark()
|
||||
if not commits:
|
||||
check(False, f"{title}: typed {value} but nothing committed — the field took no keys")
|
||||
key("Escape", 0.5)
|
||||
return trace_mark()
|
||||
typed = commits[-1][2].get("typed", "")
|
||||
got = commits[-1][2].get("value", "")
|
||||
check(typed == str(value),
|
||||
f"{title}: field received what was typed (typed={typed!r} wanted={value!r}"
|
||||
f"{' <-- it committed its PREFILL, so it never got the keyboard' if typed == prefill else ''})")
|
||||
# A value that will not parse is a FAILED CHECK, never an exception. An unguarded float()
|
||||
# here met a locale-formatted "61,0000" and took the whole run down immediately after the
|
||||
# first check in the ladder's history had passed — the seven rungs below it were never tried
|
||||
# and the report read as a total failure.
|
||||
try:
|
||||
ok_val = abs(float(got) - float(value)) < 1e-6
|
||||
except (TypeError, ValueError):
|
||||
ok_val = False
|
||||
check(ok_val, f"{title}: committed value is {got!r} (wanted {value})")
|
||||
check(str(value) != prefill, f"{title}: the test value differs from the prefill {prefill!r}")
|
||||
# RESUME JUST AFTER THE COMMIT, not at the end of the trace. A queued chain opens its next
|
||||
# field from the commit callback, so by the time trace_mark() is read here that "open" line
|
||||
# is already written — and the next call, searching only after this mark, never sees it. The
|
||||
# rectangle's Height, the slot's Radius and the label reopen all failed as "nothing did"
|
||||
# while the trace plainly showed the field open and waiting.
|
||||
return (commit_at + 1) if commit_at is not None else trace_mark()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the ladder
|
||||
|
||||
def enter_sketch(timeout=180):
|
||||
"""Open a real sketch on a real plane, and PROVE it with the socket before drawing anything.
|
||||
|
||||
THE SEQUENCE MATTERS AND IT IS NOT OBVIOUS. Shift+S enters sketch MODE and pops the plane
|
||||
offer; the offer must be dismissed; and the plane itself is chosen by clicking it in the
|
||||
viewport BEFORE Shift+S. check-gui-sketching.py has always done all four steps. This ladder
|
||||
did two of them — Design tab, then Shift+S — and went straight to the tool letters.
|
||||
|
||||
That intermediate state is the trap. `is_sketching` reads 1, every tool key is accepted and
|
||||
traced, and not one click draws anything, because there is no plane under them. The ladder
|
||||
then reports eleven product failures, all of them "a value field opened (nothing did)", and
|
||||
every one is the driver's. Two whole runs were spent on it.
|
||||
|
||||
So the gate is the ORACLE, not the keytrace: sketch_describe answers only when a sketch is
|
||||
genuinely open. Waiting on a mode flag is what allowed the wrong state to pass for the right
|
||||
one in the first place.
|
||||
"""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
click(132, 53) # Design tab
|
||||
time.sleep(2.0)
|
||||
dismiss_modals()
|
||||
click(*PLANE_PX) # pick the plane IN THE VIEWPORT — before Shift+S
|
||||
key("shift+s", 1.0)
|
||||
key("Escape", 0.5) # entering sketch mode pops the offer; dismiss it
|
||||
key("p", 0.6) # any sketch tool starts the session on that plane
|
||||
if try_call("sketch_describe") is not None:
|
||||
# NO Escape here. Every rung already opens with one to drop whatever tool the last
|
||||
# one left armed, and Escape in the Design tab walks a LIFO: first press drops the
|
||||
# armed tool, second LEAVES THE SKETCH. Pressing it here made that second press the
|
||||
# rung's own, so the ladder exited the sketch before drawing anything and then
|
||||
# reported all eleven checks failed with "nothing opened" — the tools were arming
|
||||
# into an empty Feature-mode document.
|
||||
return
|
||||
die("no sketch opened after plane click + Shift+S within "
|
||||
f"{timeout}s — sketch_describe never answered on {A.sock} (trace {TRACE})")
|
||||
|
||||
|
||||
# tool key, the clicks that draw it, and one distinct value per queued field. The values are
|
||||
# deliberately nothing like the as-drawn size, so a committed prefill cannot coincide with them.
|
||||
# Where the plane label sits in the viewport before a sketch is open. Same constant the gesture
|
||||
# ladder uses; it is a label on the 3D view, not a widget, so it moves only if the camera does.
|
||||
PLANE_PX = (913, 359)
|
||||
|
||||
# Every coordinate below stays inside 1000..1400 x 500..760 — the box check-gui-sketching.py's
|
||||
# calibration probes land four Points in, i.e. the region PROVEN to be live canvas on a 1920x1080
|
||||
# window. Earlier values started at x=950, which is left of that box and also, on a fresh datadir,
|
||||
# underneath the "Bambu Network Plug-in Required" modal.
|
||||
TOOLS = [
|
||||
("L", "Line", [(1030, 540), (1360, 540)], [61]),
|
||||
("R", "Rectangle", [(1030, 540), (1360, 730)], [62, 43]),
|
||||
("C", "Circle", [(1180, 620), (1330, 620)], [64]),
|
||||
("S", "Slot", [(1030, 580), (1300, 580), (1300, 640)], [66]),
|
||||
("G", "Polygon", [(1180, 620), (1320, 620)], [67]),
|
||||
("E", "Ellipse", [(1180, 620), (1370, 620), (1180, 720)], [68]),
|
||||
("A", "Arc", [(1040, 660), (1340, 660), (1190, 560)], [69]),
|
||||
]
|
||||
|
||||
|
||||
def rung_tool(k, name, clicks, values):
|
||||
print(f" {name}")
|
||||
key("Escape", 0.6) # back to Select, whatever the last tool left armed
|
||||
# Every rung re-establishes that a sketch is STILL open. One stray Escape too many leaves it,
|
||||
# and from then on every tool arms into a Feature-mode document that cannot open a value
|
||||
# field — which the checks below report as eleven independent product failures.
|
||||
if try_call("sketch_describe") is None:
|
||||
die(f"{name}: the sketch is no longer open before this rung — an earlier rung left it")
|
||||
key(k, 0.8)
|
||||
# MARK BEFORE THE CLICKS, not after. The field is opened from a CallAfter scheduled by the
|
||||
# render that follows the last click, so it can already be open by the time a mark taken
|
||||
# afterwards is read — and type_into_open_field, which only looks at events AFTER its mark,
|
||||
# then finds none and reports "a value field opened (nothing did)" for a field that is on
|
||||
# screen, open, and waiting. That message accused the product of the exact defect the ladder
|
||||
# exists to detect, from a bug in the ladder's own bookkeeping.
|
||||
mark = trace_mark()
|
||||
for (x, y) in clicks:
|
||||
click(x, y)
|
||||
for v in values:
|
||||
mark = type_into_open_field(v, mark)
|
||||
|
||||
|
||||
def rung_rounded_rect():
|
||||
"""The shape the user actually reported: a ROUNDED rectangle, Width -> Height -> Radius.
|
||||
|
||||
It has no keyboard shortcut — the rectangle family binds R to CornerRect and leaves the other
|
||||
modes in the toolbar flyout — so TOOLS above cannot reach it and the whole three-step chain
|
||||
went untested. `run_verb` arms it the way the offer menu does.
|
||||
|
||||
NOTE the id: the OFFER verb is `sk_rect_rounded`; `design_rect_rounded` is the ACTION name and
|
||||
run_verb throws on it, leaving the tool as Select. A run that misses that draws nothing and
|
||||
still reaches its assertions, so arm-and-verify rather than arm-and-hope.
|
||||
"""
|
||||
print(" Rounded rectangle")
|
||||
key("Escape", 0.6)
|
||||
tool = None
|
||||
for _ in range(8):
|
||||
try_call("run_verb", verb="sk_rect_rounded")
|
||||
time.sleep(0.8)
|
||||
tool = (try_call("sketch_describe") or {}).get("tool")
|
||||
if tool == "rect_rounded":
|
||||
break
|
||||
if not check(tool == "rect_rounded", f"the rounded-rectangle tool armed (tool={tool!r})"):
|
||||
return
|
||||
mark = trace_mark()
|
||||
click(1030, 540); click(1330, 700); click(1300, 660) # corners, then the radius point
|
||||
for v in (63, 41, 7):
|
||||
mark = type_into_open_field(v, mark)
|
||||
|
||||
|
||||
def rung_label_click():
|
||||
"""The user's own report: click an existing dimension label and type a new value into it.
|
||||
|
||||
KEEP THE SHAPE AS DRAWN. An earlier version committed 55 and 47 into the queued chain first,
|
||||
which resized the rectangle — and then clicked the pixel where the label had been before the
|
||||
resize. It missed, every time, and reported the reopen broken. The shape's on-screen position
|
||||
is only predictable if nothing has moved it, so Escape the chain instead: the rectangle stays
|
||||
exactly between the two corners we clicked.
|
||||
|
||||
FIND THE LABEL, do not assume its offset. A dimension label is drawn beside its edge at an
|
||||
offset that depends on zoom and text metrics, so a single hardcoded pixel is a guess that
|
||||
silently becomes wrong. Walk a short band across the top edge instead and stop at the first
|
||||
click that opens a field; if none of them does, that is a real failure and it says so.
|
||||
"""
|
||||
print(" label click-to-edit")
|
||||
key("Escape", 0.6) # Select mode
|
||||
key("R", 0.8)
|
||||
click(1020, 530)
|
||||
click(1350, 740)
|
||||
time.sleep(1.5)
|
||||
key("Escape", 0.8) # keep as drawn: abandon the queued value chain
|
||||
time.sleep(0.8)
|
||||
key("Escape", 0.6) # back to Select so a click picks rather than draws
|
||||
|
||||
mid_x, top_y = (1020 + 1350) // 2, 530
|
||||
candidates = [(mid_x, top_y + dy) for dy in (-26, -20, -14, -8, 0, 8, 14)]
|
||||
for (cx, cy) in candidates:
|
||||
mark = trace_mark()
|
||||
click(cx, cy)
|
||||
deadline = time.time() + 2.0
|
||||
while time.time() < deadline:
|
||||
if [e for e in (parse(l) for l in trace_lines()[mark:]) if e and e[0] == "open"]:
|
||||
check(True, f"clicking a dimension label reopened its value field (at {cx},{cy})")
|
||||
type_into_open_field(71, mark)
|
||||
return
|
||||
time.sleep(0.2)
|
||||
check(False, "clicking a dimension label reopened its value field "
|
||||
f"(tried {len(candidates)} points across the top edge at x={mid_x})")
|
||||
|
||||
|
||||
def main():
|
||||
if not A.attach:
|
||||
if not os.path.exists(A.bin):
|
||||
die(f"no binary at {A.bin}")
|
||||
open(TRACE, "w").close()
|
||||
launch()
|
||||
dismiss_first_run()
|
||||
win()
|
||||
print(f"click-edit ladder on {DISP}, trace {TRACE}")
|
||||
enter_sketch()
|
||||
for (k, name, clicks, values) in TOOLS:
|
||||
rung_tool(k, name, clicks, values)
|
||||
rung_rounded_rect()
|
||||
rung_label_click()
|
||||
print()
|
||||
if _fail:
|
||||
print(f"CLICK-EDIT LADDER FAILED — {_fail} of {_checks} checks", file=sys.stderr)
|
||||
else:
|
||||
print(f"CLICK-EDIT LADDER HELD — {_checks} checks")
|
||||
if _proc is not None and not A.keep:
|
||||
_proc.send_signal(signal.SIGTERM)
|
||||
try:
|
||||
_proc.wait(20)
|
||||
except subprocess.TimeoutExpired:
|
||||
_proc.kill()
|
||||
return 1 if _fail else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Executable
+132
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Autonomous 2D-sketch loop: drive the Design tab's sketch layer over the MCP socket and
|
||||
assert the things that decide whether a profile is buildable.
|
||||
|
||||
WHY THIS EXISTS. The 2D layer used to be reachable only by clicking, so every question about it
|
||||
("is this loop closed?", "did the offset survive?", "is the circle a void or a second body?")
|
||||
cost a GUI session and a human. The socket verbs make each one a call, and this script is the
|
||||
loop: build a known profile, ask the app what it thinks it has, compare against arithmetic.
|
||||
|
||||
RUN IT AGAINST A RUNNING APP:
|
||||
ORCA_CAD_MCP=/tmp/mcp.sock <binary> # launch with the socket enabled
|
||||
python3 scripts/CAD/check-mcp-sketch.py [socket] # default /tmp/mcp.sock
|
||||
|
||||
Exit 0 = every assertion held. Anything else prints the first mismatch and stops.
|
||||
"""
|
||||
import json, math, socket, sys
|
||||
|
||||
SOCK = sys.argv[1] if len(sys.argv) > 1 else "/tmp/mcp.sock"
|
||||
_n = 0
|
||||
|
||||
|
||||
def call(method, **params):
|
||||
global _n
|
||||
_n += 1
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(30)
|
||||
s.connect(SOCK)
|
||||
s.sendall((json.dumps({"jsonrpc": "2.0", "id": _n, "method": method,
|
||||
"params": params}) + "\n").encode())
|
||||
buf = b""
|
||||
while b"\n" not in buf:
|
||||
d = s.recv(65536)
|
||||
if not d:
|
||||
break
|
||||
buf += d
|
||||
r = json.loads(buf.decode().strip())
|
||||
if "error" in r:
|
||||
raise RuntimeError(f"{method}: {r['error']}")
|
||||
return r["result"]
|
||||
|
||||
|
||||
def near(a, b, tol=1e-6):
|
||||
return abs(a - b) < tol
|
||||
|
||||
|
||||
def check(cond, what):
|
||||
if not cond:
|
||||
print(f"FAIL: {what}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f" ok {what}")
|
||||
|
||||
|
||||
def areas(rep):
|
||||
return sorted(round(l["area"], 6) for l in rep["closed_loops"])
|
||||
|
||||
|
||||
print("1. a rectangle is one closed loop of exactly its own area")
|
||||
try:
|
||||
call("sketch_cancel")
|
||||
except Exception:
|
||||
pass
|
||||
call("sketch_begin", plane="XY")
|
||||
call("sketch_add", rect=[0, 0, 80, 50])
|
||||
r = call("sketch_describe")
|
||||
check(r["buildable"], "buildable")
|
||||
check(areas(r) == [4000.0], f"one loop of 4000 mm^2 (got {areas(r)})")
|
||||
|
||||
print("2. a circle inside it is a VOID, not a second profile")
|
||||
call("sketch_add", type="circle", center=[40, 25], radius=10)
|
||||
r = call("sketch_describe")
|
||||
outer = [l for l in r["closed_loops"] if near(l["area"], 4000.0)][0]
|
||||
check(len(outer["holes"]) == 1, "the rectangle encloses exactly one void")
|
||||
hole = r["closed_loops"][outer["holes"][0]]
|
||||
check(near(hole["area"], math.pi * 100), f"the void is pi*r^2 (got {hole['area']})")
|
||||
|
||||
print("3. offsetting the outer loop inward keeps it CLOSED and exact")
|
||||
call("sketch_select", entities=[0, 1, 2, 3])
|
||||
call("sketch_offset", distance=5)
|
||||
r = call("sketch_describe")
|
||||
check(r["open_ends"] == [], "no open ends after the offset")
|
||||
check(any(near(l["area"], 70 * 40) for l in r["closed_loops"]),
|
||||
f"the offset loop is 70x40 (got {areas(r)})")
|
||||
|
||||
print("4. a gap is REPORTED with its coordinates, then healed into a constraint")
|
||||
call("sketch_cancel")
|
||||
call("sketch_begin", plane="XY")
|
||||
call("sketch_add", entities=[
|
||||
{"type": "line", "p0": [0, 0], "p1": [60, 0]},
|
||||
{"type": "line", "p0": [60, 0], "p1": [60, 40]},
|
||||
{"type": "line", "p0": [60, 40], "p1": [0, 40]},
|
||||
{"type": "line", "p0": [0, 40], "p1": [0.4, 0]}, # 0.4 mm short of closing
|
||||
])
|
||||
r = call("sketch_validate", tolerance=1.0)
|
||||
check(not r["buildable"], "a 0.4 mm gap makes the profile unbuildable")
|
||||
check(len(r["open_ends"]) == 2, f"both free ends are named (got {r['open_ends']})")
|
||||
dof_before = r["dof"]
|
||||
r = call("sketch_heal", tolerance=1.0)
|
||||
check(r["welded"] == 1, f"one pair welded (got {r['welded']})")
|
||||
check(r["buildable"] and r["open_ends"] == [], "healed profile is buildable")
|
||||
check(areas(r) == [2400.0], f"healed loop is 60x40 (got {areas(r)})")
|
||||
check(r["dof"] < dof_before,
|
||||
f"the weld recorded a real constraint: DoF {dof_before} -> {r['dof']}")
|
||||
|
||||
print("5. construction geometry is excluded from the profile")
|
||||
call("sketch_select", entities=[0])
|
||||
call("sketch_construction")
|
||||
r = call("sketch_describe")
|
||||
check(not r["buildable"], "turning one side into a guide opens the profile again")
|
||||
call("sketch_construction")
|
||||
r = call("sketch_describe")
|
||||
check(r["buildable"], "turning it back closes it again")
|
||||
|
||||
print("6. re-dimensioning one side keeps the rectangle a single closed loop")
|
||||
call("sketch_cancel")
|
||||
call("sketch_begin", plane="XY")
|
||||
call("sketch_add", rect=[0, 0, 60, 40])
|
||||
r = call("sketch_describe")
|
||||
check(areas(r) == [2400.0], f"one loop of 2400 mm^2 (got {areas(r)})")
|
||||
call("sketch_select", entities=[0]) # the bottom edge, y=0, from x=0 to x=60
|
||||
r = call("sketch_set_value", value=40)
|
||||
check(r["kind"] == "length", f"dimension kind is length (got {r['kind']})")
|
||||
check(near(r["before"], 60), f"the edge measured 60 before (got {r['before']})")
|
||||
r = call("sketch_describe")
|
||||
check(len(r["closed_loops"]) == 1, "the rectangle is still exactly one closed loop")
|
||||
check(r["open_ends"] == [], "no open ends after re-dimensioning")
|
||||
# The point of the whole section: a rectangle must SURVIVE one side being re-dimensioned. We do
|
||||
# not assert a specific area — only that the topology held — but print it so a topology-preserving
|
||||
# yet geometry-wrong result is visible in the output.
|
||||
print(f" note resulting rectangle area = {areas(r)} mm^2 (topology held; geometry is what it is)")
|
||||
|
||||
call("sketch_cancel")
|
||||
print("\nall sketch assertions held")
|
||||
@@ -0,0 +1,492 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rung 9: the ladder, graded against real drawings instead of shapes I chose.
|
||||
|
||||
Rungs 1-8 are hand-built. That is their weakness: I wrote both the geometry and the
|
||||
assertion, so they prove the engine does what I expected on cases I picked. This rung
|
||||
takes a SYSTEMATIC sample of the StudyCadCam corpus (every 20th sheet, 1..996 — no
|
||||
cherry-picking) and grades the engine against each drawing's OWN vector geometry,
|
||||
extracted from the PDF. Nothing here is transcribed by eye; the drawing is the input.
|
||||
|
||||
The method: pdftocairo renders the sheet to SVG, where the drawn geometry is exactly the
|
||||
stroked (fill="none") paths and the text is filled glyph paths. Beziers are flattened, so
|
||||
every entity handed to the engine is a straight line and every comparison below is EXACT
|
||||
— no faceting tolerance to hide behind. The closed chains are then found twice: once by
|
||||
this script, in plain Python, and once by the engine. The assertions are that the two
|
||||
agree, and that the engine's own operations preserve what they promise.
|
||||
|
||||
CLOSED the engine finds the same closed loops this script does
|
||||
AREA the engine's area for each loop equals the shoelace area, to 1e-6
|
||||
VOID the engine attributes each void to the loop that actually contains it
|
||||
MIRROR a real closed profile, mirrored, is still exactly one closed loop
|
||||
OFFSET a real closed profile, offset, is still closed
|
||||
|
||||
Usage: check-sketch-engine-corpus.py [--sample N] [--corpus DIR]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
SOCK = os.environ.get("ORCA_CAD_MCP", "/tmp/mcp.sock")
|
||||
TOL = 1e-6 # exact-comparison tolerance (all inputs are lines)
|
||||
WELD = 0.05 # endpoint-coincidence tolerance, in PDF units
|
||||
|
||||
|
||||
# ── the socket ───────────────────────────────────────────────────────────────
|
||||
_id = [0]
|
||||
|
||||
|
||||
def try_call(method, **params):
|
||||
"""sketch_cancel throws when nothing is open, which is not an error to us."""
|
||||
try:
|
||||
return call(method, **params)
|
||||
except RuntimeError:
|
||||
return None
|
||||
|
||||
|
||||
def call(method, **params):
|
||||
_id[0] += 1
|
||||
req = json.dumps({"jsonrpc": "2.0", "id": _id[0], "method": method,
|
||||
"params": params}) + "\n"
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(30)
|
||||
s.connect(SOCK)
|
||||
s.sendall(req.encode())
|
||||
buf = b""
|
||||
while not buf.endswith(b"\n"):
|
||||
chunk = s.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
s.close()
|
||||
r = json.loads(buf.decode())
|
||||
if "error" in r:
|
||||
raise RuntimeError(r["error"]["message"])
|
||||
return r["result"]
|
||||
|
||||
|
||||
# ── SVG → line segments ──────────────────────────────────────────────────────
|
||||
NUM = r"[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?"
|
||||
|
||||
|
||||
def bezier(p0, p1, p2, p3, n=16):
|
||||
"""Flatten a cubic to n straight segments — the engine then sees only lines."""
|
||||
out = []
|
||||
for i in range(n):
|
||||
t0, t1 = i / n, (i + 1) / n
|
||||
pts = []
|
||||
for t in (t0, t1):
|
||||
u = 1 - t
|
||||
x = (u ** 3 * p0[0] + 3 * u * u * t * p1[0]
|
||||
+ 3 * u * t * t * p2[0] + t ** 3 * p3[0])
|
||||
y = (u ** 3 * p0[1] + 3 * u * u * t * p1[1]
|
||||
+ 3 * u * t * t * p2[1] + t ** 3 * p3[1])
|
||||
pts.append((x, y))
|
||||
out.append((pts[0], pts[1]))
|
||||
return out
|
||||
|
||||
|
||||
def path_segments(d):
|
||||
"""Parse one SVG path's `d` into straight segments."""
|
||||
toks = re.findall(r"([MLCZmlcz])|(" + NUM + ")", d)
|
||||
cmds, cur, start, segs, i = [], None, None, [], 0
|
||||
flat = []
|
||||
for a, b in toks:
|
||||
flat.append(a if a else float(b))
|
||||
while i < len(flat):
|
||||
t = flat[i]
|
||||
if isinstance(t, str):
|
||||
cmd = t
|
||||
i += 1
|
||||
# numbers repeat the previous command, as SVG allows
|
||||
if cmd in ("M", "m"):
|
||||
x, y = flat[i], flat[i + 1]; i += 2
|
||||
cur = (x, y); start = cur
|
||||
elif cmd in ("L", "l"):
|
||||
x, y = flat[i], flat[i + 1]; i += 2
|
||||
segs.append((cur, (x, y))); cur = (x, y)
|
||||
elif cmd in ("C", "c"):
|
||||
p1 = (flat[i], flat[i + 1]); p2 = (flat[i + 2], flat[i + 3])
|
||||
p3 = (flat[i + 4], flat[i + 5]); i += 6
|
||||
segs.extend(bezier(cur, p1, p2, p3)); cur = p3
|
||||
elif cmd in ("Z", "z"):
|
||||
if cur and start and dist(cur, start) > TOL:
|
||||
segs.append((cur, start))
|
||||
cur = start
|
||||
else:
|
||||
i += 1
|
||||
return segs
|
||||
|
||||
|
||||
def dist(a, b):
|
||||
return math.hypot(a[0] - b[0], a[1] - b[1])
|
||||
|
||||
|
||||
def drawing_segments(pdf):
|
||||
"""Every stroked segment on the sheet, in PDF units (y already flipped up)."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
svg = os.path.join(td, "p.svg")
|
||||
subprocess.run(["pdftocairo", "-svg", pdf, svg],
|
||||
check=True, capture_output=True)
|
||||
s = open(svg).read()
|
||||
segs = []
|
||||
# Drawn geometry is stroked with no fill; glyphs are filled with no stroke.
|
||||
for m in re.finditer(r'<path([^>]*)d="([^"]+)"', s):
|
||||
attrs, d = m.group(1), m.group(2)
|
||||
if 'fill="none"' not in attrs or "stroke=" not in attrs:
|
||||
continue
|
||||
segs.extend(path_segments(d))
|
||||
return [((a[0], -a[1]), (b[0], -b[1])) for a, b in segs if dist(a, b) > TOL]
|
||||
|
||||
|
||||
# ── closed-chain finding, independent of the engine ──────────────────────────
|
||||
def find_loops(segs):
|
||||
"""Chain segments into closed loops. Returns a list of point rings."""
|
||||
key = lambda p: (round(p[0] / WELD), round(p[1] / WELD))
|
||||
adj = {}
|
||||
for i, (a, b) in enumerate(segs):
|
||||
adj.setdefault(key(a), []).append((i, a, b))
|
||||
adj.setdefault(key(b), []).append((i, b, a))
|
||||
used, loops = set(), []
|
||||
for i0 in range(len(segs)):
|
||||
if i0 in used:
|
||||
continue
|
||||
a, b = segs[i0]
|
||||
ring, cur, prev = [a, b], b, i0
|
||||
used.add(i0)
|
||||
while True:
|
||||
nxt = None
|
||||
for (j, p, q) in adj.get(key(cur), []):
|
||||
if j in used:
|
||||
continue
|
||||
nxt = (j, q)
|
||||
break
|
||||
if nxt is None:
|
||||
break
|
||||
used.add(nxt[0])
|
||||
cur = nxt[1]
|
||||
ring.append(cur)
|
||||
if dist(cur, ring[0]) <= WELD:
|
||||
# Snap the seam shut. The gap is a flattening artefact of the PDF, up to
|
||||
# WELD wide, and handing the engine a ring that misses closing by 0.03 would
|
||||
# be testing my extractor's sloppiness rather than the engine's chaining.
|
||||
ring[-1] = ring[0]
|
||||
loops.append(ring)
|
||||
ring = None
|
||||
break
|
||||
# an open chain is simply not a loop; it is dropped
|
||||
return loops
|
||||
|
||||
|
||||
def shoelace(ring):
|
||||
a = 0.0
|
||||
for i in range(len(ring) - 1):
|
||||
a += ring[i][0] * ring[i + 1][1] - ring[i + 1][0] * ring[i][1]
|
||||
return abs(a) * 0.5
|
||||
|
||||
|
||||
def point_in(pt, ring):
|
||||
inside = False
|
||||
for i in range(len(ring) - 1):
|
||||
A, B = ring[i], ring[i + 1]
|
||||
if (A[1] > pt[1]) != (B[1] > pt[1]) and \
|
||||
pt[0] < (B[0] - A[0]) * (pt[1] - A[1]) / (B[1] - A[1]) + A[0]:
|
||||
inside = not inside
|
||||
return inside
|
||||
|
||||
|
||||
def interior_point(ring):
|
||||
"""A point strictly inside a simple closed ring (first == last).
|
||||
|
||||
The lowest vertex of a simple polygon is always convex, so stepping from it along the
|
||||
bisector of its two edges goes inward; the step is a small fraction of the shorter edge so
|
||||
it stays inside however sharp the corner is.
|
||||
"""
|
||||
q = ring[:-1] if len(ring) > 1 and ring[0] == ring[-1] else ring
|
||||
if len(q) < 3:
|
||||
return ring[0]
|
||||
k = min(range(len(q)), key=lambda i: (q[i][1], q[i][0]))
|
||||
v = q[k]
|
||||
a = (q[(k - 1) % len(q)][0] - v[0], q[(k - 1) % len(q)][1] - v[1])
|
||||
b = (q[(k + 1) % len(q)][0] - v[0], q[(k + 1) % len(q)][1] - v[1])
|
||||
la, lb = math.hypot(*a), math.hypot(*b)
|
||||
if la < 1e-12 or lb < 1e-12:
|
||||
return v
|
||||
a = (a[0] / la, a[1] / la)
|
||||
b = (b[0] / lb, b[1] / lb)
|
||||
bx, by = a[0] + b[0], a[1] + b[1]
|
||||
n = math.hypot(bx, by)
|
||||
if n < 1e-12:
|
||||
return v
|
||||
step = 1e-3 * min(la, lb)
|
||||
return (v[0] + bx / n * step, v[1] + by / n * step)
|
||||
|
||||
|
||||
# ── one drawing ──────────────────────────────────────────────────────────────
|
||||
def grade(pdf, name, report):
|
||||
segs = drawing_segments(pdf)
|
||||
loops = find_loops(segs)
|
||||
if len(loops) < 2:
|
||||
report(name, "SKIP", f"no nested closed geometry found ({len(loops)} loops)")
|
||||
return None
|
||||
|
||||
# The biggest loop is the sheet frame; the part outlines live inside it. Take the
|
||||
# largest loop that is NOT the frame, plus every loop contained in it.
|
||||
loops.sort(key=shoelace, reverse=True)
|
||||
outer = loops[1]
|
||||
voids = [r for r in loops[2:]
|
||||
if shoelace(r) > 1.0 and point_in(r[0], outer)]
|
||||
if shoelace(outer) < 100.0:
|
||||
# Not a defect and not a near miss: on these sheets the part outline is not a closed
|
||||
# stroked path at all, so the only loops extraction recovers are glyph counters and
|
||||
# arrowheads. Measured on MPD12/30/31/60: the LARGEST loop on the sheet is 5 to 132 mm2.
|
||||
# Say the number, so nobody has to re-measure to know which kind of skip this is.
|
||||
report(name, "SKIP", f"no part outline on this sheet — largest loop is only "
|
||||
f"{shoelace(outer):.1f} mm2")
|
||||
return None
|
||||
|
||||
# Feed the drawing's own geometry to the engine, as lines only.
|
||||
ents = []
|
||||
rings = [outer] + voids
|
||||
for ring in rings:
|
||||
for i in range(len(ring) - 1):
|
||||
ents.append({"type": "line",
|
||||
"p0": [ring[i][0], ring[i][1]],
|
||||
"p1": [ring[i + 1][0], ring[i + 1][1]]})
|
||||
try_call("sketch_cancel")
|
||||
call("sketch_begin", plane="XY")
|
||||
call("sketch_add", entities=ents)
|
||||
r = call("sketch_describe")
|
||||
|
||||
ok = True
|
||||
got = r["closed_loops"]
|
||||
ok &= report(name, "CLOSED", f"engine finds {len(got)} closed loops, this script "
|
||||
f"finds {len(rings)}", len(got) == len(rings))
|
||||
|
||||
# AREA — exact, because every entity is a line
|
||||
mine = sorted(shoelace(x) for x in rings)
|
||||
theirs = sorted(abs(l["area"]) for l in got)
|
||||
# The bar: 1e-3 absolute, or 1e-6 relative for the big loops. Not bit-exactness — the
|
||||
# auto-constraint pass still snaps segments that are already axis-aligned to within its
|
||||
# 1e-4 rad tolerance, which moves an area by ~1e-4. It is deliberately tight enough to
|
||||
# have caught the real defect this rung was written for: with the old 3 degree gesture
|
||||
# slack applied to scripted input, a flattened circle came back 0.067% small — 0.266 on
|
||||
# an area of 397, some 300x above this line.
|
||||
same = len(mine) == len(theirs) and all(
|
||||
abs(a - b) <= max(1e-3, 1e-6 * a) for a, b in zip(mine, theirs))
|
||||
ok &= report(name, "AREA", "every loop area matches the shoelace value exactly", same)
|
||||
|
||||
# VOID — a loop belongs to the SMALLEST loop that contains it, not to every loop that
|
||||
# encloses it. A hole inside a boss inside the part is a void of the boss, and the part
|
||||
# owns the boss. Comparing against "everything inside the outline" was measuring my own
|
||||
# sloppiness: on MPD781 that counted 36 voids where 26 of them are nested inside another
|
||||
# void. So compute the same rule here, independently, and compare the whole attribution.
|
||||
if got:
|
||||
rings = [outer] + voids
|
||||
# Probe from a point STRICTLY INSIDE each ring, never from one of its vertices — the
|
||||
# same rule the engine now uses (DesignSketchTool::region_loops). A vertex is exactly
|
||||
# where two loops touch in a real drawing, and a ray cast from a point lying ON the
|
||||
# polygon under test answers by rounding: that alone accounted for every one of the 6
|
||||
# sheets where the two attributions used to disagree. 5hvl.
|
||||
probes = [interior_point(r) for r in rings]
|
||||
mine_parent = {}
|
||||
for i, r in enumerate(rings):
|
||||
best, best_a = -1, 0.0
|
||||
for j, q in enumerate(rings):
|
||||
if i == j or not point_in(probes[i], q):
|
||||
continue
|
||||
a = shoelace(q)
|
||||
if best < 0 or a < best_a:
|
||||
best, best_a = j, a
|
||||
if best >= 0:
|
||||
mine_parent.setdefault(best, []).append(i)
|
||||
big = max(range(len(got)), key=lambda i: abs(got[i]["area"]))
|
||||
# match engine loops to my rings by area, then compare the two attributions by COUNT
|
||||
mine_counts = sorted(len(v) for v in mine_parent.values())
|
||||
got_counts = sorted(len(l["holes"]) for l in got if l["holes"])
|
||||
ok &= report(name, "VOID",
|
||||
f"void attribution matches: engine {got_counts}, containment "
|
||||
f"{mine_counts}", got_counts == mine_counts)
|
||||
|
||||
# MIRROR / OFFSET — engine operations on a REAL profile, not a tidy one
|
||||
n_outer = len(outer) - 1
|
||||
try_call("sketch_cancel")
|
||||
call("sketch_begin", plane="XY")
|
||||
call("sketch_add", entities=ents[:n_outer])
|
||||
xs = [p[0] for p in outer]
|
||||
axis = min(xs) - 10.0
|
||||
call("sketch_select", entities=list(range(n_outer)))
|
||||
try:
|
||||
call("sketch_mirror", axis_a=[axis, 0], axis_b=[axis, 1])
|
||||
m = call("sketch_describe")
|
||||
ok &= report(name, "MIRROR", "the mirrored copy is closed too",
|
||||
len(m["closed_loops"]) == 2 and m["open_ends"] == [])
|
||||
except RuntimeError as e:
|
||||
ok &= report(name, "MIRROR", f"refused: {e}", False)
|
||||
|
||||
try_call("sketch_cancel")
|
||||
call("sketch_begin", plane="XY")
|
||||
call("sketch_add", entities=ents[:n_outer])
|
||||
try:
|
||||
call("sketch_offset", distance=0.5, entities=list(range(n_outer)))
|
||||
o = call("sketch_describe")
|
||||
ok &= report(name, "OFFSET", "the offset profile is still closed",
|
||||
any(l["closed"] for l in o["closed_loops"]))
|
||||
except RuntimeError as e:
|
||||
ok &= report(name, "OFFSET", f"refused: {e}", False)
|
||||
return ok
|
||||
|
||||
|
||||
# ── scale ────────────────────────────────────────────────────────────────────
|
||||
def grade_scale(pdf, name, report, budget):
|
||||
"""Same exactness, on a profile of several hundred entities, and timed.
|
||||
|
||||
"Interactive" is measurable from here even though nothing is clicked: every MCP verb is
|
||||
serviced on the UI THREAD, so the time a reply takes is time the window was not repainting.
|
||||
A round trip that stays inside the budget is a window that stayed responsive.
|
||||
"""
|
||||
segs = drawing_segments(pdf)
|
||||
loops = find_loops(segs)
|
||||
if len(loops) < 2:
|
||||
report(name, "SKIP", f"no nested closed geometry found ({len(loops)} loops)")
|
||||
return None
|
||||
loops.sort(key=shoelace, reverse=True)
|
||||
outer = loops[1]
|
||||
voids = [r for r in loops[2:] if shoelace(r) > 1.0 and point_in(r[0], outer)]
|
||||
rings = [outer] + voids
|
||||
ents = []
|
||||
for ring in rings:
|
||||
for i in range(len(ring) - 1):
|
||||
ents.append({"type": "line",
|
||||
"p0": [ring[i][0], ring[i][1]],
|
||||
"p1": [ring[i + 1][0], ring[i + 1][1]]})
|
||||
if len(ents) < 300:
|
||||
report(name, "SKIP", f"only {len(ents)} entities — not a scale case")
|
||||
return None
|
||||
|
||||
try_call("sketch_cancel")
|
||||
call("sketch_begin", plane="XY")
|
||||
t0 = time.monotonic(); call("sketch_add", entities=ents); t_add = time.monotonic() - t0
|
||||
t0 = time.monotonic(); r = call("sketch_describe"); t_desc = time.monotonic() - t0
|
||||
t0 = time.monotonic(); call("sketch_select", entities=list(range(len(ents))))
|
||||
t_sel = time.monotonic() - t0
|
||||
t0 = time.monotonic(); call("sketch_validate"); t_val = time.monotonic() - t0
|
||||
|
||||
ok = True
|
||||
ok &= report(name, "SCALE", f"{len(ents)} entities in {len(rings)} loops", True)
|
||||
got = r["closed_loops"]
|
||||
ok &= report(name, "CLOSED", f"engine finds {len(got)} closed loops, this script "
|
||||
f"finds {len(rings)}", len(got) == len(rings))
|
||||
mine = sorted(shoelace(x) for x in rings)
|
||||
theirs = sorted(abs(l["area"]) for l in got)
|
||||
same = len(mine) == len(theirs) and all(
|
||||
abs(a - b) <= max(1e-3, 1e-6 * a) for a, b in zip(mine, theirs))
|
||||
ok &= report(name, "AREA", "every loop area matches the shoelace value exactly", same)
|
||||
worst = max(t_add, t_desc, t_sel, t_val)
|
||||
ok &= report(name, "TIME", f"add {t_add*1000:.0f} ms, describe {t_desc*1000:.0f} ms, "
|
||||
f"select {t_sel*1000:.0f} ms, validate {t_val*1000:.0f} ms "
|
||||
f"(budget {budget*1000:.0f} ms)", worst <= budget)
|
||||
return ok
|
||||
|
||||
|
||||
def _pdf_error(path):
|
||||
"""What poppler says about a file it refused, so a refusal can be classified."""
|
||||
r = subprocess.run(["pdfinfo", path], capture_output=True, text=True)
|
||||
return (r.stderr or "") + (r.stdout or "")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--corpus", default=os.path.expanduser("~/studycadcam"))
|
||||
ap.add_argument("--step", type=int, default=20)
|
||||
ap.add_argument("--limit", type=int, default=0)
|
||||
ap.add_argument("--scale", action="store_true",
|
||||
help="grade the LARGEST drawings instead: exactness plus a UI-thread budget")
|
||||
ap.add_argument("--budget", type=float, default=2.0,
|
||||
help="seconds; the slowest round trip a scale drawing may take")
|
||||
a = ap.parse_args()
|
||||
|
||||
files = {}
|
||||
for f in glob.glob(os.path.join(a.corpus, "MPD*.pdf")):
|
||||
m = re.search(r"MPD(\d+)", os.path.basename(f))
|
||||
if m:
|
||||
files[int(m.group(1))] = f
|
||||
# step 1 means EVERY sheet. Written as `n % step == 1` it silently selected nothing, because
|
||||
# n % 1 is always 0 — and the run then printed "RUNG 9 HELD" over zero drawings graded. A
|
||||
# gate that passes by grading nothing is worse than no gate, so the count is checked below.
|
||||
picks = [files[n] for n in sorted(files) if a.step <= 1 or n % a.step == 1]
|
||||
if a.scale:
|
||||
# The heaviest real profiles in the corpus, biggest first — up to ~1300 entities.
|
||||
sized = []
|
||||
for f in files.values():
|
||||
try:
|
||||
segs = drawing_segments(f)
|
||||
loops = find_loops(segs)
|
||||
if len(loops) < 2:
|
||||
continue
|
||||
loops.sort(key=shoelace, reverse=True)
|
||||
outer = loops[1]
|
||||
voids = [r for r in loops[2:] if shoelace(r) > 1.0 and point_in(r[0], outer)]
|
||||
sized.append((sum(len(r) - 1 for r in [outer] + voids), f))
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
sized.sort(reverse=True)
|
||||
picks = [f for _, f in sized[:max(1, a.limit or 6)]]
|
||||
elif a.limit:
|
||||
picks = picks[:a.limit]
|
||||
print(f"corpus: {len(files)} sheets; systematic sample every {a.step}th "
|
||||
f"-> {len(picks)} drawings\n")
|
||||
|
||||
fails, results = [], []
|
||||
|
||||
def report(name, tag, msg, cond=True):
|
||||
if tag == "SKIP":
|
||||
print(f" {name:9s} SKIP {msg}")
|
||||
return True
|
||||
mark = "ok " if cond else "FAIL"
|
||||
print(f" {name:9s} {tag:8s} {mark} {msg}")
|
||||
if not cond:
|
||||
fails.append(f"{name} {tag}: {msg}")
|
||||
return cond
|
||||
|
||||
for f in picks:
|
||||
name = re.search(r"MPD\d+", os.path.basename(f)).group(0)
|
||||
try:
|
||||
r = (grade_scale(f, name, report, a.budget) if a.scale
|
||||
else grade(f, name, report))
|
||||
if r is not None:
|
||||
results.append((name, r))
|
||||
except Exception as e: # noqa: BLE001
|
||||
# An unreadable SOURCE file is not a grading failure. MPD133 of this corpus is
|
||||
# password-protected, and pdftocairo says so on stderr while exiting non-zero;
|
||||
# reporting that as ERROR made one encrypted sheet look like an engine defect.
|
||||
if "password" in _pdf_error(f).lower():
|
||||
report(name, "SKIP", "the PDF is password-protected — nothing to extract")
|
||||
else:
|
||||
report(name, "ERROR", str(e)[:120], False)
|
||||
|
||||
graded = len(results)
|
||||
passed = sum(1 for _, r in results if r)
|
||||
if graded == 0:
|
||||
print("\nNOTHING WAS GRADED — that is a harness failure, not a clean run", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
print(f"\ngraded {graded} drawings; {passed} fully clean, {graded - passed} with "
|
||||
f"at least one failure")
|
||||
if fails:
|
||||
print("\nfailures:")
|
||||
for x in fails:
|
||||
print(" " + x)
|
||||
sys.exit(1)
|
||||
print("\nRUNG 9 HELD — the engine agrees with the drawings, not with me")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+352
@@ -0,0 +1,352 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A ladder of 2D sketches of increasing complexity, judged the way a person judges them.
|
||||
|
||||
WHY NOT AREA. Area is derived and no one can confirm it by looking. What a human checks at a
|
||||
glance, and can be exactly right or exactly wrong about, is:
|
||||
|
||||
VERTEX is the corner where I said it is
|
||||
LENGTH is the side the length I gave it
|
||||
ARC is the radius the radius I gave it
|
||||
TANGENT does the straight run into the curve smoothly, or is there a kink
|
||||
SYMMETRY is the mirrored half the exact reflection of the half I drew
|
||||
CLOSED is it one closed loop, or does it just look like one
|
||||
|
||||
Every rung asserts those. Area appears only as a cross-check, never as the verdict.
|
||||
|
||||
Entirely 2D: sketch entities only, no extrude, revolve or any solid feature.
|
||||
|
||||
ORCA_CAD_MCP=/tmp/mcp.sock <binary>
|
||||
python3 scripts/CAD/check-sketch-engine.py [socket]
|
||||
|
||||
Exit 0 = every rung held. Otherwise the first broken property is named and the run stops.
|
||||
"""
|
||||
import json, math, socket, sys
|
||||
|
||||
SOCK = sys.argv[1] if len(sys.argv) > 1 else "/tmp/mcp.sock"
|
||||
EPS = 1e-9
|
||||
_n = 0
|
||||
_fail = 0
|
||||
|
||||
|
||||
def call(method, **params):
|
||||
global _n
|
||||
_n += 1
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(30)
|
||||
s.connect(SOCK)
|
||||
s.sendall((json.dumps({"jsonrpc": "2.0", "id": _n, "method": method,
|
||||
"params": params}) + "\n").encode())
|
||||
buf = b""
|
||||
while b"\n" not in buf:
|
||||
d = s.recv(65536)
|
||||
if not d:
|
||||
break
|
||||
buf += d
|
||||
r = json.loads(buf.decode().strip())
|
||||
if "error" in r:
|
||||
raise RuntimeError(f"{method}: {r['error']['message']}")
|
||||
return r["result"]
|
||||
|
||||
|
||||
def check(kind, cond, what):
|
||||
global _fail
|
||||
if cond:
|
||||
print(f" {kind:9s} ok {what}")
|
||||
else:
|
||||
print(f" {kind:9s} FAIL {what}", file=sys.stderr)
|
||||
_fail += 1
|
||||
|
||||
|
||||
def near(a, b, tol=1e-6):
|
||||
return abs(a - b) <= tol
|
||||
|
||||
|
||||
def pt_near(p, q, tol=1e-6):
|
||||
return math.hypot(p[0] - q[0], p[1] - q[1]) <= tol
|
||||
|
||||
|
||||
def fresh(plane="XY"):
|
||||
try:
|
||||
call("sketch_cancel")
|
||||
except Exception:
|
||||
pass
|
||||
call("sketch_begin", plane=plane)
|
||||
|
||||
|
||||
def ents():
|
||||
return call("sketch_describe")["entities"]
|
||||
|
||||
|
||||
def rep():
|
||||
return call("sketch_describe")
|
||||
|
||||
|
||||
def endpoints(e):
|
||||
"""Both ends of an open curve, as tuples. Closed curves have none."""
|
||||
if "p0" not in e or "p1" not in e:
|
||||
return ()
|
||||
return tuple(e["p0"]), tuple(e["p1"])
|
||||
|
||||
|
||||
def tangent(e, at_end):
|
||||
"""Unit tangent of entity e at one of its ends, pointing ALONG the curve (p0->p1)."""
|
||||
if e["type"] == "line":
|
||||
dx = e["p1"][0] - e["p0"][0]
|
||||
dy = e["p1"][1] - e["p0"][1]
|
||||
else: # arc
|
||||
a = e["start_angle"] if not at_end else e["end_angle"]
|
||||
ccw = e["end_angle"] >= e["start_angle"]
|
||||
# d/dtheta (cos, sin) = (-sin, cos), reversed when the sweep is clockwise
|
||||
dx, dy = -math.sin(a), math.cos(a)
|
||||
if not ccw:
|
||||
dx, dy = -dx, -dy
|
||||
n = math.hypot(dx, dy)
|
||||
return (dx / n, dy / n)
|
||||
|
||||
|
||||
def tangent_at_point(e, p):
|
||||
"""Unit tangent of e at whichever of its ends is p, oriented leaving that point."""
|
||||
p0, p1 = endpoints(e)
|
||||
if pt_near(p0, p):
|
||||
t = tangent(e, False)
|
||||
return t
|
||||
t = tangent(e, True)
|
||||
return (-t[0], -t[1]) # leaving p1 means going back along the curve
|
||||
|
||||
|
||||
def smooth(e1, e2, p):
|
||||
"""G1 at shared point p: the tangent leaving e1 is opposite the tangent leaving e2."""
|
||||
a = tangent_at_point(e1, p)
|
||||
b = tangent_at_point(e2, p)
|
||||
return abs(a[0] * (-b[0]) - 0) >= 0 and abs(a[0] * b[1] - a[1] * b[0]) <= 1e-6
|
||||
|
||||
|
||||
def closed_one_loop(r, voids=0):
|
||||
return (r["buildable"] and r["open_ends"] == []
|
||||
and len([l for l in r["closed_loops"] if not any(
|
||||
i in h["holes"] for h in r["closed_loops"] for i in [])]) >= 1)
|
||||
|
||||
|
||||
def outer_loop(r):
|
||||
"""The loop that encloses the others (or the only one)."""
|
||||
if not r["closed_loops"]:
|
||||
return None
|
||||
return max(r["closed_loops"], key=lambda l: abs(l["area"]))
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
print("RUNG 1 — rectangle: four corners, four lengths, four right angles")
|
||||
fresh()
|
||||
W, H = 80.0, 50.0
|
||||
call("sketch_add", rect=[0, 0, W, H])
|
||||
r = rep()
|
||||
es = r["entities"]
|
||||
corners = {(0, 0), (W, 0), (W, H), (0, H)}
|
||||
got = set()
|
||||
for e in es:
|
||||
got.add(tuple(e["p0"]))
|
||||
got.add(tuple(e["p1"]))
|
||||
check("VERTEX", all(any(pt_near(c, g) for g in got) for c in corners),
|
||||
f"all four corners exactly where asked {sorted(corners)}")
|
||||
lens = sorted(round(e["length"], 9) for e in es)
|
||||
check("LENGTH", lens == sorted([W, W, H, H]), f"sides are {W}/{H} twice each (got {lens})")
|
||||
# right angles: consecutive sides meet at 90 degrees
|
||||
ang_ok = True
|
||||
for e in es:
|
||||
for f in es:
|
||||
if e is f:
|
||||
continue
|
||||
for p in endpoints(e):
|
||||
if any(pt_near(p, q) for q in endpoints(f)):
|
||||
a, b = tangent_at_point(e, p), tangent_at_point(f, p)
|
||||
if abs(a[0] * b[0] + a[1] * b[1]) > 1e-6:
|
||||
ang_ok = False
|
||||
check("ANGLE", ang_ok, "every corner is exactly 90 degrees")
|
||||
check("CLOSED", r["buildable"] and r["open_ends"] == [], "one closed loop, no free ends")
|
||||
|
||||
print("\nRUNG 2 — a circular void inside it")
|
||||
call("sketch_add", type="circle", center=[W / 2, H / 2], radius=12)
|
||||
r = rep()
|
||||
c = [e for e in r["entities"] if e["type"] == "circle"][0]
|
||||
check("VERTEX", pt_near(tuple(c["center"]), (W / 2, H / 2)), "void centred exactly where asked")
|
||||
check("ARC", near(c["radius"], 12), f"void radius exactly 12 (got {c['radius']})")
|
||||
out = outer_loop(r)
|
||||
check("CLOSED", len(out["holes"]) == 1, "the rectangle encloses exactly one void")
|
||||
check("CLOSED", r["buildable"] and r["open_ends"] == [], "still closed with the void present")
|
||||
|
||||
print("\nRUNG 3 — stadium: straights running into caps, tangent at every junction")
|
||||
fresh()
|
||||
L, R = 50.0, 15.0
|
||||
call("sketch_add", entities=[
|
||||
{"type": "line", "p0": [-L, -R], "p1": [L, -R]},
|
||||
{"type": "arc", "center": [L, 0], "radius": R,
|
||||
"start_angle": -math.pi / 2, "end_angle": math.pi / 2},
|
||||
{"type": "line", "p0": [L, R], "p1": [-L, R]},
|
||||
{"type": "arc", "center": [-L, 0], "radius": R,
|
||||
"start_angle": math.pi / 2, "end_angle": 3 * math.pi / 2},
|
||||
])
|
||||
r = rep()
|
||||
es = r["entities"]
|
||||
check("CLOSED", r["buildable"] and r["open_ends"] == [], "one closed loop, no free ends")
|
||||
arcs = [e for e in es if e["type"] == "arc"]
|
||||
check("ARC", all(near(a["radius"], R) for a in arcs), f"both caps exactly R={R}")
|
||||
check("LENGTH", all(near(e["length"], 2 * L) for e in es if e["type"] == "line"),
|
||||
f"both straights exactly {2*L}")
|
||||
# tangency at all four line/arc junctions
|
||||
tang = True
|
||||
for a in arcs:
|
||||
for p in endpoints(a):
|
||||
mates = [e for e in es if e is not a and any(pt_near(p, q) for q in endpoints(e))]
|
||||
for m in mates:
|
||||
if not smooth(a, m, p):
|
||||
tang = False
|
||||
check("TANGENT", tang, "straight meets cap smoothly at all four junctions (no kink)")
|
||||
|
||||
print("\nRUNG 4 — mirror: the reflected half is the exact reflection")
|
||||
fresh()
|
||||
half = [
|
||||
{"type": "line", "p0": [0, -R], "p1": [L, -R]},
|
||||
{"type": "arc", "center": [L, 0], "radius": R,
|
||||
"start_angle": -math.pi / 2, "end_angle": math.pi / 2},
|
||||
{"type": "line", "p0": [L, R], "p1": [0, R]},
|
||||
]
|
||||
call("sketch_add", entities=half)
|
||||
r = rep()
|
||||
check("CLOSED", not r["buildable"] and len(r["open_ends"]) == 2,
|
||||
f"half profile is correctly OPEN, both ends named {r['open_ends']}")
|
||||
call("sketch_select", entities=[0, 1, 2])
|
||||
call("sketch_mirror", axis_a=[0, 0], axis_b=[0, 1])
|
||||
r = rep()
|
||||
es = r["entities"]
|
||||
check("CLOSED", r["buildable"] and r["open_ends"] == [], "mirroring closed the loop")
|
||||
# every source vertex must have its exact reflection present
|
||||
src = []
|
||||
for e in es[:3]:
|
||||
src += [tuple(e["p0"]), tuple(e["p1"])]
|
||||
allv = []
|
||||
for e in es:
|
||||
allv += [tuple(e["p0"]), tuple(e["p1"])]
|
||||
sym = all(any(pt_near((-x, y), v) for v in allv) for (x, y) in src)
|
||||
check("SYMMETRY", sym, "every vertex has its exact mirror twin across x=0")
|
||||
mirrored_arc = [e for e in es[3:] if e["type"] == "arc"]
|
||||
check("ARC", mirrored_arc and near(mirrored_arc[0]["radius"], R)
|
||||
and pt_near(tuple(mirrored_arc[0]["center"]), (-L, 0)),
|
||||
f"mirrored cap keeps R={R} and lands at (-{L}, 0)")
|
||||
|
||||
print("\nRUNG 5 — offset: every curve moves by exactly d, and it stays closed")
|
||||
d = 4.0
|
||||
call("sketch_select", entities=list(range(len(es))))
|
||||
call("sketch_offset", distance=-d) # -d = outward for this CCW loop
|
||||
r = rep()
|
||||
new = r["entities"][len(es):]
|
||||
check("CLOSED", r["buildable"] and r["open_ends"] == [], "offset result is closed")
|
||||
off_arcs = [e for e in new if e["type"] == "arc"]
|
||||
check("ARC", all(near(a["radius"], R + d) for a in off_arcs),
|
||||
f"each cap radius grew by exactly {d} -> {R+d}")
|
||||
off_lines = [e for e in new if e["type"] == "line"]
|
||||
check("VERTEX", all(near(abs(e["p0"][1]), R + d) for e in off_lines),
|
||||
f"each straight moved out to |y| = {R+d} exactly")
|
||||
|
||||
print("\nRUNG 6 — a gap is found by coordinate, then closed by a real constraint")
|
||||
fresh()
|
||||
call("sketch_add", entities=[
|
||||
{"type": "line", "p0": [0, 0], "p1": [60, 0]},
|
||||
{"type": "line", "p0": [60, 0], "p1": [60, 40]},
|
||||
{"type": "line", "p0": [60, 40], "p1": [0, 40]},
|
||||
{"type": "line", "p0": [0, 40], "p1": [0.35, 0]}, # 0.35 mm short
|
||||
])
|
||||
r = call("sketch_validate", tolerance=1.0)
|
||||
check("CLOSED", not r["buildable"] and len(r["open_ends"]) == 2,
|
||||
f"the gap is reported, both free ends named {r['open_ends']}")
|
||||
dof0 = r["dof"]
|
||||
r = call("sketch_heal", tolerance=1.0)
|
||||
check("CLOSED", r["buildable"] and r["open_ends"] == [], "healed into a closed loop")
|
||||
check("VERTEX", r["welded"] == 1, "exactly one pair of vertices welded")
|
||||
check("ANGLE", r["dof"] < dof0,
|
||||
f"the weld is a real constraint, not a nudge: DoF {dof0} -> {r['dof']}")
|
||||
es = ents()
|
||||
check("VERTEX", pt_near(tuple(es[3]["p1"]), tuple(es[0]["p0"])),
|
||||
"the two ends are now the same point")
|
||||
|
||||
print("\nRUNG 7 — the composite: mirrored, tangent, two voids, all at once")
|
||||
fresh()
|
||||
call("sketch_add", entities=half)
|
||||
call("sketch_select", entities=[0, 1, 2])
|
||||
call("sketch_mirror", axis_a=[0, 0], axis_b=[0, 1])
|
||||
call("sketch_add", type="circle", center=[-25, 0], radius=6)
|
||||
call("sketch_add", type="circle", center=[25, 0], radius=6)
|
||||
r = rep()
|
||||
es = r["entities"]
|
||||
out = outer_loop(r)
|
||||
check("CLOSED", r["buildable"] and r["open_ends"] == [], "one closed outer loop, no free ends")
|
||||
check("CLOSED", len(out["holes"]) == 2, "it encloses exactly two voids")
|
||||
circles = [e for e in es if e["type"] == "circle"]
|
||||
check("ARC", all(near(c["radius"], 6) for c in circles), "both voids exactly R=6")
|
||||
check("SYMMETRY", pt_near(tuple(circles[0]["center"]), (-25, 0))
|
||||
and pt_near(tuple(circles[1]["center"]), (25, 0)),
|
||||
"the voids sit symmetrically at x = -25 and +25")
|
||||
tang = True
|
||||
for a in [e for e in es if e["type"] == "arc"]:
|
||||
for p in endpoints(a):
|
||||
for m in [e for e in es if e is not a and any(pt_near(p, q) for q in endpoints(e))]:
|
||||
if not smooth(a, m, p):
|
||||
tang = False
|
||||
check("TANGENT", tang, "every straight-to-cap junction is still smooth")
|
||||
exact = 2 * L * 2 * R + math.pi * R * R
|
||||
check("LENGTH", near(out["area"], exact, 1e-6),
|
||||
f"cross-check: enclosed area {out['area']:.4f} = 2L*2R + pi*R^2 = {exact:.4f}")
|
||||
|
||||
print("\nRUNG 8 — a real drawing: StudyCadCam MPD5, the pin's revolve half-profile")
|
||||
# Ø27 x 95 pin: C1 chamfer on the left end, cylinder to a corner at x=85, an R5 fillet into a
|
||||
# cone at 23 degrees to the axis, right face at x=95. Interpretation stated so the rung is
|
||||
# reproducible: 85 is to the CORNER, 23 deg is to the AXIS, C1 is 1 x 45.
|
||||
fresh()
|
||||
RAD, LEN, TX, ANG, RF, CH = 13.5, 95.0, 85.0, math.radians(23), 5.0, 1.0
|
||||
t = RF * math.tan(ANG / 2)
|
||||
ax, ay = TX - t, RAD # fillet tangent point on the cylinder
|
||||
cx, cy = ax, RAD - RF # fillet centre
|
||||
bx, by = TX + t * math.cos(-ANG), RAD + t * math.sin(-ANG) # tangent point on the cone
|
||||
ey = by - (LEN - bx) * math.tan(ANG) # where the cone meets the right face
|
||||
call("sketch_add", entities=[
|
||||
{"type": "line", "p0": [0, 0], "p1": [0, RAD - CH]}, # left face
|
||||
{"type": "line", "p0": [0, RAD - CH], "p1": [CH, RAD]}, # C1 chamfer
|
||||
{"type": "line", "p0": [CH, RAD], "p1": [ax, ay]}, # cylinder top
|
||||
{"type": "arc", "center": [cx, cy], "radius": RF,
|
||||
"start_angle": math.pi / 2, "end_angle": math.pi / 2 - ANG}, # R5 fillet
|
||||
{"type": "line", "p0": [bx, by], "p1": [LEN, ey]}, # 23 deg cone
|
||||
{"type": "line", "p0": [LEN, ey], "p1": [LEN, 0]}, # right face
|
||||
{"type": "line", "p0": [LEN, 0], "p1": [0, 0]}, # axis
|
||||
])
|
||||
r = rep()
|
||||
es = r["entities"]
|
||||
check("CLOSED", r["buildable"] and r["open_ends"] == [], "the half-profile is one closed loop")
|
||||
xs = [v[0] for e in es if "p0" in e for v in (e["p0"], e["p1"])]
|
||||
ys = [v[1] for e in es if "p0" in e for v in (e["p0"], e["p1"])]
|
||||
check("LENGTH", near(max(xs) - min(xs), LEN), f"overall length exactly {LEN} (the 95 dimension)")
|
||||
check("VERTEX", near(max(ys), RAD), f"outer radius exactly {RAD} (the dia 27)")
|
||||
fil = [e for e in es if e["type"] == "arc"][0]
|
||||
check("ARC", near(fil["radius"], RF), f"the corner fillet is exactly R{RF:g}")
|
||||
cone = [e for e in es if e["type"] == "line"
|
||||
and not near(e["p0"][0], e["p1"][0]) and not near(e["p0"][1], e["p1"][1])
|
||||
and e["length"] > 5]
|
||||
if cone:
|
||||
c0 = cone[0]
|
||||
a = abs(math.degrees(math.atan2(c0["p1"][1] - c0["p0"][1], c0["p1"][0] - c0["p0"][0])))
|
||||
check("ANGLE", near(a, 23, 1e-6), f"the cone is exactly 23 degrees to the axis (got {a:.6f})")
|
||||
cham = [e for e in es if e["type"] == "line" and near(e["length"], CH * math.sqrt(2), 1e-9)]
|
||||
check("ANGLE", bool(cham), "the C1 chamfer is exactly 1 x 45 (length 1*sqrt2)")
|
||||
tang = True
|
||||
for p in endpoints(fil):
|
||||
for m in [e for e in es if e is not fil and any(pt_near(p, q) for q in endpoints(e))]:
|
||||
if not smooth(fil, m, p):
|
||||
tang = False
|
||||
check("TANGENT", tang, "the fillet is tangent to BOTH the cylinder and the cone (no kink)")
|
||||
|
||||
call("sketch_cancel")
|
||||
|
||||
try:
|
||||
call("sketch_cancel")
|
||||
except Exception:
|
||||
pass # a rung may have closed it already
|
||||
print(f"\n{'ALL RUNGS HELD' if _fail == 0 else str(_fail) + ' CHECK(S) FAILED'}")
|
||||
sys.exit(1 if _fail else 0)
|
||||
Executable
+89
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env bash
|
||||
# One turn of the keyboard-focus convergence loop, start to verdict, with no human in it.
|
||||
#
|
||||
# scripts/CAD/focus-loop.sh # full turn: sync -> build -> restart -> assert
|
||||
# SKIP_BUILD=1 scripts/CAD/focus-loop.sh # re-assert against the binary already on the host
|
||||
#
|
||||
# Exit 0 only when every gate holds. Any other exit is a failing gate and names which.
|
||||
#
|
||||
# WHY THIS EXISTS. The focus defects in the Design tab were chased for days by hand: build, launch
|
||||
# the GUI, drive it with xdotool, read a screenshot, guess, repeat. That needs a person at every
|
||||
# step and it is where the days went. This does not: behemoth carries an agent-owned Xvfb :10 with
|
||||
# openbox, the app, xdotool and an MCP socket that reports sketch state as JSON, so a turn is
|
||||
# sync -> build -> restart -> assert, and the ASSERTION is the verdict, not my reading of a picture.
|
||||
#
|
||||
# WHY BEHEMOTH AND NOT THE orcacad-gui RIG CONTAINER. The rig was the obvious host and it does not
|
||||
# work for this: its image pins a dependency set 216 non-CAD source files behind cad-mainline
|
||||
# (assimp among them), so today's CAD sources call GUI_App::is_auto_close_sketch_loops and
|
||||
# MainFrame::ensure_design_panel, which that tree has never heard of. Syncing all of src/ to fix
|
||||
# that needs a deps rebuild measured in hours. behemoth already builds this exact tree, already
|
||||
# runs a WM on :10, and is the machine the user actually runs the product on — so the loop asserts
|
||||
# against the shipping artefact rather than a stale twin. Reviving the rig means rebuilding its
|
||||
# deps image first; until then it cannot adjudicate anything about this code.
|
||||
#
|
||||
# SC2029: every ssh command below quotes locally-expanded config (HOST, SRC, DISP) on purpose
|
||||
# -- the remote tree is not this checkout and has no such config of its own.
|
||||
# shellcheck disable=SC2029
|
||||
set -uo pipefail
|
||||
|
||||
HOST="${HOST:-tommaso@100.103.234.2}"
|
||||
DISP="${DISP:-:10}"
|
||||
SRC="${SRC:-\$HOME/projects/orca/orcacad-native/src}"
|
||||
TRACE="${TRACE:-/tmp/ux-focus-loop.log}"
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
BIN="build/src/Release/orca-slicer"
|
||||
|
||||
say() { printf '\n=== %s\n' "$*"; }
|
||||
die() { printf 'GATE FAILED: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
ssh -o ConnectTimeout=10 "$HOST" true || die "cannot reach $HOST"
|
||||
|
||||
# ---------------------------------------------------------------- S1 sync
|
||||
# Only the CAD paths and the ladders. behemoth's tree is a full cad-mainline checkout kept in step
|
||||
# by its own realign; pushing unrelated files from here would make the build host disagree with
|
||||
# git for reasons no later session could reconstruct.
|
||||
say "S1 sync"
|
||||
rsync -q "$REPO"/src/slic3r/GUI/CAD/*.{cpp,hpp} "$HOST:$SRC/src/slic3r/GUI/CAD/" || die "sync GUI/CAD"
|
||||
rsync -q "$REPO"/src/libslic3r/CAD/*.{cpp,hpp} "$HOST:$SRC/src/libslic3r/CAD/" || die "sync libslic3r/CAD"
|
||||
rsync -q "$REPO"/scripts/CAD/check-gui-click-edit.py "$REPO"/scripts/CAD/check-gui-sketching.py \
|
||||
"$HOST:/tmp/" || die "sync ladders"
|
||||
echo " sources + ladders in place"
|
||||
|
||||
# ---------------------------------------------------------------- S2 build
|
||||
# flock: two concurrent Orca builds once OOM'd this machine for 2h28m. Every build script on the
|
||||
# fleet takes this same lock.
|
||||
#
|
||||
# Grade the BINARY'S TIMESTAMP, never the build command's exit code. This is a Ninja Multi-Config
|
||||
# tree whose default rules are Debug while the artefact under test is Release, so a wrong-config
|
||||
# invocation returns success in seconds having touched nothing — it cost a wasted cycle here
|
||||
# before anyone thought to look at the file.
|
||||
if [ -z "${SKIP_BUILD:-}" ]; then
|
||||
say "S2 build"
|
||||
before=$(ssh "$HOST" "stat -c %Y $SRC/$BIN 2>/dev/null || echo 0")
|
||||
ssh "$HOST" "flock /tmp/orca-rig-build.lock \$HOME/projects/orca/orcacad-native/rebuild.sh > /tmp/focus-build.log 2>&1"
|
||||
rc=$?
|
||||
after=$(ssh "$HOST" "stat -c %Y $SRC/$BIN 2>/dev/null || echo 0")
|
||||
if [ "$rc" != 0 ] || [ "$after" = "$before" ]; then
|
||||
ssh "$HOST" "grep -m5 -B2 'error:' /tmp/focus-build.log; tail -5 /tmp/focus-build.log"
|
||||
die "S2 build (exit $rc, binary $( [ "$after" = "$before" ] && echo unchanged || echo rebuilt ))"
|
||||
fi
|
||||
echo " built"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- S3 F2P
|
||||
# The ladder launches and tears down the app itself, in its own datadir, so nothing here has to
|
||||
# manage a process. It types WITHOUT clicking the field first, which is the whole contract.
|
||||
say "S3 fail-to-pass: type without clicking the field"
|
||||
ssh "$HOST" "cd $SRC && DISPLAY=$DISP python3 /tmp/check-gui-click-edit.py \
|
||||
--display $DISP --bin $BIN --trace $TRACE"
|
||||
f2p=$?
|
||||
|
||||
# ---------------------------------------------------------------- S4 P2P
|
||||
say "S4 pass-to-pass: the existing gesture ladder"
|
||||
ssh "$HOST" "cd $SRC && DISPLAY=$DISP python3 /tmp/check-gui-sketching.py 2>&1 | tail -3"
|
||||
p2p=$?
|
||||
|
||||
say "VERDICT"
|
||||
[ "$f2p" = 0 ] || die "F2P: a tool did not take the typed value (exit $f2p)"
|
||||
[ "$p2p" = 0 ] || die "P2P: the gesture ladder regressed (exit $p2p)"
|
||||
echo "ALL GATES HELD"
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
# Every ladder, in one command, as the gate before a push that touched the Design tab.
|
||||
#
|
||||
# WHY A SCRIPT AND NOT CI. Three of the four rungs need a running application with an OpenGL
|
||||
# canvas and synthetic input; GitHub's runners have neither. So the gate is local and explicit:
|
||||
# run this, read the last line, and do not push a red one. The kernel suite is the only part CI
|
||||
# can carry, and it already does.
|
||||
#
|
||||
# scripts/CAD/run-all-checks.sh # kernel + engine + corpus (every 20th) + gestures + offer
|
||||
# FULL=1 scripts/CAD/run-all-checks.sh # corpus over ALL 997 sheets (~25 min)
|
||||
# SKIP_GUI=1 scripts/CAD/run-all-checks.sh # kernel only, for a machine with no rig
|
||||
#
|
||||
# The rig container is expected to be up with the app running and ORCA_CAD_MCP set; bring it up
|
||||
# with scripts/CAD/start-headless-gui.sh inside it. The corpus lives at /corpus in that container.
|
||||
set -uo pipefail
|
||||
# ../.. -- this script lives in scripts/CAD/, so one level up is scripts/, not the repo
|
||||
# root. It was scripts/ladder-all.sh when it was written; the move fixed the three sibling
|
||||
# scripts and missed this one, which left every rung looking for its own path under
|
||||
# scripts/scripts/ and reporting instant failures that were all the same typo.
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/../.." || exit 1
|
||||
|
||||
# orcacad-gui, NOT snapmaker-gui: that is the other fork's rig, and defaulting to it makes
|
||||
# this gate verify the wrong fork's binary while reporting green. run-kernel-tests.sh
|
||||
# carries the same warning about the build volume, where the defect was found first.
|
||||
C="${C:-orcacad-gui}"
|
||||
CORPUS="${CORPUS:-/corpus}"
|
||||
STEP="${STEP:-20}"
|
||||
[ -n "${FULL:-}" ] && STEP=1
|
||||
fail=0
|
||||
|
||||
step() {
|
||||
local name="$1"; shift
|
||||
echo
|
||||
echo "=== $name ==="
|
||||
if "$@"; then echo "--- $name OK"; else echo "--- $name FAILED"; fail=1; fi
|
||||
}
|
||||
|
||||
# This fork's rig runs Xvfb on :11, the other fork's on :10, and the check scripts default
|
||||
# to ":10" when DISPLAY is unset -- which docker exec leaves unset. The rungs that drive the
|
||||
# GUI therefore looked for a window on a display that does not exist here and reported
|
||||
# "FATAL no app window on :10", which reads like a dead app rather than a wrong display.
|
||||
RIG_DISPLAY="${RIG_DISPLAY:-:11}"
|
||||
|
||||
# SC2329: every call goes through step(), which invokes it via "$@", so shellcheck
|
||||
# cannot see the callers below.
|
||||
# shellcheck disable=SC2329
|
||||
run_in_rig() { # copy the script in fresh, then run it there
|
||||
docker cp "$1" "$C:/tmp/$(basename "$1")" >/dev/null || return 1
|
||||
shift
|
||||
docker exec -e DISPLAY="$RIG_DISPLAY" "$C" python3 "$@"
|
||||
}
|
||||
|
||||
# FIRST, and it needs no rig: the offer table the menu is compiled from must be what the atlas
|
||||
# says. The header calls itself GENERATED and had been hand-edited anyway — which cost four rows
|
||||
# that existed only in the header, one row wired to the wrong action, and a count of 91 for a
|
||||
# 92-row array, so the last verb was unreachable (z8rs, ziam).
|
||||
# docs/CAD/, not docs/: SoftFever moved the design docs into the CAD subfolder
|
||||
# (bbd1989e1e) and this line kept the old path, so the rung failed on a missing file
|
||||
# rather than on anything about the table. The other fork still has docs/ux/.
|
||||
step "offer table matches the atlas" python3 docs/CAD/ux/mockups/gen_offer_table.py --check
|
||||
|
||||
step "kernel suite" scripts/CAD/run-kernel-tests.sh --vol "${KVOL:-orcacad_kerneltest}"
|
||||
|
||||
if [ -z "${SKIP_GUI:-}" ]; then
|
||||
step "engine ladder (rungs 1-8, scripted geometry)" \
|
||||
run_in_rig scripts/CAD/check-sketch-engine.py /tmp/check-sketch-engine.py
|
||||
step "corpus rung (real drawings, every ${STEP}th)" \
|
||||
run_in_rig scripts/CAD/check-sketch-engine-corpus.py /tmp/check-sketch-engine-corpus.py --corpus "$CORPUS" --step "$STEP"
|
||||
step "corpus scale rung (the heaviest sheets)" \
|
||||
run_in_rig scripts/CAD/check-sketch-engine-corpus.py /tmp/check-sketch-engine-corpus.py --corpus "$CORPUS" --scale
|
||||
step "gesture ladder (mouse and keyboard)" \
|
||||
run_in_rig scripts/CAD/check-gui-sketching.py /tmp/check-gui-sketching.py
|
||||
# The offer ladder needs TWO extra things the others do not: the app must have been launched
|
||||
# with ORCA_CAD_KEYTRACE=1 (its [OFFER] lines are the whole instrument), and it reads the
|
||||
# generated offer table to predict what each selection should show — which is not in the
|
||||
# container's own baked source tree, so it is copied in beside the script — /tmp, where
|
||||
# run_in_rig puts the script, is one of the paths the ladder looks in.
|
||||
docker cp src/slic3r/GUI/CAD/DesignOffer.hpp "$C:/tmp/DesignOffer.hpp" >/dev/null
|
||||
step "offer ladder (right-click, the menu, the verbs behind it)" \
|
||||
run_in_rig scripts/CAD/check-gui-context-menu.py /tmp/check-gui-context-menu.py
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$fail" -eq 0 ]; then echo "ALL LADDERS HELD"; else echo "AT LEAST ONE LADDER FAILED"; fi
|
||||
exit "$fail"
|
||||
Executable
+159
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env bash
|
||||
# Headless CAD-kernel test loop. THE verification contract for delegated work:
|
||||
# exit 0 means the kernel builds and the selected Catch2 tags pass. Nothing else counts.
|
||||
#
|
||||
# Builds only the `libslic3r_tests` target (not the GUI app), so a round trip is
|
||||
# minutes, not tens of minutes. Needs no display: every [CadDocument] case builds a
|
||||
# CadDocument, recompute()s it and asserts on geometry.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/CAD/run-kernel-tests.sh # [CadDocument] tags, default volume
|
||||
# scripts/CAD/run-kernel-tests.sh --tags '[CadDocument],[Sketch]'
|
||||
# scripts/CAD/run-kernel-tests.sh --vol wt_mirror # private build cache (parallel workers)
|
||||
# scripts/CAD/run-kernel-tests.sh --host tommaso@100.103.234.2 # build on a remote host
|
||||
#
|
||||
# Parallel workers MUST pass a distinct --vol: two builds sharing one cache corrupt
|
||||
# each other. A new volume pays one full build; runs after that are incremental.
|
||||
#
|
||||
# --host exists because the deps image lives wherever it was first built. It rsyncs this
|
||||
# working tree to a per-volume staging dir on that host and re-runs this same script
|
||||
# there, so the verification contract is identical either way. Drop --host once the image
|
||||
# is present locally.
|
||||
# Rig build traps already paid for once each (stale project, NLopt cache, pybind11, OCCT_LIBS, SLIC3R_CAD gate): docs/rig_build_traps.md
|
||||
set -euo pipefail
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
# orcacad-deps, NOT snapmaker-deps: this fork is mainline-based and needs Eigen 5.0.1,
|
||||
# CGAL 5.6.3, wx 3.3.2 and Python 3.12 Development.Embed, none of which snapmaker-deps has.
|
||||
# With the wrong image CMake dies at configure, which is exactly why this fork went
|
||||
# M1-M8 without ever compiling (see commit 1633005bba).
|
||||
IMAGE="${IMAGE:-orcacad-deps}"
|
||||
# Must NOT default to snapmaker_buildcache: that is the other fork's volume, and pointing
|
||||
# this fork at it makes the two silently trade build artefacts. build-gui-incremental.sh had the
|
||||
# identical defect and was fixed to orcacad_buildcache; this script was missed.
|
||||
VOL="${BUILD_VOL:-orcacad_kerneltest}"
|
||||
# No exclusions. Both cases that used to be quarantined now run: the solver SIGABRT on
|
||||
# circle-line tangency is fixed (tkz), and the internal-thread case turned out to have
|
||||
# correct geometry and a wrong reference in the test (kzy). A green run here now means
|
||||
# the whole CAD suite passed, not "everything except the two we gave up on".
|
||||
#
|
||||
# ...and that claim was still not true, because the default tag was [CadDocument] alone while
|
||||
# four CAD test files carry their own tags and NOTHING ELSE selected them. test_sketchinference
|
||||
# ([inference], 15), test_sketchedit ([SketchEdit], 23), test_sketchconstraints
|
||||
# ([SketchConstraints], 8) and test_sketchimport ([SketchImport], 4) never ran here, nor did the
|
||||
# older [slvs]-only cases in test_slvs_constraints. Measured 2026-08-31: the default reported
|
||||
# 2624 assertions / 206 cases, the full set 7648 / 264 -- so the gate was speaking for about a
|
||||
# third of the assertions, and a whole file could be added, tagged by its own convention, and
|
||||
# stay dark while the suite printed green. All 58 were passing; the coverage was simply never
|
||||
# exercised. Adding a tag here is now part of adding a test file.
|
||||
TAGS="${TAGS:-[CadDocument],[inference],[SketchEdit],[SketchConstraints],[SketchImport],[slvs]}"
|
||||
HOST=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--vol) VOL="$2"; shift 2 ;;
|
||||
--tags) TAGS="$2"; shift 2 ;;
|
||||
--image) IMAGE="$2"; shift 2 ;;
|
||||
--host) HOST="$2"; shift 2 ;;
|
||||
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "REPO=$REPO IMAGE=$IMAGE VOL=$VOL TAGS=$TAGS HOST=${HOST:-local}"
|
||||
|
||||
if [[ -n "$HOST" ]]; then
|
||||
# Stage per-volume so parallel workers never share a remote tree.
|
||||
REMOTE="kt-$VOL" # relative: ssh and rsync both start in the remote home dir
|
||||
# SC2029: $REMOTE expanding on the CLIENT is the point -- it is derived from $VOL here,
|
||||
# and the remote has no such variable. The rsync destination below expands it the same way.
|
||||
# shellcheck disable=SC2029
|
||||
ssh "$HOST" "mkdir -p $REMOTE"
|
||||
# Only the inputs the build reads. --delete keeps a stale file from a prior worker from
|
||||
# silently compiling in.
|
||||
rsync -a --delete \
|
||||
"$REPO/src" "$REPO/tests" "$REPO/resources" "$REPO/cmake" "$REPO/scripts" \
|
||||
"$REPO/CMakeLists.txt" \
|
||||
"$HOST:$REMOTE/"
|
||||
exec ssh "$HOST" "cd $REMOTE && scripts/CAD/run-kernel-tests.sh --vol '$VOL' --tags '$TAGS' --image '$IMAGE'"
|
||||
fi
|
||||
|
||||
if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
|
||||
echo "FATAL: image '$IMAGE' not present locally. Either transfer it, or pass" >&2
|
||||
echo " --host <user@host> to build where the image already exists." >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# A private volume is always built CLEAN on first use. Cloning a warm cache from another
|
||||
# tree looks tempting but is a correctness trap: rsync preserves source mtimes, so ninja
|
||||
# compares foreign object timestamps against them, decides everything is up to date, and
|
||||
# relinks stale objects. That produced a binary with NO [CadDocument] tests at all while
|
||||
# reporting success -- a green run that tested nothing. Pay the one-off full build instead;
|
||||
# incremental rebuilds within a volume are correct because the mtimes then share a lineage.
|
||||
if ! docker volume inspect "$VOL" >/dev/null 2>&1; then
|
||||
echo "=== $VOL: new volume, clean configure + full build (one-off, slow) ==="
|
||||
docker volume create "$VOL" >/dev/null
|
||||
fi
|
||||
|
||||
# tests/ is mounted too -- unlike build-gui-incremental.sh, this script exists precisely to
|
||||
# compile tests being edited. CMakeLists.txt and cmake/ carry the SLIC3R_CAD gate; taking
|
||||
# them from the baked image instead leaves the gate off and the CAD symbols vanish.
|
||||
|
||||
# ---- OOM guard (2026-08-21) -------------------------------------------------------------
|
||||
# Two of these builds ran at once on 2026-08-21, each with ninja -j$(nproc)=16: ~36 cc1plus
|
||||
# holding 42 GB of a 62 GB box -> global OOM at 21:05, a 2h28m kill storm, ssh unreachable,
|
||||
# lightdm destroyed. Neither build produced a single object. scripts/CAD/build-gui.sh grew the
|
||||
# bounds first; every script that starts a compile needs the same three, or the guard is only
|
||||
# as strong as the script you happened not to use.
|
||||
# flock — the lock path is SHARED with build-gui.sh and the other fork on purpose, so
|
||||
# concurrent builds serialise instead of summing.
|
||||
# -j — bounded parallelism; ~1.17 GB per cc1plus was the measured average.
|
||||
# --memory — the actual guarantee: a runaway build dies in its own cgroup instead of taking
|
||||
# the host down. --memory-swap equal to --memory forbids swap, which is what made
|
||||
# ssh hang.
|
||||
JOBS="${JOBS:-12}"
|
||||
MEM="${MEM:-40g}"
|
||||
LOCK=/tmp/orca-rig-build.lock
|
||||
|
||||
exec 9>"$LOCK"
|
||||
if ! flock -n 9; then
|
||||
echo "another build holds $LOCK — waiting (this is the OOM guard, not a hang)"
|
||||
flock 9
|
||||
fi
|
||||
|
||||
docker run --rm \
|
||||
--memory="$MEM" --memory-swap="$MEM" \
|
||||
-v "$REPO/src":/OrcaSlicer/src \
|
||||
-v "$REPO/tests":/OrcaSlicer/tests \
|
||||
-v "$REPO/resources":/OrcaSlicer/resources \
|
||||
-v "$REPO/CMakeLists.txt":/OrcaSlicer/CMakeLists.txt \
|
||||
-v "$REPO/cmake":/OrcaSlicer/cmake \
|
||||
-v "$REPO/deps_src":/OrcaSlicer/deps_src \
|
||||
-v "$VOL":/OrcaSlicer/build \
|
||||
"$IMAGE" \
|
||||
bash -lc "set -e
|
||||
cd /OrcaSlicer
|
||||
DESTDIR=/OrcaSlicer/deps/build/destdir/usr/local
|
||||
export PATH=\$DESTDIR/bin:\$PATH # wx-config, and anything else the deps prefix ships
|
||||
# Configure unconditionally. Guarding on 'CMakeCache.txt exists' is wrong: a FAILED
|
||||
# configure still writes that file, so the guard then skips reconfiguring forever and
|
||||
# every later run reuses a poisoned cache while ignoring corrected flags. A no-op
|
||||
# reconfigure costs seconds; that bug costs an afternoon.
|
||||
# SLIC3R_GTK=3 and BUILD_TESTS=ON are not optional extras: src/CMakeLists.txt turns
|
||||
# SLIC3R_GTK into 'wx-config --toolkit=gtk<N>', so omitting it asks for toolkit 'gtk'
|
||||
# and no wx build matches -> 'Could NOT find wxWidgets'. BUILD_TESTS=ON is what makes
|
||||
# the libslic3r_tests target exist at all. build_linux.sh sets both (lines 218, 226).
|
||||
cmake -S . -B build -G 'Ninja Multi-Config' \
|
||||
-DCMAKE_PREFIX_PATH=\$DESTDIR \
|
||||
-DwxWidgets_CONFIG_EXECUTABLE=\$DESTDIR/bin/wx-config \
|
||||
-DSLIC3R_GTK=3 -DBUILD_TESTS=ON -DSLIC3R_GUI=OFF \
|
||||
-DSLIC3R_CAD=ON -DSLIC3R_STATIC=1 -DORCA_TOOLS=ON -DCMAKE_BUILD_TYPE=Release
|
||||
# SLIC3R_GUI=OFF: this script builds ONLY libslic3r_tests, which links libslic3r and no GUI
|
||||
# code, but cmake still PROCESSES the if SLIC3R_GUI block of src/CMakeLists.txt (lines 16-98)
|
||||
# and every find_package inside it. That made the kernel suite depend on the GUI dependency
|
||||
# set for no benefit, and it broke the moment upstream added wxInspector as a REQUIRED
|
||||
# find_package at line 92: the orcacad-deps image predates it, so configure died pointing at
|
||||
# src/CMakeLists.txt:92 while nothing about the kernel had changed. Turning the block off is
|
||||
# not a workaround for that one dependency; it is the kernel suite finally declaring what it
|
||||
# actually needs, so the next GUI-side dependency upstream adds cannot break it either.
|
||||
cmake --build build --config Release --target libslic3r_tests -- -j$JOBS
|
||||
./build/tests/libslic3r/Release/libslic3r_tests '$TAGS' --order decl"
|
||||
Executable
+148
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bring the headless GUI up on a VNC-served X display, ready to drive or to attach Remmina to.
|
||||
#
|
||||
# Runs INSIDE the long-lived GUI container (see the header of scripts/CAD/build-gui-incremental.sh for how
|
||||
# that container is created). Idempotent: safe to re-run to recover a session whose app died.
|
||||
#
|
||||
# docker exec <container> /OrcaSlicer/scripts/CAD/start-headless-gui.sh # launch + settle
|
||||
# docker exec <container> /OrcaSlicer/scripts/CAD/start-headless-gui.sh --status # report, change nothing
|
||||
#
|
||||
# WHY THIS EXISTS. Dismissing the first-run dialogs by computing the titlebar close box from
|
||||
# `xdotool getwindowgeometry --shell` and clicking it went wrong whenever the dialog had already
|
||||
# closed: the eval left the geometry variables stale or empty, the click landed at a garbage
|
||||
# coordinate, and it repeatedly hit the Sketch button in the toolbar underneath, so the app came up
|
||||
# in sketch mode with a stray Sketch feature. Three of those in one session.
|
||||
#
|
||||
# The titlebar click is nevertheless the RIGHT mechanism and is kept. `xdotool windowclose` looks
|
||||
# cleaner but kills the app: it destroys the GdkWindow out from under the dialog and the process
|
||||
# dies with "GdkWindow unexpectedly destroyed", three GLib-GObject criticals and a segfault
|
||||
# (measured 2026-07-30). Escape does not close the Setup Wizard either. So the fix is not a
|
||||
# different mechanism, it is refusing to click on geometry we have not validated.
|
||||
set -euo pipefail
|
||||
|
||||
DISP="${DISP:-:11}"
|
||||
GEOM="${GEOM:-1920x1080}"
|
||||
BIN="${BIN:-/OrcaSlicer/build/src/Release/orca-slicer}"
|
||||
# Packaging cannot bundle python (the deps python layer carries a doubled-DESTDIR RUNPATH), so the
|
||||
# build-tree binary needs the deps libpython on the path. Packaging-only issue; the app runs fine.
|
||||
LIBPY="/OrcaSlicer/deps/build/destdir/usr/local/libpython/lib"
|
||||
LIBPY2="/OrcaSlicer/build/src/Release/python/lib"
|
||||
LOG="${LOG:-/tmp/gui-session.log}"
|
||||
|
||||
export DISPLAY="$DISP" HOME=/root
|
||||
export LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe
|
||||
# The ladders read the document back through the MCP socket, and the offer ladder's only
|
||||
# instrument is the [OFFER] keytrace. Neither is on by default, and a session launched without
|
||||
# them comes up looking perfectly healthy: the window is there, status says app up, and every
|
||||
# ladder then dies on "Connection refused" — which reads as a dead app rather than a rig that was
|
||||
# started without its instrument. The script that launches the rig is where they belong.
|
||||
export ORCA_CAD_MCP="${ORCA_CAD_MCP:-/tmp/mcp.sock}"
|
||||
export ORCA_CAD_KEYTRACE="${ORCA_CAD_KEYTRACE:-1}"
|
||||
export LD_LIBRARY_PATH="$LIBPY:$LIBPY2:${LD_LIBRARY_PATH:-}"
|
||||
mkdir -p /root/.config # startup dies in boost::filesystem::create_directory without this
|
||||
|
||||
# Skip zombies. This container accumulates <defunct> instances of the app across runs, and pgrep
|
||||
# matches them, so the naive "first match" reported a dead pid as if the session were healthy.
|
||||
app_pid() {
|
||||
local p
|
||||
for p in $(pgrep -f "$(basename "$BIN")" 2>/dev/null); do
|
||||
[ "$(awk "{print \$3}" "/proc/$p/stat" 2>/dev/null)" = "Z" ] && continue
|
||||
echo "$p"; return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
status() {
|
||||
echo "display : $(pgrep -f "Xvfb $DISP" >/dev/null && echo up || echo DOWN)"
|
||||
echo "wm : $(pgrep -x openbox >/dev/null && echo up || echo DOWN)"
|
||||
if pgrep -x x11vnc >/dev/null; then echo "vnc : up on :5900"
|
||||
elif ! command -v x11vnc >/dev/null; then echo "vnc : n/a (x11vnc not installed here)"
|
||||
else echo "vnc : DOWN"; fi
|
||||
local p; p="$(app_pid || true)"
|
||||
echo "app : ${p:-DOWN}"
|
||||
# WHICH binary is on screen, not just that something is. A pid alone cannot tell you whether
|
||||
# you are looking at the build you just linked or one from last week, and that is precisely
|
||||
# the question every rig verification is asking.
|
||||
[ -n "${p:-}" ] && echo "binary : $(readlink -f "/proc/$p/exe" 2>/dev/null || echo unknown)"
|
||||
[ -n "${p:-}" ] && echo "windows : $(xdotool search --name . getwindowname %@ 2>/dev/null | paste -sd'|' -)"
|
||||
return 0
|
||||
}
|
||||
|
||||
[ "${1:-}" = "--status" ] && { status; exit 0; }
|
||||
|
||||
# --- desktop: Xvfb, a window manager, and the VNC server ------------------------------------
|
||||
# openbox is REQUIRED: without it xdotool windowactivate aborts with "windowmanager claims not to
|
||||
# support _NET_ACTIVE_WINDOW" and dialogs never take focus.
|
||||
pgrep -f "Xvfb $DISP" >/dev/null || { nohup Xvfb "$DISP" -screen 0 "${GEOM}x24" -nolisten tcp >/tmp/xvfb.log 2>&1 & sleep 3; }
|
||||
pgrep -x openbox >/dev/null || { nohup openbox >/tmp/openbox.log 2>&1 & sleep 1; }
|
||||
if ! pgrep -x x11vnc >/dev/null && command -v x11vnc >/dev/null; then
|
||||
AUTH=()
|
||||
[ -f /root/.vnc/passwd ] && AUTH=(-rfbauth /root/.vnc/passwd)
|
||||
nohup x11vnc -display "$DISP" -rfbport 5900 "${AUTH[@]}" -forever -shared -noxdamage \
|
||||
>/tmp/x11vnc.log 2>&1 &
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
# --- app ------------------------------------------------------------------------------------
|
||||
# Kill by BASENAME, not by "$BIN". The app enforces a single instance, so an older copy launched
|
||||
# from a DIFFERENT path (the packaged build/package/bin/ one, say, when BIN points at the freshly
|
||||
# linked build/src/Release/ one) survives a path-matched pkill, keeps the instance lock, and the
|
||||
# new process exits seconds after loading fonts — leaving no error anywhere. app_pid() below has
|
||||
# always matched by basename, so status then reported that stale process as a healthy session:
|
||||
# the launch looked green while the window on screen was days old. Measured 2026-08-02, where it
|
||||
# nearly passed a UI change against a Jul-30 binary. The killer and the reporter must agree on
|
||||
# what counts as "the app".
|
||||
pkill -9 -f "$(basename "$BIN")" 2>/dev/null || true
|
||||
sleep 2
|
||||
nohup "$BIN" >"$LOG" 2>&1 &
|
||||
echo "launched $(basename "$BIN") pid $!"
|
||||
|
||||
# Wait for the main window rather than sleeping a fixed amount: cold starts vary a lot under
|
||||
# software GL, and a fixed sleep either wastes time or races.
|
||||
for _ in $(seq 1 40); do
|
||||
xdotool search --name "Untitled" >/dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# --- first-run dialogs ----------------------------------------------------------------------
|
||||
# Click the titlebar close box, but only on geometry we have just read for a window that still
|
||||
# exists, and only if the resulting point is inside the screen. Every variable is unset first so a
|
||||
# failed read cannot leave the previous dialog's numbers behind — that is the whole bug.
|
||||
screen_w="${GEOM%x*}"; screen_h="${GEOM#*x}"
|
||||
close_dialog() {
|
||||
local name="$1" id X Y WIDTH HEIGHT cx cy
|
||||
id="$(xdotool search --name "$name" 2>/dev/null | head -1 || true)"
|
||||
[ -z "$id" ] && return 1
|
||||
unset X Y WIDTH HEIGHT
|
||||
eval "$(xdotool getwindowgeometry --shell "$id" 2>/dev/null || true)"
|
||||
# All four must be present and numeric: an empty or stale read is how the stray click happened.
|
||||
for v in "${X:-}" "${Y:-}" "${WIDTH:-}" "${HEIGHT:-}"; do
|
||||
[[ "$v" =~ ^-?[0-9]+$ ]] || { echo " $name: unreadable geometry, not clicking"; return 1; }
|
||||
done
|
||||
cx=$((X + WIDTH - 11)); cy=$((Y - 31)) # openbox decoration: close box above the frame
|
||||
if [ "$cx" -lt 0 ] || [ "$cy" -lt 0 ] || [ "$cx" -ge "$screen_w" ] || [ "$cy" -ge "$screen_h" ]; then
|
||||
echo " $name: close box at ${cx},${cy} is off-screen, not clicking"; return 1
|
||||
fi
|
||||
echo " $name: closing via titlebar at ${cx},${cy}"
|
||||
xdotool mousemove "$cx" "$cy" click 1
|
||||
sleep 2
|
||||
return 0
|
||||
}
|
||||
# "Restore" is not a first-RUN dialog, it is a second-run one: killing the app mid-session leaves
|
||||
# unsaved items behind, and the next launch asks whether to restore them. It sits over the tab bar
|
||||
# with a modal grab, so every synthetic click afterwards lands on it and the ladder reports
|
||||
# geometry that never got drawn — that is the "success with no log" shape twice already.
|
||||
for name in "Setup Wizard" "New version" "Restore"; do
|
||||
for _ in 1 2 3; do close_dialog "$name" || break; done
|
||||
done
|
||||
|
||||
# --- main window ----------------------------------------------------------------------------
|
||||
main="$(xdotool search --name "Untitled" 2>/dev/null | head -1 || true)"
|
||||
if [ -n "$main" ]; then
|
||||
xdotool windowmove "$main" 0 0 windowsize "$main" "$screen_w" "$screen_h" 2>/dev/null || true
|
||||
xdotool windowactivate "$main" 2>/dev/null || true
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
echo "--- session ---"
|
||||
status
|
||||
@@ -0,0 +1,95 @@
|
||||
# Deps-only base image for fast iteration on Orca.
|
||||
# Identical system+pinned-dependency setup to scripts/Dockerfile, but STOPS after
|
||||
# `build_linux.sh -dr` (no slicer/AppImage build). Produces an image with the pinned
|
||||
# deps baked at /OrcaSlicer/deps/build/destdir, so the slicer can be rebuilt
|
||||
# incrementally via scripts/CAD/build-gui-incremental.sh without re-running the long deps build.
|
||||
#
|
||||
# Build once (rebuild only when deps/ changes, e.g. OCCT module flags):
|
||||
# docker build -t snapmaker-deps -f scripts/Dockerfile.deps .
|
||||
FROM docker.io/ubuntu:24.04
|
||||
LABEL maintainer="Orca CAD iteration base"
|
||||
|
||||
# Disable interactive package configuration
|
||||
RUN apt-get update && \
|
||||
echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections
|
||||
|
||||
# Add a deb-src
|
||||
RUN echo deb-src http://archive.ubuntu.com/ubuntu \
|
||||
$(cat /etc/*release | grep VERSION_CODENAME | cut -d= -f2) main universe>> /etc/apt/sources.list
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
autoconf \
|
||||
build-essential \
|
||||
cmake \
|
||||
curl \
|
||||
eglexternalplatform-dev \
|
||||
extra-cmake-modules \
|
||||
file \
|
||||
git \
|
||||
gstreamer1.0-plugins-bad \
|
||||
gstreamer1.0-libav \
|
||||
libcairo2-dev \
|
||||
libcurl4-openssl-dev \
|
||||
libdbus-1-dev \
|
||||
libglew-dev \
|
||||
libglu1-mesa-dev \
|
||||
libgstreamer1.0-dev \
|
||||
libgstreamerd-3-dev \
|
||||
libgstreamer-plugins-base1.0-dev \
|
||||
libgstreamer-plugins-good1.0-dev \
|
||||
libgtk-3-dev \
|
||||
libsecret-1-dev \
|
||||
libsoup2.4-dev \
|
||||
libssl3 \
|
||||
libssl-dev \
|
||||
libtool \
|
||||
libudev-dev \
|
||||
libwayland-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libxkbcommon-dev \
|
||||
locales \
|
||||
locales-all \
|
||||
m4 \
|
||||
pkgconf \
|
||||
sudo \
|
||||
wayland-protocols \
|
||||
wget
|
||||
|
||||
ENV LC_ALL=en_US.utf8
|
||||
RUN locale-gen $LC_ALL
|
||||
ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
COPY ./ OrcaSlicer
|
||||
WORKDIR OrcaSlicer
|
||||
|
||||
# System dependencies
|
||||
RUN ./build_linux.sh -u
|
||||
|
||||
# Pinned dependencies in ./deps (OCCT 7.6 with ModelingAlgorithms enabled, OpenCV,
|
||||
# OpenVDB, Boost, wxWidgets, ...). This is the long step; it is baked into the image.
|
||||
# -j 12, not the default all-cores: on 2026-08-21 an unbounded deps build plus an unbounded
|
||||
# app build put 42 GB of cc1plus on a 62 GB box and OOM-killed the host for 2h28m. 12 keeps
|
||||
# the compile fast while leaving the machine usable. Pair with `docker build --memory`.
|
||||
RUN ./build_linux.sh -dr -j 12
|
||||
|
||||
# Compatibility symlink. This deps tree installs into deps/build/OrcaSlicer_dep/, while the
|
||||
# orcacad_buildcache volume was configured against the previous image, whose prefix was
|
||||
# deps/build/destdir/. Those paths are baked as absolute strings into the app's CMakeCache, so
|
||||
# without this symlink a rebuild on the new image reconfigures from scratch — hours of compile
|
||||
# to change one directory name. Same tree, two names.
|
||||
RUN ln -sfn /OrcaSlicer/deps/build/OrcaSlicer_dep /OrcaSlicer/deps/build/destdir
|
||||
|
||||
# The rig's GUI runtime. This used to arrive for free because orcacad-deps was layered on
|
||||
# snapmaker-deps; that lineage is Trap 1 in docs/rig_build_traps.md (a baked project(Snapmaker_Orca)
|
||||
# tree) and building from this Dockerfile is what removes it — along with the X stack the rig
|
||||
# needs. scripts/CAD/start-headless-gui.sh requires Xvfb and openbox (without a window manager `xdotool
|
||||
# windowactivate` aborts with "windowmanager claims not to support..."), drives the UI with
|
||||
# xdotool, and captures to /shots with scrot/ImageMagick. Same set snapmaker-deps carries.
|
||||
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
imagemagick \
|
||||
openbox \
|
||||
scrot \
|
||||
xauth \
|
||||
xdotool \
|
||||
xvfb \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -0,0 +1,70 @@
|
||||
# Adds assimp to the orcacad-deps image.
|
||||
#
|
||||
# WHY THIS EXISTS. OrcaSlicer mainline gained `find_package(assimp REQUIRED)` in
|
||||
# src/libslic3r/CMakeLists.txt (glTF/GLB/FBX import for texture-to-colour), and the baked
|
||||
# orcacad-deps image predates it: the image's deps/ tree has no Assimp directory at all, and
|
||||
# nothing named assimp exists anywhere in it. On the host, deps/build/dep_Assimp-prefix has
|
||||
# only `patch` and `update` stamps -- no build, no install -- so the dependency was fetched
|
||||
# and then never built, in the image or out of it.
|
||||
#
|
||||
# The consequence was that scripts/CAD/run-kernel-tests.sh failed at CMake CONFIGURE time,
|
||||
# before a single source file compiled, so THIS FORK'S KERNEL SUITE COULD NOT RUN AT ALL.
|
||||
# Every kernel change ported here was parity-checked against Snapmaker and never independently
|
||||
# tested (w80c). The Snapmaker fork does not hit this: its base requires neither
|
||||
# assimp nor OpenCV.
|
||||
#
|
||||
# WHY A LAYER AND NOT A FULL DEPS REBUILD. Rebuilding every dependency takes hours and would
|
||||
# rewrite artifacts that are currently working. This adds exactly the one missing package on
|
||||
# top of the existing image, and it is a Dockerfile rather than a `docker commit` so that what
|
||||
# was done is reviewable and repeatable instead of being an undocumented mutation.
|
||||
#
|
||||
# The flags below are copied from the project's own recipe (deps/Assimp/Assimp.cmake) plus the
|
||||
# standard superbuild arguments from orcaslicer_add_cmake_project (deps/CMakeLists.txt:158) and
|
||||
# DEP_CMAKE_OPTS (deps/deps-linux.cmake). Keep them in step with that recipe: this file is a
|
||||
# stand-in for the superbuild, not an independent opinion about how to build assimp.
|
||||
#
|
||||
# BUILD (from the repo root, tarball already in deps/DL_CACHE/Assimp/):
|
||||
# docker build -f scripts/Dockerfile.deps-assimp -t orcacad-deps .
|
||||
#
|
||||
# VERIFY:
|
||||
# docker run --rm orcacad-deps sh -c \
|
||||
# 'ls /OrcaSlicer/deps/build/destdir/usr/local/lib/cmake/assimp*'
|
||||
# scripts/CAD/run-kernel-tests.sh
|
||||
#
|
||||
FROM orcacad-deps
|
||||
|
||||
# v5.4.3 is the version deps/Assimp/Assimp.cmake selects for CMake >= 3.22 (the image has
|
||||
# 3.28.3). The SHA256 is that recipe's URL_HASH, verified against the cached tarball before
|
||||
# this file was written -- an unverified archive is not a dependency, it is whatever was
|
||||
# sitting in the cache.
|
||||
ARG ASSIMP_SHA256=66dfbaee288f2bc43172440a55d0235dfc7bf885dda6435c038e8000e79582cb
|
||||
COPY deps/DL_CACHE/Assimp/v5.4.3.tar.gz /tmp/assimp.tar.gz
|
||||
|
||||
RUN set -eux; \
|
||||
echo "${ASSIMP_SHA256} /tmp/assimp.tar.gz" | sha256sum -c -; \
|
||||
mkdir -p /tmp/assimp-src && \
|
||||
tar -xzf /tmp/assimp.tar.gz -C /tmp/assimp-src --strip-components=1; \
|
||||
DESTDIR=/OrcaSlicer/deps/build/destdir/usr/local; \
|
||||
cmake -S /tmp/assimp-src -B /tmp/assimp-build -G Ninja \
|
||||
-DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX="$DESTDIR" \
|
||||
-DCMAKE_PREFIX_PATH="$DESTDIR" \
|
||||
-DCMAKE_POSITION_INDEPENDENT_CODE=ON \
|
||||
-DBUILD_SHARED_LIBS=OFF \
|
||||
-DASSIMP_BUILD_USE_CCACHE=OFF \
|
||||
-DASSIMP_BUILD_TESTS=OFF \
|
||||
-DASSIMP_BUILD_SAMPLES=OFF \
|
||||
-DASSIMP_BUILD_ASSIMP_TOOLS=OFF \
|
||||
-DASSIMP_INSTALL_PDB=OFF \
|
||||
-DASSIMP_NO_EXPORT=ON \
|
||||
-DASSIMP_BUILD_ALL_IMPORTERS_BY_DEFAULT=OFF \
|
||||
-DASSIMP_BUILD_GLTF_IMPORTER=ON \
|
||||
-DASSIMP_BUILD_OBJ_IMPORTER=ON \
|
||||
-DASSIMP_BUILD_FBX_IMPORTER=ON \
|
||||
-DASSIMP_BUILD_ZLIB=ON \
|
||||
-DASSIMP_WARNINGS_AS_ERRORS=OFF \
|
||||
-DBUILD_WITH_STATIC_CRT=OFF; \
|
||||
cmake --build /tmp/assimp-build --target install -- -j"$(nproc)"; \
|
||||
rm -rf /tmp/assimp-src /tmp/assimp-build /tmp/assimp.tar.gz; \
|
||||
test -n "$(ls "$DESTDIR"/lib/cmake/assimp* 2>/dev/null)"
|
||||
@@ -1,258 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Assign deterministic, globally-unique setting_id to OrcaSlicer system profiles.
|
||||
|
||||
Policy (see AGENTS.md "Critical Constraints"):
|
||||
* A preset's setting_id is a pure function of its identity:
|
||||
setting_id = base62_16( uuid5(NAMESPACE, "<vendor>/<type>/<name>") )
|
||||
The same value is recomputed on the fly by the C++ app
|
||||
(Slic3r::generate_preset_setting_id); the two MUST stay byte-identical. The rule
|
||||
(generate_preset_setting_id, below) is also imported by the validator
|
||||
(orca_extra_profile_check.py). Uniqueness is therefore automatic: two presets
|
||||
collide only if they share vendor + type + name, which the validator flags.
|
||||
* Bambu (BBL) owns the authoritative "G*" id space and is the only reserved vendor:
|
||||
its ids are never rewritten (preserves backward-compat with Bambu-synced presets).
|
||||
Every other vendor - including OrcaFilamentLibrary and Custom - follows the
|
||||
deterministic rule.
|
||||
* Only instantiated presets (instantiation == "true") carry a setting_id; base /
|
||||
template profiles do not.
|
||||
|
||||
Only setting_id is rewritten. filament_id is deliberately left untouched: it is a
|
||||
per-material id, shared across a filament's nozzle variants and inherited from base
|
||||
templates, so it must not be made per-file unique.
|
||||
|
||||
Run from anywhere: python3 scripts/assign_vendor_setting_ids.py
|
||||
The script is idempotent: a second run over an unchanged tree produces no diff.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
|
||||
# Deterministic preset setting_id rule. Imported by the validator
|
||||
# (orca_extra_profile_check.py) and kept byte-identical to the C++
|
||||
# Slic3r::generate_preset_setting_id. Dedicated namespace, distinct from the cloud
|
||||
# namespace (f47ac10b-...) so the two id spaces never coincide; this constant is baked
|
||||
# into both languages - never change it.
|
||||
NAMESPACE = uuid.UUID("c1f4d9e2-7a3b-5c8d-9e0f-1a2b3c4d5e6f")
|
||||
ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
ID_LENGTH = 16
|
||||
|
||||
|
||||
def generate_preset_setting_id(vendor, type_name, name):
|
||||
"""Deterministic 16-char base62 setting_id for a preset.
|
||||
|
||||
input = f"{vendor}/{type_name}/{name}"; u = uuid5(NAMESPACE, input);
|
||||
id = the low ID_LENGTH base62 digits of int(u.bytes, "big"), most-significant first.
|
||||
"""
|
||||
u = uuid.uuid5(NAMESPACE, f"{vendor}/{type_name}/{name}")
|
||||
n = int.from_bytes(u.bytes, "big")
|
||||
digits = []
|
||||
for _ in range(ID_LENGTH):
|
||||
digits.append(ALPHABET[n % 62])
|
||||
n //= 62
|
||||
return "".join(reversed(digits))
|
||||
|
||||
|
||||
PROFILES_DIR = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "resources", "profiles"))
|
||||
|
||||
# Bambu (BBL) is the only reserved vendor: it keeps its authoritative "G*" cloud ids.
|
||||
RESERVED_VENDORS = {"BBL"}
|
||||
|
||||
PROFILE_SUBDIRS = ("filament", "process", "machine")
|
||||
|
||||
|
||||
def iter_profile_files(vendor_dir):
|
||||
"""Yield (json path, type) under a vendor, in a deterministic order.
|
||||
|
||||
type is the subdir name ("filament"/"process"/"machine"), which matches
|
||||
Preset::get_type_string() on the C++ side.
|
||||
"""
|
||||
for sub in PROFILE_SUBDIRS:
|
||||
base = os.path.join(vendor_dir, sub)
|
||||
if not os.path.isdir(base):
|
||||
continue
|
||||
for root, dirs, files in os.walk(base):
|
||||
dirs.sort() # deterministic traversal across filesystems
|
||||
for name in sorted(files):
|
||||
if name.endswith(".json"):
|
||||
yield os.path.join(root, name), sub
|
||||
|
||||
|
||||
def read_profile(path):
|
||||
"""Return (setting_id, instantiation, name) as present (or None)."""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
data = json.loads(f.read())
|
||||
except (ValueError, OSError):
|
||||
return None, None, None
|
||||
if not isinstance(data, dict):
|
||||
return None, None, None
|
||||
return data.get("setting_id"), data.get("instantiation"), data.get("name")
|
||||
|
||||
|
||||
def list_vendors():
|
||||
return sorted(
|
||||
d for d in os.listdir(PROFILES_DIR)
|
||||
if os.path.isdir(os.path.join(PROFILES_DIR, d))
|
||||
)
|
||||
|
||||
|
||||
_JSON_STR = r'"(?:[^"\\]|\\.)*"'
|
||||
|
||||
|
||||
def remove_key_line(text, key):
|
||||
"""Remove a top-level `"key": "..."` member, preserving formatting.
|
||||
|
||||
Handles both the common case (member has a trailing comma) and the member
|
||||
being the LAST in its object (consume the preceding comma instead, so no
|
||||
dangling comma is left). Returns (new_text, count).
|
||||
"""
|
||||
# Member followed by a comma (not the last in the object).
|
||||
trailing = re.compile(
|
||||
r'[ \t]*"' + re.escape(key) + r'"[ \t]*:[ \t]*' + _JSON_STR + r'[ \t]*,[ \t]*\r?\n'
|
||||
)
|
||||
new, n = trailing.subn("", text, count=1)
|
||||
if n:
|
||||
return new, n
|
||||
# Member is the last one: drop the preceding comma and the member itself.
|
||||
leading = re.compile(
|
||||
r',[ \t]*\r?\n[ \t]*"' + re.escape(key) + r'"[ \t]*:[ \t]*' + _JSON_STR
|
||||
)
|
||||
return leading.subn("", text, count=1)
|
||||
|
||||
|
||||
def _remove_key_in_tree(key, should_remove):
|
||||
"""Remove `key` from files where should_remove(sid, inst, text) is True."""
|
||||
removed = 0
|
||||
for vendor in list_vendors():
|
||||
for path, _type in iter_profile_files(os.path.join(PROFILES_DIR, vendor)):
|
||||
with open(path, "rb") as f:
|
||||
text = f.read().decode("utf-8")
|
||||
sid, inst, _name = read_profile(path)
|
||||
if not should_remove(sid, inst, text):
|
||||
continue
|
||||
new_text, n = remove_key_line(text, key)
|
||||
if n == 0:
|
||||
raise RuntimeError(f"Could not locate {key} line to remove: {path}")
|
||||
json.loads(new_text) # fail loudly if removal broke the JSON
|
||||
with open(path, "wb") as f:
|
||||
f.write(new_text.encode("utf-8"))
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
|
||||
def remove_misspelled_settings_id():
|
||||
"""Delete the misspelled "settings_id" key (extra "s") wherever it appears.
|
||||
|
||||
The app never reads that key, so those presets effectively had no setting_id
|
||||
and get a correct one assigned by the normal pass; here we drop the junk key.
|
||||
"""
|
||||
return _remove_key_in_tree(
|
||||
"settings_id", lambda sid, inst, text: '"settings_id"' in text
|
||||
)
|
||||
|
||||
|
||||
def strip_base_setting_ids():
|
||||
"""Remove setting_id from every base profile (instantiation != "true").
|
||||
|
||||
Convention: only instantiated, user-selectable presets carry a setting_id;
|
||||
base/template profiles do not. Applied across all vendors.
|
||||
"""
|
||||
return _remove_key_in_tree(
|
||||
"setting_id", lambda sid, inst, text: bool(sid) and inst != "true"
|
||||
)
|
||||
|
||||
|
||||
def replace_id_value(text, key, new_value):
|
||||
"""Replace the first top-level `"key": "..."` value, preserving all formatting."""
|
||||
pattern = re.compile(r'("' + re.escape(key) + r'"\s*:\s*)"(?:[^"\\]|\\.)*"')
|
||||
repl = lambda m: m.group(1) + json.dumps(new_value, ensure_ascii=False)
|
||||
new_text, n = pattern.subn(repl, text, count=1)
|
||||
return new_text, n
|
||||
|
||||
|
||||
def insert_setting_id(text, new_id):
|
||||
"""Insert a `"setting_id"` line into a preset that lacks one.
|
||||
|
||||
Placed just before `filament_id` (or, failing that, `instantiation`) so it
|
||||
matches the canonical key order, reusing that anchor line's indentation and
|
||||
line ending. Only setting_id is added; filament_id is left untouched.
|
||||
"""
|
||||
for key in ("filament_id", "instantiation"):
|
||||
m = re.search(r'^([ \t]*)"' + key + r'"[ \t]*:.*?(\r?\n)', text, re.MULTILINE)
|
||||
if m:
|
||||
line = f'{m.group(1)}"setting_id": {json.dumps(new_id, ensure_ascii=False)},{m.group(2)}'
|
||||
return text[:m.start()] + line + text[m.start():], 1
|
||||
return text, 0
|
||||
|
||||
|
||||
def rewrite_file(path, new_id, has_setting_id):
|
||||
"""Set the preset's setting_id to new_id (replacing or inserting as needed).
|
||||
|
||||
filament_id is intentionally left untouched. Uses binary IO so the file's
|
||||
original line endings (LF or CRLF) and exact formatting are preserved
|
||||
byte-for-byte apart from the changed/added line. The result is re-parsed to
|
||||
guarantee it is still valid JSON.
|
||||
"""
|
||||
with open(path, "rb") as f:
|
||||
text = f.read().decode("utf-8")
|
||||
if has_setting_id:
|
||||
text, n = replace_id_value(text, "setting_id", new_id)
|
||||
else:
|
||||
text, n = insert_setting_id(text, new_id)
|
||||
if n == 0:
|
||||
raise RuntimeError(f"Could not set setting_id on {path}")
|
||||
json.loads(text) # fail loudly if the edit broke the JSON
|
||||
with open(path, "wb") as f:
|
||||
f.write(text.encode("utf-8"))
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
# 0. Drop the misspelled "settings_id" key wherever it appears.
|
||||
typos = remove_misspelled_settings_id()
|
||||
|
||||
# 1. Strip setting_id from base profiles everywhere (only instantiated presets keep one).
|
||||
stripped = strip_base_setting_ids()
|
||||
|
||||
# 2. Assign the deterministic setting_id to every instantiated preset of every
|
||||
# non-reserved vendor.
|
||||
changed = added = 0
|
||||
vendors_touched = []
|
||||
for vendor in list_vendors():
|
||||
if vendor in RESERVED_VENDORS:
|
||||
continue
|
||||
vendor_changed = 0
|
||||
for path, type_name in iter_profile_files(os.path.join(PROFILES_DIR, vendor)):
|
||||
sid, inst, name = read_profile(path)
|
||||
if inst != "true":
|
||||
continue
|
||||
if not name:
|
||||
raise RuntimeError(f"instantiated preset has no \"name\": {path}")
|
||||
new_id = generate_preset_setting_id(vendor, type_name, name)
|
||||
if sid == new_id:
|
||||
continue # already correct - idempotent
|
||||
rewrite_file(path, new_id, has_setting_id=sid is not None)
|
||||
changed += 1
|
||||
vendor_changed += 1
|
||||
if sid is None:
|
||||
added += 1
|
||||
if vendor_changed:
|
||||
vendors_touched.append((vendor, vendor_changed))
|
||||
|
||||
print(f"Misspelled settings_id removed : {typos}")
|
||||
print(f"Base setting_ids stripped : {stripped}")
|
||||
print(f"Reserved vendors : {sorted(RESERVED_VENDORS)}")
|
||||
print(f"Vendors updated : {len(vendors_touched)}")
|
||||
for v, n in vendors_touched:
|
||||
print(f" {v} ({n} files)")
|
||||
print(f"Files rewritten : {changed} (of which newly assigned: {added})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -122,6 +122,9 @@ sed "/name: OrcaSlicer/{
|
||||
\1 git_commit_hash: \"$GIT_COMMIT_HASH\"|
|
||||
}" > "$MANIFEST_DOCKER"
|
||||
|
||||
# ---------- pack deps/ ----------
|
||||
./scripts/flatpak/make_deps_tar.sh
|
||||
|
||||
# ---------- run build in Docker ----------
|
||||
DOCKER="${DOCKER:-docker}"
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
@echo off
|
||||
REM Runs check_profile.ps1, the Windows twin of check_profile.sh, from cmd.
|
||||
REM Arguments are passed straight through, so anything the .ps1 takes works here:
|
||||
REM scripts\check_profile.bat -Vendor Elegoo validate_custom
|
||||
REM -ExecutionPolicy Bypass is needed because a Windows client defaults to Restricted,
|
||||
REM which refuses to run a checked-out .ps1 at all.
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0check_profile.ps1" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
@@ -0,0 +1,693 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Local twin of the "Check profiles" CI job (.github/workflows/check_profiles.yml), for Windows.
|
||||
|
||||
.DESCRIPTION
|
||||
The Windows counterpart of scripts/check_profile.sh, and kept deliberately close to it.
|
||||
|
||||
Runs the same five checks, in the same order, with the same validator flags, and with the
|
||||
same semantics: every check runs even after an earlier one fails (the workflow's
|
||||
continue-on-error), then the script exits non-zero once at the end.
|
||||
|
||||
profile_tool scripts/orca_profile_tool.py check
|
||||
validate_system validator -p <profiles> -l <level>
|
||||
validate_slice validator -p <profiles> -s -l <level>
|
||||
validate_filament_subtypes validator -p <profiles> -l <level> -f
|
||||
validate_custom validator against every released custom-preset fixture
|
||||
|
||||
profile_tool is the only check that is not the validator binary; it makes the static checks
|
||||
the validator cannot, because the validator loads the tree the way the slicer does and so
|
||||
never sees a profile no <vendor>.json indexes, a preset name two files claim, or a file
|
||||
normalize and update-index would still rewrite.
|
||||
|
||||
Everything that has to be downloaded - the profile validator and the custom-preset fixture
|
||||
archives - lands under <repo>\.test\check_profiles and is reused on the next run. That
|
||||
directory also holds one log per check plus a copy of the comment CI would post on the PR.
|
||||
|
||||
resources\profiles\user, which the validator creates as its data dir but a CI checkout never
|
||||
has, is moved aside for the duration of the run and restored on exit. Only one run per work
|
||||
dir at a time.
|
||||
|
||||
-Vendor narrows a run to one vendor while working on that vendor's profiles - the one
|
||||
deliberate divergence from CI, which always checks the whole tree. A check that cannot be
|
||||
narrowed is left out of the run and reported as skipped.
|
||||
|
||||
x64 and ARM64 hosts are both supported. A locally built validator is chosen by the machine
|
||||
type in its PE header rather than by the name of its build tree, so build\ (x64) and
|
||||
build-arm64\ side by side resolve correctly; the published nightly is x64 only and runs
|
||||
under emulation on ARM64.
|
||||
|
||||
.PARAMETER ProfilesDir
|
||||
Profile tree to validate (default: resources\profiles).
|
||||
|
||||
.PARAMETER Vendor
|
||||
Check only this vendor, named after its <Vendor>.json (e.g. "Co Print"). validate_custom is
|
||||
narrowed with it too, by keeping only that vendor's presets in each fixture tree. The one
|
||||
check it cannot narrow is validate_slice for a vendor that ships no printers; the summary
|
||||
reports that one as skipped, and naming it explicitly still runs it. profile_tool keeps its
|
||||
two cross-vendor checks (setting_id and filament_id) tree-wide, so a scoped run can still
|
||||
fail on another vendor's files.
|
||||
|
||||
.PARAMETER Validator
|
||||
OrcaSlicer_profile_validator.exe to use; also $env:ORCA_PROFILE_VALIDATOR. Default: the local
|
||||
build*\ Release build (then RelWithDebInfo, MinSizeRel, Debug) for this architecture, else
|
||||
the nightly release build is downloaded.
|
||||
|
||||
.PARAMETER Download
|
||||
Ignore local builds and use the downloaded nightly validator.
|
||||
|
||||
.PARAMETER Refresh
|
||||
Re-download the validator and fixtures instead of using the cache.
|
||||
|
||||
.PARAMETER WorkDir
|
||||
Downloads, logs and fixture trees (default: .test\check_profiles). Point it somewhere short,
|
||||
such as D:\t, if a fixture tree trips Windows' 260-character path limit.
|
||||
|
||||
.PARAMETER LogLevel
|
||||
Validator log level (default: 2, as in CI).
|
||||
|
||||
.PARAMETER Checks
|
||||
Checks to run, by name (default: all of them, in the order listed above).
|
||||
|
||||
.EXAMPLE
|
||||
scripts\check_profile.bat
|
||||
|
||||
.EXAMPLE
|
||||
powershell -ExecutionPolicy Bypass -File scripts\check_profile.ps1 validate_system validate_slice
|
||||
|
||||
.EXAMPLE
|
||||
scripts\check_profile.bat -Vendor Elegoo
|
||||
#>
|
||||
|
||||
# PositionalBinding is off so that the check names are the only positional arguments; left on,
|
||||
# a bare "validate_system" would bind to whichever named parameter came next in this block.
|
||||
[CmdletBinding(PositionalBinding = $false)]
|
||||
param(
|
||||
[Alias('p')] [string] $ProfilesDir,
|
||||
[Alias('v')] [string] $Vendor,
|
||||
[string] $Validator,
|
||||
[string] $WorkDir,
|
||||
[Alias('l')] [int] $LogLevel = 2,
|
||||
[switch] $Download,
|
||||
[switch] $Refresh,
|
||||
[Alias('h')] [switch] $Help,
|
||||
[Parameter(Position = 0, ValueFromRemainingArguments = $true)] [string[]] $Checks
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Windows PowerShell 5.1 still negotiates TLS 1.0/1.1, which github.com refuses.
|
||||
[Net.ServicePointManager]::SecurityProtocol =
|
||||
[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
|
||||
$ValidatorReleaseUrl = 'https://github.com/OrcaSlicer/OrcaSlicer/releases/download/nightly-builds'
|
||||
$FixtureReleaseUrl = 'https://github.com/OrcaSlicer/OrcaSlicer-profile-validator/releases/download/fixture-archive'
|
||||
|
||||
$RepoRoot = Split-Path -Parent $PSScriptRoot
|
||||
|
||||
# PROCESSOR_ARCHITECTURE reports the architecture of the *shell*, so a 32-bit PowerShell on a
|
||||
# 64-bit OS says x86; ARCHITEW6432 is the machine's own in that case.
|
||||
$HostArch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
|
||||
$HostArch = switch ($HostArch) {
|
||||
'ARM64' { 'arm64' }
|
||||
'AMD64' { 'x64' }
|
||||
default { 'x86' }
|
||||
}
|
||||
|
||||
$AllChecks = @('profile_tool', 'validate_system', 'validate_slice', 'validate_filament_subtypes', 'validate_custom')
|
||||
|
||||
$script:LogWriter = $null
|
||||
$script:Python = ''
|
||||
|
||||
# ---------------------------------------------------------------------------- helpers
|
||||
|
||||
function Die([string] $Message) {
|
||||
Write-Host "check_profile.ps1: $Message" -ForegroundColor Red
|
||||
exit 2
|
||||
}
|
||||
|
||||
# UTF-8 without a BOM, so a log reads the same here as the artifact CI uploads.
|
||||
function New-LogWriter([string] $Path) {
|
||||
New-Object IO.StreamWriter($Path, $false, (New-Object Text.UTF8Encoding($false)))
|
||||
}
|
||||
|
||||
# Output of the check being run: shown, and kept in that check's log.
|
||||
function Write-CheckLog([string] $Text) {
|
||||
Write-Host $Text
|
||||
if ($script:LogWriter) { $script:LogWriter.WriteLine($Text) }
|
||||
}
|
||||
|
||||
# Runs a program and returns its exit code, streaming stdout and stderr into the current check's
|
||||
# log. -OutFile sends that output to a file of its own instead, silently.
|
||||
function Invoke-Tool {
|
||||
param([string] $Exe, [string[]] $Arguments, [string] $OutFile)
|
||||
|
||||
# Under 'Stop', 2>&1 turns every stderr line of a native command into a terminating error.
|
||||
# Assigning the preference here scopes it to this function, so it undoes itself on return.
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
$writer = if ($OutFile) { New-LogWriter $OutFile } else { $null }
|
||||
try {
|
||||
& $Exe @Arguments 2>&1 | ForEach-Object {
|
||||
if ($writer) { $writer.WriteLine("$_") } else { Write-CheckLog "$_" }
|
||||
}
|
||||
return $LASTEXITCODE
|
||||
} catch {
|
||||
if ($writer) { $writer.WriteLine("$_") } else { Write-CheckLog "$_" }
|
||||
return 1
|
||||
} finally {
|
||||
if ($writer) { $writer.Dispose() }
|
||||
}
|
||||
}
|
||||
|
||||
# The first $Limit characters of a log, as CI truncates them for the PR comment.
|
||||
function Get-LogHead([string] $Path, [int] $Limit) {
|
||||
$text = if (Test-Path -LiteralPath $Path) { [IO.File]::ReadAllText($Path) } else { '' }
|
||||
if (-not $text) { return 'No output captured' }
|
||||
if ($text.Length -gt $Limit) { return $text.Substring(0, $Limit) }
|
||||
return $text
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------- arguments
|
||||
|
||||
if ($Help) { Get-Help $PSCommandPath -Detailed; exit 0 }
|
||||
|
||||
foreach ($name in $Checks) {
|
||||
if ($name -like '-*') { Die "unknown option '$name' (try -Help)" }
|
||||
if ($AllChecks -notcontains $name) { Die "unknown check '$name' (try -Help)" }
|
||||
}
|
||||
# A check named on the command line always runs; only the default set is narrowed (see the run
|
||||
# loop below).
|
||||
$NamedChecks = [bool] $Checks
|
||||
if (-not $Checks) { $Checks = $AllChecks }
|
||||
|
||||
if (-not $ProfilesDir) { $ProfilesDir = Join-Path $RepoRoot 'resources\profiles' }
|
||||
if (-not (Test-Path -LiteralPath $ProfilesDir -PathType Container)) { Die "profile directory not found: $ProfilesDir" }
|
||||
$ProfilesDir = (Resolve-Path -LiteralPath $ProfilesDir).Path
|
||||
|
||||
# A vendor neither tool knows is not an error to them: the static checks load nothing of their own
|
||||
# and still report success, so a typo would otherwise be three green checks and one baffling slice
|
||||
# failure. The match has to be made on the names themselves rather than with a Test-Path - the
|
||||
# validator compares the <Vendor>.json stem case-sensitively, while Windows' case-insensitive
|
||||
# filesystem would let "creality" pass a path test and then match no vendor. A vendor is a
|
||||
# <name>.json with a sibling <name> directory; that pair is also what tells one apart from
|
||||
# blacklist.json, which sits in the same folder.
|
||||
if ($Vendor) {
|
||||
$found = $false
|
||||
$suggestion = ''
|
||||
foreach ($file in (Get-ChildItem -LiteralPath $ProfilesDir -Filter '*.json' -File)) {
|
||||
$name = [IO.Path]::GetFileNameWithoutExtension($file.Name)
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $ProfilesDir $name) -PathType Container)) { continue }
|
||||
if ($name -ceq $Vendor) { $found = $true; break }
|
||||
if ($name -eq $Vendor) { $suggestion = $name }
|
||||
}
|
||||
if (-not $found) {
|
||||
if ($suggestion) { Die "unknown vendor '$Vendor'; vendor names are case-sensitive, did you mean '$suggestion'?" }
|
||||
Die "unknown vendor '$Vendor': no such vendor in $ProfilesDir"
|
||||
}
|
||||
}
|
||||
|
||||
# The validator's -v and orca_profile_tool.py check's --vendor both take that stem; an unscoped
|
||||
# run passes neither, so the checks below splat these in either way.
|
||||
$VendorArgs = if ($Vendor) { @('-v', $Vendor) } else { @() }
|
||||
$VendorPyArgs = if ($Vendor) { @('--vendor', $Vendor) } else { @() }
|
||||
|
||||
if (-not $WorkDir) { $WorkDir = Join-Path $RepoRoot '.test\check_profiles' }
|
||||
$LogDir = Join-Path $WorkDir 'logs'
|
||||
try { New-Item -ItemType Directory -Force -Path $LogDir | Out-Null } catch { Die "cannot create ${LogDir}: $_" }
|
||||
$WorkDir = (Resolve-Path -LiteralPath $WorkDir).Path
|
||||
$LogDir = Join-Path $WorkDir 'logs'
|
||||
|
||||
if (-not $Validator) { $Validator = $env:ORCA_PROFILE_VALIDATOR }
|
||||
|
||||
# ------------------------------------------------------------------- clean profile tree
|
||||
|
||||
# The validator points its data dir at the profile tree, so it creates - and, with -g, fills -
|
||||
# <profiles>\user. A CI checkout never has that directory, and anything left in it from an
|
||||
# earlier local run would be loaded as user presets and validated too. Move it aside for the
|
||||
# duration of the run so what gets checked is what CI checks.
|
||||
$script:StashedUserDir = ''
|
||||
|
||||
function Push-UserPresets {
|
||||
$user = Join-Path $ProfilesDir 'user'
|
||||
if (-not (Test-Path -LiteralPath $user -PathType Container)) { return }
|
||||
$script:StashedUserDir = Join-Path $WorkDir "user-presets-$PID"
|
||||
Remove-Item -LiteralPath $script:StashedUserDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
try { Move-Item -LiteralPath $user -Destination $script:StashedUserDir }
|
||||
catch { $script:StashedUserDir = ''; Die "cannot move $user aside: $_" }
|
||||
Write-Host "moved $user aside for the run (restored on exit)"
|
||||
}
|
||||
|
||||
function Pop-UserPresets {
|
||||
# The validator leaves an empty user\default\{filament,machine,process} skeleton behind.
|
||||
# Prune it directory by directory, never wholesale: one that holds a real file survives and
|
||||
# is reported instead of being deleted. Runs even when nothing was stashed, so a tree that
|
||||
# had no user\ before the run does not gain one.
|
||||
$user = Join-Path $ProfilesDir 'user'
|
||||
Remove-EmptyDirs $user
|
||||
if (-not $script:StashedUserDir) { return }
|
||||
if (Test-Path -LiteralPath $user) {
|
||||
Write-Host "$user is not empty; your presets stay in $($script:StashedUserDir)"
|
||||
} else {
|
||||
Move-Item -LiteralPath $script:StashedUserDir -Destination $user
|
||||
}
|
||||
$script:StashedUserDir = ''
|
||||
}
|
||||
|
||||
function Remove-EmptyDirs([string] $Path) {
|
||||
if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return }
|
||||
Get-ChildItem -LiteralPath $Path -Recurse -Directory -Force |
|
||||
Sort-Object { $_.FullName.Length } -Descending |
|
||||
ForEach-Object {
|
||||
if (-not (Get-ChildItem -LiteralPath $_.FullName -Force)) {
|
||||
Remove-Item -LiteralPath $_.FullName -Force
|
||||
}
|
||||
}
|
||||
if (-not (Get-ChildItem -LiteralPath $Path -Force)) { Remove-Item -LiteralPath $Path -Force }
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------- downloads
|
||||
|
||||
# Cached; -Refresh, or -Force, pulls a new copy. Release assets never change, so caching them is
|
||||
# safe; the fixture manifest is the index that grows with every release, and CI re-reads it on
|
||||
# every run.
|
||||
function Save-Asset {
|
||||
param([string] $Url, [string] $Dest, [switch] $Force)
|
||||
|
||||
if (-not $Refresh -and -not $Force -and (Test-Path -LiteralPath $Dest -PathType Leaf) -and
|
||||
(Get-Item -LiteralPath $Dest).Length -gt 0) {
|
||||
return
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $Dest) | Out-Null
|
||||
Write-Host "downloading $(Split-Path -Leaf $Dest) ..."
|
||||
|
||||
# Invoke-WebRequest spends most of a large download repainting its progress bar.
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
$part = "$Dest.part"
|
||||
for ($attempt = 1; ; $attempt++) {
|
||||
try {
|
||||
Invoke-WebRequest -Uri $Url -OutFile $part -UseBasicParsing
|
||||
break
|
||||
} catch {
|
||||
if ($attempt -ge 3) {
|
||||
Remove-Item -LiteralPath $part -Force -ErrorAction SilentlyContinue
|
||||
throw "download failed: $Url"
|
||||
}
|
||||
}
|
||||
}
|
||||
Move-Item -LiteralPath $part -Destination $Dest -Force
|
||||
}
|
||||
|
||||
function Get-Sha256([string] $Path) {
|
||||
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash
|
||||
}
|
||||
|
||||
# Directories a built validator can sit in, best first: build_win.bat names its trees build,
|
||||
# build-dbginfo, build-minsize and build-dbg, each optionally -clang and -arm64 suffixed, and
|
||||
# CMake puts the binary in src\<config>. Each pattern is matched both directly under a build root
|
||||
# and one level down (build\x64), for trees laid out the way the macOS ones are.
|
||||
function Get-ValidatorSearchDirs {
|
||||
foreach ($config in 'Release', 'RelWithDebInfo', 'MinSizeRel', 'Debug') {
|
||||
Join-Path $RepoRoot "build*\src\$config"
|
||||
Join-Path $RepoRoot "build*\*\src\$config"
|
||||
}
|
||||
Join-Path $RepoRoot 'build*\src'
|
||||
Join-Path $RepoRoot 'build*\*\src'
|
||||
}
|
||||
|
||||
# The machine type from the PE header, which is what actually decides whether an .exe can run
|
||||
# here - the name of the build tree only says what it was meant to be.
|
||||
function Get-ExeArch([string] $Path) {
|
||||
try {
|
||||
$stream = [IO.File]::OpenRead($Path)
|
||||
try {
|
||||
$reader = New-Object IO.BinaryReader($stream)
|
||||
$stream.Position = 0x3C # e_lfanew: offset of the PE header
|
||||
$stream.Position = $reader.ReadInt32()
|
||||
if ($reader.ReadUInt32() -ne 0x00004550) { return '' } # "PE\0\0"
|
||||
switch ($reader.ReadUInt16()) {
|
||||
0x8664 { 'x64' }
|
||||
0xAA64 { 'arm64' }
|
||||
0x014C { 'x86' }
|
||||
default { '' }
|
||||
}
|
||||
} finally { $stream.Dispose() }
|
||||
} catch { '' }
|
||||
}
|
||||
|
||||
# First locally built OrcaSlicer_profile_validator.exe, in the order above. An x86 build, and on
|
||||
# ARM64 an x64 one, is kept only as a fallback: it runs, but emulated. An ARM64 build on an x64
|
||||
# host does not run at all and is never offered.
|
||||
function Find-LocalValidator {
|
||||
$emulated = ''
|
||||
foreach ($pattern in (Get-ValidatorSearchDirs)) {
|
||||
foreach ($dir in @(Resolve-Path -Path $pattern -ErrorAction SilentlyContinue)) {
|
||||
$candidate = Join-Path $dir.Path 'OrcaSlicer_profile_validator.exe'
|
||||
if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue }
|
||||
$arch = Get-ExeArch $candidate
|
||||
if (-not $arch -or $arch -eq $HostArch) { return $candidate }
|
||||
if (-not $emulated -and ($arch -eq 'x86' -or $HostArch -eq 'arm64')) { $emulated = $candidate }
|
||||
}
|
||||
}
|
||||
if ($emulated) { Write-Host "no $HostArch build found, falling back to $emulated (emulated)" }
|
||||
return $emulated
|
||||
}
|
||||
|
||||
# The nightly release build, same one CI uses. Windows ships the bare .exe, x64 only.
|
||||
function Save-NightlyValidator {
|
||||
if ($HostArch -ne 'x64') {
|
||||
Write-Host "the nightly Windows validator is x64; it runs here under emulation"
|
||||
}
|
||||
$exe = Join-Path $WorkDir 'validator\OrcaSlicer_profile_validator.exe'
|
||||
Save-Asset -Url "$ValidatorReleaseUrl/OrcaSlicer_profile_validator_Windows_nightly.exe" -Dest $exe
|
||||
return $exe
|
||||
}
|
||||
|
||||
function Resolve-Validator {
|
||||
if ($Validator) {
|
||||
if (-not (Test-Path -LiteralPath $Validator -PathType Leaf)) { Die "validator not found: $Validator" }
|
||||
return (Resolve-Path -LiteralPath $Validator).Path
|
||||
}
|
||||
if (-not $Download) {
|
||||
$local = Find-LocalValidator
|
||||
if ($local) {
|
||||
Write-Host "using locally built validator: $local"
|
||||
return $local
|
||||
}
|
||||
}
|
||||
try { $downloaded = Save-NightlyValidator } catch { Die "could not obtain a profile validator: $_" }
|
||||
Write-Host "using downloaded validator: $downloaded"
|
||||
return $downloaded
|
||||
}
|
||||
|
||||
# python3 is rarely on PATH on Windows: the py launcher is the reliable way in, and a bare
|
||||
# `python` may be the Store stub, which prints an advert and exits non-zero. Probe each for the
|
||||
# interpreter it actually resolves to, and use that.
|
||||
function Resolve-Python {
|
||||
$ErrorActionPreference = 'Continue'
|
||||
if ($script:Python) { return $script:Python }
|
||||
foreach ($candidate in 'py -3', 'python', 'python3') {
|
||||
$words = $candidate -split ' '
|
||||
$exe = Get-Command $words[0] -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if (-not $exe) { continue }
|
||||
$leading = @($words | Select-Object -Skip 1)
|
||||
$found = & $exe.Source @leading -c 'import sys; print(sys.executable)' 2>$null
|
||||
if ($LASTEXITCODE -eq 0 -and $found) {
|
||||
$script:Python = "$found"
|
||||
return $script:Python
|
||||
}
|
||||
}
|
||||
Die 'no Python 3 found; install it (or the py launcher) and re-run'
|
||||
}
|
||||
|
||||
# The fixtures name every preset "<vendor>_<parent preset>_orca_test", after the "name" inside
|
||||
# <Vendor>.json rather than the file stem -Vendor takes - BBL.json is "Bambulab", iQ.json is
|
||||
# "innovatiQ". Falls back to the stem for a vendor file with no readable name.
|
||||
function Get-VendorDisplayName {
|
||||
$name = ''
|
||||
try { $name = (Get-Content -LiteralPath (Join-Path $ProfilesDir "$Vendor.json") -Raw -ErrorAction Stop | ConvertFrom-Json).name } catch { }
|
||||
if ($name) { return "$name" }
|
||||
return $Vendor
|
||||
}
|
||||
|
||||
# Unpack just the presets one vendor's profiles own, and return how many that was. Each fixture
|
||||
# preset was generated from a single system preset and inherits it by name - none inherits another
|
||||
# user preset - so the selection is self-contained.
|
||||
function Expand-VendorPresets([string] $Zip, [string] $Tree, [string] $Prefix) {
|
||||
# .NET rather than Expand-Archive: a fixture holds around 20,000 entries, and unpacking every
|
||||
# one of them to keep a twentieth costs more than the validation this is speeding up.
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$kept = 0
|
||||
$archive = [IO.Compression.ZipFile]::OpenRead($Zip)
|
||||
try {
|
||||
foreach ($entry in $archive.Entries) {
|
||||
# A directory entry has an empty Name, so it never matches and is never created.
|
||||
if (-not $entry.Name.StartsWith($Prefix, [StringComparison]::Ordinal)) { continue }
|
||||
$dest = Join-Path $Tree $entry.FullName.Replace('/', [IO.Path]::DirectorySeparatorChar)
|
||||
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($dest)) | Out-Null
|
||||
[IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $dest, $true)
|
||||
$kept++
|
||||
}
|
||||
} finally {
|
||||
$archive.Dispose()
|
||||
}
|
||||
return $kept
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------- checks
|
||||
|
||||
$CheckBodies = @{
|
||||
|
||||
profile_tool = {
|
||||
Invoke-Tool -Exe (Resolve-Python) -Arguments (@((Join-Path $RepoRoot 'scripts\orca_profile_tool.py'), 'check', '--profiles', $ProfilesDir) + $VendorPyArgs)
|
||||
}
|
||||
|
||||
validate_system = {
|
||||
Invoke-Tool -Exe $Validator -Arguments (@('-p', $ProfilesDir) + $VendorArgs + @('-l', "$LogLevel"))
|
||||
}
|
||||
|
||||
# Slices a two-colour cube through every printer so all custom g-code (incl.
|
||||
# change_filament_gcode) is expanded - catches undefined-placeholder / invalid-flow bugs the
|
||||
# static checks cannot see.
|
||||
validate_slice = {
|
||||
Invoke-Tool -Exe $Validator -Arguments (@('-p', $ProfilesDir) + $VendorArgs + @('-s', '-l', "$LogLevel"))
|
||||
}
|
||||
|
||||
validate_filament_subtypes = {
|
||||
Invoke-Tool -Exe $Validator -Arguments (@('-p', $ProfilesDir) + $VendorArgs + @('-l', "$LogLevel", '-f'))
|
||||
}
|
||||
|
||||
# Every released fixture is a snapshot of user presets saved by that OrcaSlicer version; each
|
||||
# is unpacked over the current system profiles and validated, so a profile change that would
|
||||
# break an existing user's presets fails here.
|
||||
#
|
||||
# Under -Vendor only that vendor's presets are unpacked, which is what makes the validator's
|
||||
# -v usable here: -v filters the system vendors but never the user presets, so on a whole-tree
|
||||
# snapshot it reports every other vendor's presets as unresolvable parents - thousands of
|
||||
# errors saying nothing about the vendor under test. Teaching -v to filter user presets too is
|
||||
# the deeper fix, but this runs against whatever validator is to hand, the published nightly
|
||||
# included, so the picking has to happen here. It picks on the "<vendor display name>_"
|
||||
# filename prefix that generate_custom_presets() (the validator's -g mode) gave every preset in
|
||||
# these fixtures; a fixture cut from a renamed generator would surface as the "checked nothing"
|
||||
# warning below. Presets whose source had no vendor carry no prefix - a few of those still name
|
||||
# one in the middle, like "PET @BBL A1_orca_test" - and only an unscoped run covers them.
|
||||
validate_custom = {
|
||||
$fixturesDir = Join-Path $WorkDir 'profile-fixtures'
|
||||
$outputDir = Join-Path $WorkDir 'custom-preset-validation'
|
||||
New-Item -ItemType Directory -Force -Path $fixturesDir, $outputDir | Out-Null
|
||||
|
||||
$manifestPath = Join-Path $fixturesDir 'manifest.json'
|
||||
Save-Asset -Url "$FixtureReleaseUrl/manifest.json" -Dest $manifestPath -Force
|
||||
|
||||
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
|
||||
$entries = if ($manifest -is [array]) { $manifest } else { $manifest.fixtures }
|
||||
$fixtures = @($entries | Where-Object { $_.version -and $_.asset })
|
||||
if (-not $fixtures) {
|
||||
Write-CheckLog "No custom preset fixtures found in $FixtureReleaseUrl/manifest.json"
|
||||
return 1
|
||||
}
|
||||
|
||||
$vendorPrefix = if ($Vendor) { "$(Get-VendorDisplayName)_" } else { '' }
|
||||
$totalKept = 0
|
||||
$status = 0
|
||||
$failedLogs = @()
|
||||
$summary = @('## Custom Preset Fixture Validation', '', '| Version | Status | Log |', '| --- | --- | --- |')
|
||||
|
||||
foreach ($fixture in $fixtures) {
|
||||
$version = $fixture.version
|
||||
$asset = $fixture.asset
|
||||
$fixtureZip = Join-Path $fixturesDir $asset
|
||||
$profileTree = Join-Path $outputDir "profiles-$version"
|
||||
$logPath = Join-Path $outputDir "$version.log"
|
||||
$assetUrl = "$FixtureReleaseUrl/$([uri]::EscapeDataString($asset))"
|
||||
|
||||
Save-Asset -Url $assetUrl -Dest $fixtureZip
|
||||
|
||||
$expected = $fixture.asset_sha256
|
||||
if ($expected -and $expected -ne '<sha256>' -and (Get-Sha256 $fixtureZip) -ne $expected.ToUpperInvariant()) {
|
||||
# A cached zip can be stale or truncated; the release asset itself is immutable,
|
||||
# so deleting it forces Save-Asset to pull a fresh copy.
|
||||
Write-Host "checksum mismatch for $asset, re-downloading"
|
||||
Remove-Item -LiteralPath $fixtureZip -Force
|
||||
Save-Asset -Url $assetUrl -Dest $fixtureZip
|
||||
$actual = Get-Sha256 $fixtureZip
|
||||
if ($actual -ne $expected.ToUpperInvariant()) {
|
||||
Write-CheckLog "${asset}: expected $expected, got $actual"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "validating custom presets from $version ..."
|
||||
Remove-Item -LiteralPath $profileTree -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $profileTree | Out-Null
|
||||
# Piped rather than copied through <profiles>\*, so a vendor directory whose name
|
||||
# holds a wildcard character is still copied by its literal path. Under -Vendor the
|
||||
# validator opens only that vendor and OrcaFilamentLibrary and skips the other sixty-odd
|
||||
# (PresetBundle::load_system_presets_from_json), so the rest are left out of the copy:
|
||||
# ~3s a fixture that was going on files nothing opens.
|
||||
Get-ChildItem -LiteralPath $ProfilesDir -Force |
|
||||
Where-Object { -not $Vendor -or -not $_.PSIsContainer -or $_.Name -eq $Vendor -or $_.Name -eq 'OrcaFilamentLibrary' } |
|
||||
Copy-Item -Destination $profileTree -Recurse -Force
|
||||
Remove-Item -LiteralPath (Join-Path $profileTree 'user') -Recurse -Force -ErrorAction SilentlyContinue
|
||||
if ($Vendor) {
|
||||
$kept = Expand-VendorPresets $fixtureZip $profileTree $vendorPrefix
|
||||
$totalKept += $kept
|
||||
Write-CheckLog " $kept $Vendor preset file(s)"
|
||||
} else {
|
||||
Expand-Archive -LiteralPath $fixtureZip -DestinationPath $profileTree -Force
|
||||
}
|
||||
|
||||
$result = Invoke-Tool -Exe $Validator -Arguments (@('-p', $profileTree) + $VendorArgs + @('-l', "$LogLevel")) -OutFile $logPath
|
||||
if ($result -eq 0) {
|
||||
$summary += "| $version | PASS | $version.log |"
|
||||
# Only failures are worth keeping; each tree is a full copy of resources\profiles.
|
||||
Remove-Item -LiteralPath $profileTree -Recurse -Force
|
||||
} else {
|
||||
$summary += "| $version | FAIL | $version.log |"
|
||||
$failedLogs += $logPath
|
||||
$status = 1
|
||||
}
|
||||
}
|
||||
|
||||
# A vendor added after the newest fixture was cut appears in none of them: nothing failed,
|
||||
# but nothing was checked either.
|
||||
if ($Vendor -and $totalKept -eq 0) {
|
||||
Write-CheckLog "no $Vendor presets in any fixture; validate_custom checked nothing"
|
||||
}
|
||||
|
||||
[IO.File]::WriteAllLines((Join-Path $outputDir 'summary.md'), [string[]] $summary)
|
||||
$summary | ForEach-Object { Write-CheckLog $_ }
|
||||
|
||||
if ($failedLogs) {
|
||||
Write-CheckLog ''
|
||||
Write-CheckLog '## Failed Fixture Logs'
|
||||
foreach ($logPath in $failedLogs) {
|
||||
Write-CheckLog ''
|
||||
Write-CheckLog "### $([IO.Path]::GetFileNameWithoutExtension($logPath))"
|
||||
Write-CheckLog '```'
|
||||
Write-CheckLog (Get-LogHead $logPath 12000)
|
||||
Write-CheckLog '```'
|
||||
}
|
||||
}
|
||||
return $status
|
||||
}
|
||||
}
|
||||
|
||||
# Heading CI puts above this check's log in the PR comment.
|
||||
$CommentHeadings = @{
|
||||
profile_tool = '### Profile Check Failed (orca_profile_tool.py)'
|
||||
validate_system = '### System Profile Validation Failed'
|
||||
validate_slice = '### Slice Validation Failed (custom g-code expansion)'
|
||||
validate_filament_subtypes = '### Filament Subtype Validation Failed'
|
||||
validate_custom = '### Custom Preset Validation Failed'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------- run
|
||||
|
||||
function Invoke-Check([string] $Name) {
|
||||
$log = Join-Path $LogDir "$Name.log"
|
||||
Write-Host ''
|
||||
Write-Host "==> $Name$(if ($Vendor) { " ($Vendor)" })" -ForegroundColor Cyan
|
||||
|
||||
$script:LogWriter = New-LogWriter $log
|
||||
try {
|
||||
$result = & $CheckBodies[$Name] | Select-Object -Last 1
|
||||
} catch {
|
||||
Write-CheckLog "$_"
|
||||
$result = 1
|
||||
} finally {
|
||||
$script:LogWriter.Dispose()
|
||||
$script:LogWriter = $null
|
||||
}
|
||||
|
||||
if ([int] $result -eq 0) {
|
||||
Write-Host " $Name passed" -ForegroundColor Green
|
||||
return $true
|
||||
}
|
||||
Write-Host " $Name failed (exit $result)" -ForegroundColor Red
|
||||
return $false
|
||||
}
|
||||
|
||||
# The fixture trees under the work dir are shared scratch space keyed by fixture version, so a
|
||||
# second run would delete a tree the first one is validating.
|
||||
$LockDir = Join-Path $WorkDir '.lock'
|
||||
try { New-Item -ItemType Directory -Path $LockDir -ErrorAction Stop | Out-Null }
|
||||
catch { Die "another run is using $WorkDir (pass -WorkDir, or remove $LockDir if no run is active)" }
|
||||
|
||||
# The validator writes UTF-8, but PowerShell decodes a child process's output using the console
|
||||
# code page, which mangles the accented and CJK preset names in its messages.
|
||||
$PreviousOutputEncoding = [Console]::OutputEncoding
|
||||
|
||||
try {
|
||||
[Console]::OutputEncoding = New-Object Text.UTF8Encoding($false)
|
||||
Push-UserPresets
|
||||
|
||||
if ($Checks | Where-Object { $_ -ne 'profile_tool' }) { $Validator = Resolve-Validator }
|
||||
|
||||
# An empty printer set is a failure to the sweep, so validate_slice is recorded as skipped
|
||||
# rather than run for a vendor that ships no printers (the filament-only OrcaFilamentLibrary);
|
||||
# naming the check explicitly still runs it.
|
||||
$results = [ordered] @{}
|
||||
$skipReasons = @{}
|
||||
foreach ($name in $AllChecks) {
|
||||
if ($Checks -notcontains $name) { continue }
|
||||
if ($name -eq 'validate_slice' -and -not $NamedChecks -and $Vendor -and
|
||||
-not (Test-Path -LiteralPath (Join-Path (Join-Path $ProfilesDir $Vendor) 'machine') -PathType Container)) {
|
||||
$results[$name] = 'skip'
|
||||
$skipReasons[$name] = "$Vendor ships no printers"
|
||||
} else {
|
||||
$results[$name] = if (Invoke-Check $name) { 'pass' } else { 'fail' }
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '==> summary' -ForegroundColor Cyan
|
||||
foreach ($name in $results.Keys) {
|
||||
switch ($results[$name]) {
|
||||
'pass' { Write-Host " PASS $name" -ForegroundColor Green }
|
||||
'skip' { Write-Host " SKIP $name ($($skipReasons[$name]))" -ForegroundColor Yellow }
|
||||
default { Write-Host " FAIL $name ($(Join-Path $LogDir "$name.log"))" -ForegroundColor Red }
|
||||
}
|
||||
}
|
||||
|
||||
$failed = @($results.Keys | Where-Object { $results[$_] -eq 'fail' })
|
||||
if (-not $failed) {
|
||||
Remove-Item -LiteralPath (Join-Path $WorkDir 'pr_comment.md') -Force -ErrorAction SilentlyContinue
|
||||
Write-Host ''
|
||||
if (@($results.Values) -contains 'skip') {
|
||||
Write-Host "Every check that ran passed, but CI runs the skipped ones too. Logs: $LogDir" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "All checks passed. Logs: $LogDir" -ForegroundColor Green
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
|
||||
# The comment check_profiles_comment.yml would post when something fails.
|
||||
$comment = @(
|
||||
# Marker matched by check_profiles_comment.yml to delete prior comments.
|
||||
'<!-- profile-validation-comment -->'
|
||||
'## :x: Profile Validation Errors'
|
||||
''
|
||||
foreach ($name in $failed) {
|
||||
$CommentHeadings[$name]
|
||||
''
|
||||
'```'
|
||||
Get-LogHead (Join-Path $LogDir "$name.log") 30000
|
||||
'```'
|
||||
''
|
||||
}
|
||||
'---'
|
||||
'*Fix the errors above and push a new commit. To reproduce this run locally: `scripts/check_profile.sh`, or `scripts\check_profile.bat` on Windows.*'
|
||||
)
|
||||
$commentPath = Join-Path $WorkDir 'pr_comment.md'
|
||||
[IO.File]::WriteAllLines($commentPath, [string[]] $comment)
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "One or more profile checks failed. Logs: $LogDir" -ForegroundColor Red
|
||||
Write-Host "The comment CI would post: $commentPath"
|
||||
exit 1
|
||||
} finally {
|
||||
Pop-UserPresets
|
||||
Remove-Item -LiteralPath $LockDir -Force -ErrorAction SilentlyContinue
|
||||
[Console]::OutputEncoding = $PreviousOutputEncoding
|
||||
}
|
||||
Executable
+650
@@ -0,0 +1,650 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Local twin of the "Check profiles" CI job (.github/workflows/check_profiles.yml).
|
||||
#
|
||||
# Runs the same five checks, in the same order, with the same validator flags, and with the
|
||||
# same semantics: every check runs even after an earlier one fails (the workflow's
|
||||
# continue-on-error), then the script exits non-zero once at the end.
|
||||
#
|
||||
# Everything that has to be downloaded - the profile validator and the custom-preset fixture
|
||||
# archives - lands under <repo>/.test/check_profiles/ and is reused on the next run. That
|
||||
# directory also holds one log per check plus a copy of the comment CI would post on the PR.
|
||||
#
|
||||
# resources/profiles/user, which the validator creates as its data dir but a CI checkout never
|
||||
# has, is moved aside for the duration of the run and restored on exit. Only one run per work
|
||||
# dir at a time.
|
||||
#
|
||||
# -v/--vendor narrows a run to one vendor while working on that vendor's profiles - the one
|
||||
# deliberate divergence from CI, which always checks the whole tree. A check that cannot be
|
||||
# narrowed is left out of the run and reported as skipped; see usage().
|
||||
#
|
||||
# Usage: scripts/check_profile.sh [OPTION]... [CHECK]...
|
||||
|
||||
# The check_* functions run through run_check, which dispatches on the check name, so
|
||||
# ShellCheck cannot see that they (and what they call) are used.
|
||||
# shellcheck disable=SC2329
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
VALIDATOR_RELEASE_URL="https://github.com/OrcaSlicer/OrcaSlicer/releases/download/nightly-builds"
|
||||
FIXTURE_RELEASE_URL="https://github.com/OrcaSlicer/OrcaSlicer-profile-validator/releases/download/fixture-archive"
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
HOST_ARCH="$(uname -m)"
|
||||
|
||||
PROFILES_DIR="${REPO_ROOT}/resources/profiles"
|
||||
WORK_DIR="${REPO_ROOT}/.test/check_profiles"
|
||||
VALIDATOR="${ORCA_PROFILE_VALIDATOR:-}"
|
||||
# Vendor to check, named after its <Vendor>.json - empty means every vendor, which is exactly what
|
||||
# both the validator's -v and orca_profile_tool.py check's --vendor take an empty value to mean.
|
||||
# So the flag is passed unconditionally below rather than kept in an array bash 3.2 cannot expand
|
||||
# empty under `set -u`.
|
||||
VENDOR=""
|
||||
LOG_LEVEL=2
|
||||
PREFER_DOWNLOAD=0
|
||||
REFRESH=0
|
||||
|
||||
ALL_CHECKS=(profile_tool validate_system validate_slice validate_filament_subtypes validate_custom)
|
||||
CHECKS=()
|
||||
# "<check><TAB>pass|fail" per check that ran, plus "<check><TAB>skip<TAB>why" for one a vendor
|
||||
# scope left out; a string rather than an array because bash 3.2 (still the /bin/bash on macOS)
|
||||
# cannot expand an empty array under `set -u`.
|
||||
RESULTS=""
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Run the profile checks from .github/workflows/check_profiles.yml locally.
|
||||
|
||||
Usage: scripts/check_profile.sh [OPTION]... [CHECK]...
|
||||
|
||||
Checks (default: all, in this order):
|
||||
profile_tool scripts/orca_profile_tool.py check
|
||||
validate_system validator -p <profiles> -l <level>
|
||||
validate_slice validator -p <profiles> -s -l <level>
|
||||
validate_filament_subtypes validator -p <profiles> -l <level> -f
|
||||
validate_custom validator against every released custom-preset fixture
|
||||
|
||||
Options:
|
||||
-p, --profiles DIR profile tree to validate (default: resources/profiles)
|
||||
-v, --vendor NAME check only this vendor, named after its <Vendor>.json (e.g. "Co Print")
|
||||
--validator BIN OrcaSlicer_profile_validator to use; also \$ORCA_PROFILE_VALIDATOR.
|
||||
Default: the local build*/ Release build (then RelWithDebInfo, then
|
||||
Debug) for this architecture, else the nightly release build is
|
||||
downloaded for this platform
|
||||
--download ignore local builds and use the downloaded nightly validator
|
||||
--refresh re-download the validator and fixtures instead of using the cache
|
||||
--work-dir DIR downloads, logs and fixture trees (default: .test/check_profiles)
|
||||
-l, --log-level N validator log level (default: ${LOG_LEVEL}, as in CI)
|
||||
-h, --help show this help
|
||||
|
||||
Note: profile_tool is the only check that is not the validator binary; it makes the static
|
||||
checks the validator cannot, because the validator loads the tree the way the slicer does
|
||||
and so never sees a profile no <vendor>.json indexes, a preset name two files claim, or a
|
||||
file normalize and update-index would still rewrite.
|
||||
|
||||
Note: --vendor narrows validate_custom too, by keeping only that vendor's presets in each
|
||||
fixture tree. The one check it cannot narrow is validate_slice for a vendor that ships no
|
||||
printers; the summary reports that one as skipped, and naming it explicitly still runs it.
|
||||
profile_tool keeps its two cross-vendor checks (setting_id and filament_id) tree-wide, so a
|
||||
scoped run can still fail on another vendor's files.
|
||||
EOF
|
||||
}
|
||||
|
||||
msg() { printf '%s\n' "$*" >&2; }
|
||||
die() { printf 'check_profile.sh: %s\n' "$*" >&2; exit 2; }
|
||||
|
||||
if [ -t 1 ]; then
|
||||
C_RED=$'\033[91m'; C_GREEN=$'\033[92m'; C_BOLD=$'\033[1m'; C_RESET=$'\033[0m'
|
||||
else
|
||||
C_RED=''; C_GREEN=''; C_BOLD=''; C_RESET=''
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------- arguments
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
-p|--profiles) [ $# -ge 2 ] || die "$1 needs a directory"; PROFILES_DIR="$2"; shift 2 ;;
|
||||
-v|--vendor) [ $# -ge 2 ] || die "$1 needs a vendor name"; VENDOR="$2"; shift 2 ;;
|
||||
--validator) [ $# -ge 2 ] || die "$1 needs a path"; VALIDATOR="$2"; shift 2 ;;
|
||||
--work-dir) [ $# -ge 2 ] || die "$1 needs a directory"; WORK_DIR="$2"; shift 2 ;;
|
||||
-l|--log-level) [ $# -ge 2 ] || die "$1 needs a number"; LOG_LEVEL="$2"; shift 2 ;;
|
||||
--download) PREFER_DOWNLOAD=1; shift ;;
|
||||
--refresh) REFRESH=1; shift ;;
|
||||
-*) die "unknown option '$1' (try --help)" ;;
|
||||
*)
|
||||
known=0
|
||||
for check in "${ALL_CHECKS[@]}"; do
|
||||
[ "$1" = "${check}" ] && known=1
|
||||
done
|
||||
[ "${known}" -eq 1 ] || die "unknown check '$1' (try --help)"
|
||||
CHECKS[${#CHECKS[@]}]="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# A check named on the command line always runs; only the default set is narrowed (see the run
|
||||
# loop below).
|
||||
NAMED_CHECKS="${#CHECKS[@]}"
|
||||
[ "${NAMED_CHECKS}" -gt 0 ] || CHECKS=("${ALL_CHECKS[@]}")
|
||||
|
||||
[ -d "${PROFILES_DIR}" ] || die "profile directory not found: ${PROFILES_DIR}"
|
||||
PROFILES_DIR="$(cd -- "${PROFILES_DIR}" && pwd)"
|
||||
|
||||
# A vendor neither tool knows is not an error to them: the static checks load nothing of their own
|
||||
# and still report success, so a typo would otherwise be three green checks and one baffling slice
|
||||
# failure. The match has to be made on the names themselves rather than with a -f test - the
|
||||
# validator compares the <Vendor>.json stem case-sensitively, while a case-insensitive filesystem
|
||||
# (macOS, Windows) would let "creality" pass a file test and then match no vendor. A vendor is a
|
||||
# <name>.json with a sibling <name>/ directory; that pair is also what tells one apart from
|
||||
# blacklist.json, which sits in the same folder.
|
||||
if [ -n "${VENDOR}" ]; then
|
||||
wanted="$(printf '%s' "${VENDOR}" | tr '[:upper:]' '[:lower:]')"
|
||||
found=""
|
||||
suggestion=""
|
||||
for file in "${PROFILES_DIR}"/*.json; do
|
||||
name="${file##*/}"; name="${name%.json}"
|
||||
[ -d "${PROFILES_DIR}/${name}" ] || continue
|
||||
if [ "${name}" = "${VENDOR}" ]; then
|
||||
found=1
|
||||
break
|
||||
fi
|
||||
[ "$(printf '%s' "${name}" | tr '[:upper:]' '[:lower:]')" = "${wanted}" ] && suggestion="${name}"
|
||||
done
|
||||
if [ -z "${found}" ]; then
|
||||
[ -z "${suggestion}" ] || die "unknown vendor '${VENDOR}'; vendor names are case-sensitive, did you mean '${suggestion}'?"
|
||||
die "unknown vendor '${VENDOR}': no such vendor in ${PROFILES_DIR}"
|
||||
fi
|
||||
fi
|
||||
|
||||
LOG_DIR="${WORK_DIR}/logs"
|
||||
mkdir -p "${LOG_DIR}" || die "cannot create ${LOG_DIR}"
|
||||
WORK_DIR="$(cd -- "${WORK_DIR}" && pwd)"
|
||||
LOG_DIR="${WORK_DIR}/logs"
|
||||
|
||||
wants() {
|
||||
local check
|
||||
for check in "${CHECKS[@]}"; do
|
||||
[ "${check}" = "$1" ] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------- clean profile tree
|
||||
|
||||
# The validator points its data dir at the profile tree, so it creates - and, with -g, fills -
|
||||
# <profiles>/user. A CI checkout never has that directory, and anything left in it from an
|
||||
# earlier local run would be loaded as user presets and validated too. Move it aside for the
|
||||
# duration of the run so what gets checked is what CI checks.
|
||||
STASHED_USER_DIR=""
|
||||
|
||||
stash_user_presets() {
|
||||
[ -d "${PROFILES_DIR}/user" ] || return 0
|
||||
STASHED_USER_DIR="${WORK_DIR}/user-presets-$$"
|
||||
rm -rf "${STASHED_USER_DIR}"
|
||||
mv "${PROFILES_DIR}/user" "${STASHED_USER_DIR}" || { STASHED_USER_DIR=""; die "cannot move ${PROFILES_DIR}/user aside"; }
|
||||
msg "moved ${PROFILES_DIR}/user aside for the run (restored on exit)"
|
||||
}
|
||||
|
||||
restore_user_presets() {
|
||||
# The validator leaves an empty user/default/{filament,machine,process} skeleton behind.
|
||||
# Prune it with rmdir, never rm -rf: a directory that holds a real file survives and is
|
||||
# reported instead of being deleted. Runs even when nothing was stashed, so a tree that had
|
||||
# no user/ before the run does not gain one.
|
||||
find "${PROFILES_DIR}/user" -depth -type d -exec rmdir {} + 2>/dev/null
|
||||
[ -n "${STASHED_USER_DIR}" ] || return 0
|
||||
if [ -d "${PROFILES_DIR}/user" ]; then
|
||||
msg "${PROFILES_DIR}/user is not empty; your presets stay in ${STASHED_USER_DIR}"
|
||||
STASHED_USER_DIR=""
|
||||
return 0
|
||||
fi
|
||||
mv "${STASHED_USER_DIR}" "${PROFILES_DIR}/user"
|
||||
STASHED_USER_DIR=""
|
||||
}
|
||||
|
||||
# The fixture trees under the work dir are shared scratch space keyed by fixture version, so a
|
||||
# second run would delete a tree the first one is validating.
|
||||
LOCK_DIR="${WORK_DIR}/.lock"
|
||||
mkdir "${LOCK_DIR}" 2>/dev/null ||
|
||||
die "another run is using ${WORK_DIR} (pass --work-dir, or remove ${LOCK_DIR} if no run is active)"
|
||||
|
||||
cleanup() {
|
||||
restore_user_presets
|
||||
rmdir "${LOCK_DIR}" 2>/dev/null
|
||||
}
|
||||
|
||||
# Installed only once the lock is ours, so a refused start never releases someone else's.
|
||||
trap cleanup EXIT
|
||||
trap 'cleanup; exit 130' INT TERM
|
||||
|
||||
stash_user_presets
|
||||
|
||||
# ---------------------------------------------------------------------------- downloads
|
||||
|
||||
# fetch URL DEST [force] - cached; --refresh, or a non-empty third argument, forces a new
|
||||
# download. Release assets never change, so caching them is safe; the fixture manifest is the
|
||||
# index that grows with every release, and CI re-reads it on every run.
|
||||
fetch() {
|
||||
local url="$1" dest="$2" force="${3:-}"
|
||||
if [ "${REFRESH}" -eq 0 ] && [ -z "${force}" ] && [ -s "${dest}" ]; then
|
||||
return 0
|
||||
fi
|
||||
mkdir -p "$(dirname -- "${dest}")" || return 1
|
||||
msg "downloading $(basename -- "${dest}") ..."
|
||||
if ! curl -fsSL --retry 3 -o "${dest}.part" "${url}"; then
|
||||
rm -f "${dest}.part"
|
||||
msg "download failed: ${url}"
|
||||
return 1
|
||||
fi
|
||||
mv -f "${dest}.part" "${dest}"
|
||||
}
|
||||
|
||||
sha256_of() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$1" | cut -d' ' -f1
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 "$1" | cut -d' ' -f1
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Directories a built validator can sit in, best first: every Ninja Multi-Config Release tree,
|
||||
# then RelWithDebInfo, then Debug, then a single-config generator's plain src/. Each pattern is
|
||||
# matched both directly under a build root (build/) and one level down (build/arm64, build/x64),
|
||||
# and unmatched globs are dropped by the caller's -d test.
|
||||
validator_search_dirs() {
|
||||
local config
|
||||
for config in Release RelWithDebInfo Debug; do
|
||||
printf '%s\n' "${REPO_ROOT}"/build*/src/"${config}" "${REPO_ROOT}"/build*/*/src/"${config}"
|
||||
done
|
||||
printf '%s\n' "${REPO_ROOT}"/build*/src "${REPO_ROOT}"/build*/*/src
|
||||
}
|
||||
|
||||
# A build tree named for the other architecture (build/x86_64 on an arm64 host, build/arm64 on
|
||||
# an x64 one) is a last resort: on Linux it will not run at all, on macOS it goes via Rosetta.
|
||||
# Takes a repo-relative path - an absolute one would also match an arch name in the checkout path.
|
||||
is_foreign_arch_dir() {
|
||||
case "${HOST_ARCH}" in
|
||||
arm64|aarch64) case "$1" in *x86_64*|*x86-64*|*x64*|*amd64*) return 0 ;; esac ;;
|
||||
x86_64|amd64) case "$1" in *arm64*|*aarch64*) return 0 ;; esac ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# First locally built OrcaSlicer_profile_validator, in the order above. macOS puts it in an .app
|
||||
# bundle, Linux and Windows next to the other binaries.
|
||||
find_local_validator() {
|
||||
local dir candidate foreign=""
|
||||
while IFS= read -r dir; do
|
||||
[ -d "${dir}" ] || continue
|
||||
for candidate in \
|
||||
"${dir}/OrcaSlicer_profile_validator" \
|
||||
"${dir}/OrcaSlicer_profile_validator.exe" \
|
||||
"${dir}/OrcaSlicer_profile_validator.app/Contents/MacOS/OrcaSlicer_profile_validator"; do
|
||||
[ -f "${candidate}" ] && [ -x "${candidate}" ] || continue
|
||||
if ! is_foreign_arch_dir "${dir#"${REPO_ROOT}"/}"; then
|
||||
printf '%s\n' "${candidate}"
|
||||
return 0
|
||||
fi
|
||||
[ -n "${foreign}" ] || foreign="${candidate}"
|
||||
done
|
||||
done <<EOF
|
||||
$(validator_search_dirs)
|
||||
EOF
|
||||
[ -n "${foreign}" ] || return 1
|
||||
msg "no ${HOST_ARCH} build found, falling back to ${foreign}"
|
||||
printf '%s\n' "${foreign}"
|
||||
}
|
||||
|
||||
# The nightly release build, same one CI uses. Linux ships the bare binary, macOS a .dmg
|
||||
# holding the signed .app, Windows an .exe.
|
||||
download_validator() {
|
||||
local dest="${WORK_DIR}/validator" binary dmg app mounted app_src
|
||||
case "$(uname -s)" in
|
||||
Linux*)
|
||||
case "${HOST_ARCH}" in
|
||||
arm64|aarch64) msg "the nightly Linux validator is x86_64; build it locally for ${HOST_ARCH}" ;;
|
||||
esac
|
||||
binary="${dest}/OrcaSlicer_profile_validator"
|
||||
fetch "${VALIDATOR_RELEASE_URL}/OrcaSlicer_profile_validator_Linux_Ubuntu2404_nightly" "${binary}" || return 1
|
||||
chmod +x "${binary}" || return 1
|
||||
;;
|
||||
Darwin*)
|
||||
dmg="${dest}/OrcaSlicer_profile_validator.dmg"
|
||||
app="${dest}/OrcaSlicer_profile_validator.app"
|
||||
binary="${app}/Contents/MacOS/OrcaSlicer_profile_validator"
|
||||
fetch "${VALIDATOR_RELEASE_URL}/OrcaSlicer_profile_validator_Mac_universal_nightly.dmg" "${dmg}" || return 1
|
||||
if [ ! -x "${binary}" ] || [ "${REFRESH}" -eq 1 ]; then
|
||||
mounted="${dest}/mnt"
|
||||
rm -rf "${mounted}" "${app}"
|
||||
mkdir -p "${mounted}" || return 1
|
||||
hdiutil attach -nobrowse -readonly -mountpoint "${mounted}" "${dmg}" >/dev/null || return 1
|
||||
app_src="$(find "${mounted}" -maxdepth 1 -name '*.app' -print 2>/dev/null | head -n 1)"
|
||||
if [ -n "${app_src}" ]; then
|
||||
cp -R "${app_src}" "${app}"
|
||||
fi
|
||||
hdiutil detach "${mounted}" >/dev/null 2>&1
|
||||
rmdir "${mounted}" 2>/dev/null
|
||||
[ -x "${binary}" ] || { msg "no validator app inside ${dmg}"; return 1; }
|
||||
fi
|
||||
;;
|
||||
MINGW*|MSYS*|CYGWIN*)
|
||||
binary="${dest}/OrcaSlicer_profile_validator.exe"
|
||||
fetch "${VALIDATOR_RELEASE_URL}/OrcaSlicer_profile_validator_Windows_nightly.exe" "${binary}" || return 1
|
||||
chmod +x "${binary}" || return 1
|
||||
;;
|
||||
*)
|
||||
msg "no nightly validator published for $(uname -s); build it (-DORCA_TOOLS=ON) and pass --validator"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
printf '%s\n' "${binary}"
|
||||
}
|
||||
|
||||
resolve_validator() {
|
||||
if [ -n "${VALIDATOR}" ]; then
|
||||
[ -x "${VALIDATOR}" ] || die "validator not executable: ${VALIDATOR}"
|
||||
return 0
|
||||
fi
|
||||
if [ "${PREFER_DOWNLOAD}" -eq 0 ]; then
|
||||
VALIDATOR="$(find_local_validator)"
|
||||
if [ -n "${VALIDATOR}" ]; then
|
||||
msg "using locally built validator: ${VALIDATOR}"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
VALIDATOR="$(download_validator)" || die "could not obtain a profile validator"
|
||||
msg "using downloaded validator: ${VALIDATOR}"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------- checks
|
||||
|
||||
check_profile_tool() {
|
||||
python3 "${REPO_ROOT}/scripts/orca_profile_tool.py" check --profiles "${PROFILES_DIR}" --vendor "${VENDOR}"
|
||||
}
|
||||
|
||||
check_validate_system() {
|
||||
"${VALIDATOR}" -p "${PROFILES_DIR}" -v "${VENDOR}" -l "${LOG_LEVEL}"
|
||||
}
|
||||
|
||||
# Slices a two-colour cube through every printer so all custom g-code (incl. change_filament_gcode)
|
||||
# is expanded - catches undefined-placeholder / invalid-flow bugs the static checks cannot see.
|
||||
check_validate_slice() {
|
||||
"${VALIDATOR}" -p "${PROFILES_DIR}" -v "${VENDOR}" -s -l "${LOG_LEVEL}"
|
||||
}
|
||||
|
||||
check_validate_filament_subtypes() {
|
||||
"${VALIDATOR}" -p "${PROFILES_DIR}" -v "${VENDOR}" -l "${LOG_LEVEL}" -f
|
||||
}
|
||||
|
||||
# The fixtures name every preset "<vendor>_<parent preset>_orca_test", after the "name" inside
|
||||
# <Vendor>.json rather than the file stem --vendor takes - BBL.json is "Bambulab", iQ.json is
|
||||
# "innovatiQ". Falls back to the stem for a vendor file with no readable name.
|
||||
vendor_display_name() {
|
||||
local name
|
||||
name="$(VENDOR_JSON="${PROFILES_DIR}/${VENDOR}.json" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
try:
|
||||
with open(os.environ["VENDOR_JSON"], encoding="utf-8") as fh:
|
||||
print(json.load(fh).get("name", ""))
|
||||
except Exception:
|
||||
pass
|
||||
PY
|
||||
)"
|
||||
printf '%s\n' "${name:-${VENDOR}}"
|
||||
}
|
||||
|
||||
# Every released fixture is a snapshot of user presets saved by that OrcaSlicer version; each is
|
||||
# unpacked over the current system profiles and validated, so a profile change that would break
|
||||
# an existing user's presets fails here.
|
||||
#
|
||||
# Under --vendor only that vendor's presets are unpacked, which is what makes the validator's -v
|
||||
# usable here: -v filters the system vendors but never the user presets, so on a whole-tree
|
||||
# snapshot it reports every other vendor's presets as unresolvable parents - thousands of errors
|
||||
# saying nothing about the vendor under test. Teaching -v to filter user presets too is the deeper
|
||||
# fix, but this runs against whatever validator is to hand, the published nightly included, so the
|
||||
# picking has to happen here. It picks on the "<vendor display name>_" filename prefix that
|
||||
# generate_custom_presets() (the validator's -g mode) gave every preset in these fixtures; a
|
||||
# fixture cut from a renamed generator would surface as the "checked nothing" warning below.
|
||||
# Presets whose source had no vendor carry no prefix - a few of those still name one in the
|
||||
# middle, like "PET @BBL A1_orca_test" - and only an unscoped run covers them.
|
||||
check_validate_custom() {
|
||||
local fixtures_dir="${WORK_DIR}/profile-fixtures"
|
||||
local output_dir="${WORK_DIR}/custom-preset-validation"
|
||||
local summary="${output_dir}/summary.md"
|
||||
local status=0 failed_logs="" vendor_prefix="" total_kept=0
|
||||
local version asset expected_sha256 asset_url fixture_zip profile_tree log_path actual_sha256 result kept
|
||||
|
||||
command -v unzip >/dev/null 2>&1 || { msg "unzip is required for validate_custom"; return 1; }
|
||||
[ -z "${VENDOR}" ] || vendor_prefix="$(vendor_display_name)_"
|
||||
mkdir -p "${fixtures_dir}" "${output_dir}" || return 1
|
||||
|
||||
fetch "${FIXTURE_RELEASE_URL}/manifest.json" "${fixtures_dir}/manifest.json" force || return 1
|
||||
|
||||
MANIFEST_PATH="${fixtures_dir}/manifest.json" python3 - > "${fixtures_dir}/fixtures.tsv" <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
with open(os.environ["MANIFEST_PATH"], encoding="utf-8") as fh:
|
||||
manifest = json.load(fh)
|
||||
|
||||
if isinstance(manifest, dict):
|
||||
entries = manifest.get("fixtures", [])
|
||||
else:
|
||||
entries = manifest
|
||||
|
||||
for entry in entries:
|
||||
version = entry.get("version", "")
|
||||
asset = entry.get("asset", "")
|
||||
sha256 = entry.get("asset_sha256", "")
|
||||
if not version or not asset:
|
||||
continue
|
||||
print(f"{version}\t{asset}\t{sha256}")
|
||||
PY
|
||||
|
||||
if [ ! -s "${fixtures_dir}/fixtures.tsv" ]; then
|
||||
echo "No custom preset fixtures found in ${FIXTURE_RELEASE_URL}/manifest.json"
|
||||
return 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "## Custom Preset Fixture Validation"
|
||||
echo ""
|
||||
echo "| Version | Status | Log |"
|
||||
echo "| --- | --- | --- |"
|
||||
} > "${summary}"
|
||||
|
||||
while IFS=$'\t' read -r version asset expected_sha256; do
|
||||
[ -n "${version}" ] || continue
|
||||
fixture_zip="${fixtures_dir}/${asset}"
|
||||
profile_tree="${output_dir}/profiles-${version}"
|
||||
log_path="${output_dir}/${version}.log"
|
||||
|
||||
asset_url="${FIXTURE_RELEASE_URL}/$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "${asset}")"
|
||||
fetch "${asset_url}" "${fixture_zip}" || return 1
|
||||
|
||||
if [ -n "${expected_sha256}" ] && [ "${expected_sha256}" != "<sha256>" ]; then
|
||||
actual_sha256="$(sha256_of "${fixture_zip}")"
|
||||
if [ -z "${actual_sha256}" ]; then
|
||||
msg "no sha256 tool available, skipping checksum of ${asset}"
|
||||
elif [ "${actual_sha256}" != "${expected_sha256}" ]; then
|
||||
# A cached zip can be stale or truncated; the release asset itself is immutable,
|
||||
# so deleting it forces fetch to pull a fresh copy.
|
||||
msg "checksum mismatch for ${asset}, re-downloading"
|
||||
rm -f "${fixture_zip}"
|
||||
fetch "${asset_url}" "${fixture_zip}" || return 1
|
||||
actual_sha256="$(sha256_of "${fixture_zip}")"
|
||||
[ "${actual_sha256}" = "${expected_sha256}" ] || { msg "${asset}: expected ${expected_sha256}, got ${actual_sha256}"; return 1; }
|
||||
fi
|
||||
fi
|
||||
|
||||
msg "validating custom presets from ${version} ..."
|
||||
rm -rf "${profile_tree}"
|
||||
mkdir -p "${profile_tree}" || return 1
|
||||
if [ -n "${VENDOR}" ]; then
|
||||
# -v has the validator open this vendor and OrcaFilamentLibrary and skip the rest
|
||||
# (PresetBundle::load_system_presets_from_json), and unzip's * spans '/', so one
|
||||
# pattern reaches every preset type. Copying and unpacking only those turns a ~5s
|
||||
# setup per fixture into ~0.5s. Naming the vendor twice, when it is the library
|
||||
# itself, is not an error to cp; exit 11 is unzip's "nothing matched", which is the
|
||||
# fixture-predates-the-vendor case the warning below reports.
|
||||
cp -a "${PROFILES_DIR}"/*.json "${PROFILES_DIR}/${VENDOR}" \
|
||||
"${PROFILES_DIR}/OrcaFilamentLibrary" "${profile_tree}/" || return 1
|
||||
unzip -q "${fixture_zip}" "user/*/${vendor_prefix}*" -d "${profile_tree}"
|
||||
result=$?
|
||||
[ "${result}" -eq 0 ] || [ "${result}" -eq 11 ] || return 1
|
||||
kept="$(find "${profile_tree}/user" -type f 2>/dev/null | wc -l | tr -d ' ')"
|
||||
total_kept=$((total_kept + kept))
|
||||
msg " ${kept} ${VENDOR} preset file(s)"
|
||||
else
|
||||
cp -a "${PROFILES_DIR}/." "${profile_tree}/" || return 1
|
||||
rm -rf "${profile_tree}/user"
|
||||
unzip -q "${fixture_zip}" -d "${profile_tree}" || return 1
|
||||
fi
|
||||
|
||||
"${VALIDATOR}" -p "${profile_tree}" -v "${VENDOR}" -l "${LOG_LEVEL}" > "${log_path}" 2>&1
|
||||
result=$?
|
||||
|
||||
if [ "${result}" -eq 0 ]; then
|
||||
echo "| ${version} | PASS | ${version}.log |" >> "${summary}"
|
||||
# Only failures are worth keeping; each tree is a full copy of resources/profiles.
|
||||
rm -rf "${profile_tree}"
|
||||
else
|
||||
echo "| ${version} | FAIL | ${version}.log |" >> "${summary}"
|
||||
failed_logs="${failed_logs}${log_path}"$'\n'
|
||||
status=1
|
||||
fi
|
||||
done < "${fixtures_dir}/fixtures.tsv"
|
||||
|
||||
# A vendor added after the newest fixture was cut appears in none of them: nothing failed, but
|
||||
# nothing was checked either.
|
||||
if [ -n "${VENDOR}" ] && [ "${total_kept}" -eq 0 ]; then
|
||||
msg "no ${VENDOR} presets in any fixture; validate_custom checked nothing"
|
||||
fi
|
||||
|
||||
cat "${summary}"
|
||||
if [ -n "${failed_logs}" ]; then
|
||||
echo ""
|
||||
echo "## Failed Fixture Logs"
|
||||
while IFS= read -r log_path; do
|
||||
[ -n "${log_path}" ] || continue
|
||||
echo ""
|
||||
echo "### $(basename "${log_path}" .log)"
|
||||
echo '```'
|
||||
head -c 12000 "${log_path}" || echo "No output captured"
|
||||
echo '```'
|
||||
done <<EOF
|
||||
${failed_logs}
|
||||
EOF
|
||||
fi
|
||||
|
||||
return "${status}"
|
||||
}
|
||||
|
||||
# Heading CI puts above this check's log in the PR comment.
|
||||
comment_heading() {
|
||||
case "$1" in
|
||||
profile_tool) echo "### Profile Check Failed (orca_profile_tool.py)" ;;
|
||||
validate_system) echo "### System Profile Validation Failed" ;;
|
||||
validate_slice) echo "### Slice Validation Failed (custom g-code expansion)" ;;
|
||||
validate_filament_subtypes) echo "### Filament Subtype Validation Failed" ;;
|
||||
validate_custom) echo "### Custom Preset Validation Failed" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------- run
|
||||
|
||||
run_check() {
|
||||
local name="$1"
|
||||
local log="${LOG_DIR}/${name}.log"
|
||||
local result
|
||||
printf '\n%s==> %s%s%s\n' "${C_BOLD}" "${name}" "${VENDOR:+ (${VENDOR})}" "${C_RESET}"
|
||||
"check_${name}" 2>&1 | tee "${log}"
|
||||
result="${PIPESTATUS[0]}"
|
||||
if [ "${result}" -eq 0 ]; then
|
||||
printf '%s %s passed%s\n' "${C_GREEN}" "${name}" "${C_RESET}"
|
||||
RESULTS="${RESULTS}${name}"$'\t'"pass"$'\n'
|
||||
else
|
||||
printf '%s %s failed (exit %s)%s\n' "${C_RED}" "${name}" "${result}" "${C_RESET}"
|
||||
RESULTS="${RESULTS}${name}"$'\t'"fail"$'\n'
|
||||
fi
|
||||
}
|
||||
|
||||
if wants validate_system || wants validate_slice || wants validate_filament_subtypes || wants validate_custom; then
|
||||
resolve_validator
|
||||
fi
|
||||
|
||||
# An empty printer set is a failure to the sweep, so validate_slice is recorded as skipped rather
|
||||
# than run for a vendor that ships no printers (the filament-only OrcaFilamentLibrary); naming the
|
||||
# check explicitly still runs it.
|
||||
for check in "${ALL_CHECKS[@]}"; do
|
||||
wants "${check}" || continue
|
||||
if [ "${check}" = validate_slice ] && [ "${NAMED_CHECKS}" -eq 0 ] && [ -n "${VENDOR}" ] &&
|
||||
[ ! -d "${PROFILES_DIR}/${VENDOR}/machine" ]; then
|
||||
RESULTS="${RESULTS}${check}"$'\t'"skip"$'\t'"${VENDOR} ships no printers"$'\n'
|
||||
else
|
||||
run_check "${check}"
|
||||
fi
|
||||
done
|
||||
|
||||
# Summary, plus the comment check_profiles_comment.yml would post when something fails.
|
||||
failed=0
|
||||
skipped=0
|
||||
printf '\n%s==> summary%s\n' "${C_BOLD}" "${C_RESET}"
|
||||
while IFS=$'\t' read -r name result why; do
|
||||
[ -n "${name}" ] || continue
|
||||
if [ "${result}" = "pass" ]; then
|
||||
printf '%s PASS%s %s\n' "${C_GREEN}" "${C_RESET}" "${name}"
|
||||
elif [ "${result}" = "skip" ]; then
|
||||
printf '%s SKIP%s %s (%s)\n' "${C_BOLD}" "${C_RESET}" "${name}" "${why}"
|
||||
skipped=1
|
||||
else
|
||||
printf '%s FAIL%s %s (%s)\n' "${C_RED}" "${C_RESET}" "${name}" "${LOG_DIR}/${name}.log"
|
||||
failed=1
|
||||
fi
|
||||
done <<EOF
|
||||
${RESULTS}
|
||||
EOF
|
||||
|
||||
if [ "${failed}" -eq 0 ]; then
|
||||
rm -f "${WORK_DIR}/pr_comment.md"
|
||||
if [ "${skipped}" -eq 0 ]; then
|
||||
printf '\n%sAll checks passed.%s Logs: %s\n' "${C_GREEN}" "${C_RESET}" "${LOG_DIR}"
|
||||
else
|
||||
printf '\n%sEvery check that ran passed%s, but CI runs the skipped ones too. Logs: %s\n' \
|
||||
"${C_GREEN}" "${C_RESET}" "${LOG_DIR}"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
{
|
||||
# Marker matched by check_profiles_comment.yml to delete prior comments.
|
||||
echo "<!-- profile-validation-comment -->"
|
||||
echo "## :x: Profile Validation Errors"
|
||||
echo ""
|
||||
while IFS=$'\t' read -r name result; do
|
||||
[ "${result}" = "fail" ] || continue
|
||||
comment_heading "${name}"
|
||||
echo ""
|
||||
echo '```'
|
||||
head -c 30000 "${LOG_DIR}/${name}.log" || echo "No output captured"
|
||||
echo '```'
|
||||
echo ""
|
||||
done <<INNER
|
||||
${RESULTS}
|
||||
INNER
|
||||
echo "---"
|
||||
# Single-quoted on purpose: the backticks below are markdown, not command substitution.
|
||||
# shellcheck disable=SC2016
|
||||
echo '*Fix the errors above and push a new commit. To reproduce this run locally: `scripts/check_profile.sh`, or `scripts\check_profile.bat` on Windows.*'
|
||||
} > "${WORK_DIR}/pr_comment.md"
|
||||
|
||||
printf '\n%sOne or more profile checks failed.%s Logs: %s\n' "${C_RED}" "${C_RESET}" "${LOG_DIR}"
|
||||
printf 'The comment CI would post: %s\n' "${WORK_DIR}/pr_comment.md"
|
||||
exit 1
|
||||
@@ -1,3 +1,4 @@
|
||||
builddir
|
||||
.flatpak-builder
|
||||
*.docker.yml
|
||||
deps.tar*
|
||||
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
# Check a Flatpak manifest for `type: dir` sources at or before the orca_deps
|
||||
# module.
|
||||
#
|
||||
# Usage: check_manifest_cacheable.sh [manifest]
|
||||
# Defaults to com.orcaslicer.OrcaSlicer.yml next to this script.
|
||||
#
|
||||
# Exits 0 when none are found, 1 when any are, listing them as file:line, and
|
||||
# 2 when the manifest is missing or has no orca_deps module.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
anchor=orca_deps
|
||||
manifest=${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/com.orcaslicer.OrcaSlicer.yml}
|
||||
|
||||
module_re='^ - name: '
|
||||
dir_re='(^|[-{,[:space:]])type:[[:space:]]*dir([,}[:space:]]|$)'
|
||||
|
||||
if [ ! -f "$manifest" ]; then
|
||||
echo "$manifest: no such file" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
anchor_start=$(grep -n "${module_re}${anchor}[[:space:]]*$" "$manifest" | cut -d: -f1 || true)
|
||||
if [ -z "$anchor_start" ]; then
|
||||
echo "$manifest: no module named '$anchor'; this check needs updating" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# First module header after the anchor, or EOF if the anchor is last.
|
||||
anchor_end=$(grep -n "$module_re" "$manifest" | cut -d: -f1 |
|
||||
awk -v s="$anchor_start" '$1 > s { print $1; exit }')
|
||||
[ -n "$anchor_end" ] || anchor_end=$(awk 'END { print NR + 1 }' "$manifest")
|
||||
|
||||
violations=$(awk -v e="$anchor_end" -v f="$manifest" -v mre="$module_re" -v dre="$dir_re" '
|
||||
{ sub(/\r$/, "") }
|
||||
/^[[:space:]]*#/ { next }
|
||||
$0 ~ mre { module = $3 }
|
||||
NR < e && $0 ~ dre {
|
||||
printf "%s:%d: %s (module %s)\n", f, NR, $0, module
|
||||
}' "$manifest")
|
||||
|
||||
if [ -n "$violations" ]; then
|
||||
printf '%s\n' "$violations" >&2
|
||||
echo >&2
|
||||
echo "flatpak-builder cannot checksum a directory, so each of these makes" >&2
|
||||
echo "$anchor and every module after it rebuild from scratch on every run." >&2
|
||||
echo "See the $anchor sources in this manifest for the tarball used instead." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "manifest OK: no 'type: dir' source at or before $anchor"
|
||||
@@ -144,6 +144,7 @@ modules:
|
||||
env:
|
||||
BUILD_DIR: deps/build_flatpak
|
||||
build-commands:
|
||||
- tar xf deps.tar
|
||||
- |
|
||||
cmake -S deps -B $BUILD_DIR \
|
||||
-DFLATPAK=ON \
|
||||
@@ -169,10 +170,10 @@ modules:
|
||||
- /libpython/include
|
||||
|
||||
sources:
|
||||
# OrcaSlicer deps/ directory (avoids copying .git from worktree)
|
||||
- type: dir
|
||||
path: ../../deps
|
||||
dest: deps
|
||||
# flatpak-builder cannot checksum a directory, so a `type: dir` here would
|
||||
# rebuild every dependency on every run. Generated by make_deps_tar.sh.
|
||||
- type: file
|
||||
path: deps.tar
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Pre-downloaded dependency archives
|
||||
@@ -222,6 +223,12 @@ modules:
|
||||
sha256: 51afe0db79af8386e2027d56d685177135581e0ee82ade9d7f2caff8deab5ec5
|
||||
dest: external-packages/OpenCSG
|
||||
|
||||
# SolveSpace libslvs (2D sketch constraint solver, Design tab)
|
||||
- type: file
|
||||
url: https://github.com/JacobStoren/SolveSpaceLib/archive/4d8704523e4bf212fadf5189f92484244f670fea.zip
|
||||
sha256: 1c4bdde9c3c6ef20ea4b50b73601de56769f2eb131b36927d7c6489f102e6c30
|
||||
dest: external-packages/SLVS
|
||||
|
||||
# Blosc 1.17.0 (tamasmeszaros fork)
|
||||
- type: file
|
||||
url: https://github.com/tamasmeszaros/c-blosc/archive/refs/heads/v1.17.0_tm.zip
|
||||
@@ -377,6 +384,17 @@ modules:
|
||||
- cmake --build build_flatpak --target generate_system_cache -j$FLATPAK_BUILDER_N_JOBS
|
||||
- ./scripts/build_preset_cache.sh -n -b build_flatpak /app/share/OrcaSlicer/profiles
|
||||
|
||||
# Built (not run) here via the action's run-tests, then shipped to a separate
|
||||
# test job. Only the test sources compile; nothing installs to /app.
|
||||
test-commands:
|
||||
- cmake . -B build_flatpak -DBUILD_TESTS=ON
|
||||
# A suite missing from this list fails the leg loudly, since ctest registers a
|
||||
# <target>_NOT_BUILT test for it. (tests/all is a Ninja subdirectory target and
|
||||
# this build uses the default Makefile generator, so it is not available here.)
|
||||
- cmake --build build_flatpak -j"${FLATPAK_BUILDER_N_JOBS:-$(nproc)}" --target
|
||||
libslic3r_tests fff_print_tests sla_print_tests libnest2d_tests slic3rutils_tests
|
||||
filament_group_tests
|
||||
|
||||
cleanup:
|
||||
- /include
|
||||
|
||||
@@ -413,6 +431,10 @@ modules:
|
||||
- type: dir
|
||||
path: ../../localization
|
||||
dest: localization
|
||||
# For the post-build unit-test step (BUILD_TESTS=ON); not built by the app.
|
||||
- type: dir
|
||||
path: ../../tests
|
||||
dest: tests
|
||||
|
||||
- type: file
|
||||
path: ../../CMakeLists.txt
|
||||
@@ -426,6 +448,9 @@ modules:
|
||||
- type: file
|
||||
path: ../build_preset_cache.sh
|
||||
dest: scripts
|
||||
- type: file
|
||||
path: ../run_unit_tests.sh
|
||||
dest: scripts
|
||||
|
||||
# AppData metainfo for GNOME Software & Co.
|
||||
- type: file
|
||||
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
# Pack deps/ into scripts/flatpak/deps.tar for the Flatpak manifest's orca_deps
|
||||
# module.
|
||||
#
|
||||
# Usage: make_deps_tar.sh
|
||||
# Requires GNU tar. On macOS, brew install gnu-tar.
|
||||
#
|
||||
# The archive is byte-reproducible. Member order, mtimes, ownership and the
|
||||
# group/other write bits are pinned, so identical deps/ contents always produce
|
||||
# an identical file. deps/build* and deps/DL_CACHE are excluded.
|
||||
#
|
||||
# Prints the output path, size and sha256.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -ne 0 ]; then
|
||||
echo "usage: ${0##*/}" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
tar_bin=$(command -v gtar || command -v tar || true)
|
||||
tar_version=$([ -n "$tar_bin" ] && "$tar_bin" --version 2>/dev/null || true)
|
||||
case $tar_version in
|
||||
*"GNU tar"*) ;;
|
||||
*) echo "${0##*/}: needs GNU tar; on macOS run 'brew install gnu-tar'" >&2
|
||||
exit 2 ;;
|
||||
esac
|
||||
|
||||
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
repo_root=$(cd "$script_dir/../.." && pwd)
|
||||
out=$script_dir/deps.tar
|
||||
tmp=$out.tmp
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
|
||||
"$tar_bin" --format=gnu \
|
||||
--sort=name \
|
||||
--mtime=@0 \
|
||||
--owner=0 --group=0 --numeric-owner \
|
||||
--mode=go-w \
|
||||
--exclude='deps/build*' \
|
||||
--exclude='deps/DL_CACHE' \
|
||||
-cf "$tmp" \
|
||||
-C "$repo_root" deps
|
||||
|
||||
mv -f "$tmp" "$out"
|
||||
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sum=$(sha256sum "$out" | cut -d' ' -f1)
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
sum=$(shasum -a 256 "$out" | cut -d' ' -f1)
|
||||
else
|
||||
sum=unavailable
|
||||
fi
|
||||
|
||||
echo "Wrote $out ($(du -h "$out" | cut -f1), sha256 $sum)"
|
||||
@@ -8,8 +8,9 @@ flatpak install flathub org.gnome.Platform//50 org.gnome.Sdk//50 org.freedesktop
|
||||
|
||||
##
|
||||
# in OrcaSlicer folder, run following command to build Orca
|
||||
# # First time build
|
||||
# flatpak-builder --state-dir=.flatpak-builder --keep-build-dirs --user --force-clean build-dir scripts/flatpak/com.orcaslicer.OrcaSlicer.yml
|
||||
|
||||
# # Subsequent builds (only rebuilding OrcaSlicer)
|
||||
# # First time build
|
||||
# ./scripts/flatpak/make_deps_tar.sh && flatpak-builder --state-dir=.flatpak-builder --keep-build-dirs --user --force-clean build-dir scripts/flatpak/com.orcaslicer.OrcaSlicer.yml
|
||||
|
||||
# # Subsequent builds (only rebuilding OrcaSlicer; run make_deps_tar.sh first if deps/ changed)
|
||||
# flatpak-builder --state-dir=.flatpak-builder --keep-build-dirs --user build-dir scripts/flatpak/com.orcaslicer.OrcaSlicer.yml --build-only=OrcaSlicer
|
||||
@@ -1,632 +0,0 @@
|
||||
import os
|
||||
import json
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from assign_vendor_setting_ids import generate_preset_setting_id
|
||||
|
||||
OBSOLETE_KEYS = {
|
||||
"acceleration", "scale", "rotate", "duplicate", "duplicate_grid",
|
||||
"bed_size", "print_center", "g0", "wipe_tower_per_color_wipe",
|
||||
"support_sharp_tails", "support_remove_small_overhangs", "support_with_sheath",
|
||||
"tree_support_collision_resolution", "tree_support_with_infill",
|
||||
"max_volumetric_speed", "max_print_speed", "support_closing_radius",
|
||||
"remove_freq_sweep", "remove_bed_leveling", "remove_extrusion_calibration",
|
||||
"support_transition_line_width", "support_transition_speed", "bed_temperature",
|
||||
"bed_temperature_initial_layer", "can_switch_nozzle_type", "can_add_auxiliary_fan",
|
||||
"extra_flush_volume", "spaghetti_detector", "adaptive_layer_height",
|
||||
"z_hop_type", "z_lift_type", "bed_temperature_difference", "long_retraction_when_cut",
|
||||
"retraction_distance_when_cut", "extruder_type", "internal_bridge_support_thickness",
|
||||
"extruder_clearance_max_radius", "top_area_threshold", "reduce_wall_solid_infill",
|
||||
"filament_load_time", "filament_unload_time", "smooth_coefficient",
|
||||
"overhang_totally_speed", "silent_mode", "overhang_speed_classic"
|
||||
}
|
||||
|
||||
# Utility functions for printing messages in different colors.
|
||||
def print_error(msg):
|
||||
print(f"\033[91m[ERROR]\033[0m {msg}") # Red
|
||||
|
||||
def print_warning(msg):
|
||||
print(f"\033[93m[WARNING]\033[0m {msg}") # Yellow
|
||||
|
||||
def print_info(msg):
|
||||
print(f"\033[94m[INFO]\033[0m {msg}") # Blue
|
||||
|
||||
def print_success(msg):
|
||||
print(f"\033[92m[SUCCESS]\033[0m {msg}") # Green
|
||||
|
||||
|
||||
# Add helper function for duplicate key detection.
|
||||
def no_duplicates_object_pairs_hook(pairs):
|
||||
seen = {}
|
||||
for key, value in pairs:
|
||||
if key in seen:
|
||||
raise ValueError(f"Duplicate key detected: {key}")
|
||||
seen[key] = value
|
||||
return seen
|
||||
|
||||
# NOTE: currently Orca expects compatible_printers to be a defined in every instantiation profile, inheritation is not supported in Profile page
|
||||
def check_filament_compatible_printers(vendor, vendor_folder):
|
||||
"""
|
||||
Checks JSON files in the vendor folder for missing or empty 'compatible_printers'
|
||||
when 'instantiation' is flagged as true.
|
||||
|
||||
In the OrcaFilamentLibrary 'compatible_printers' is optional: a profile without it is generic and
|
||||
offered on every printer, while a profile that lists printers supersedes the generic one there.
|
||||
|
||||
Parameters:
|
||||
vendor (str): The vendor name the folder belongs to.
|
||||
vendor_folder (str or Path): The directory to search for JSON profile files.
|
||||
|
||||
Returns:
|
||||
int: The number of profiles with missing or empty 'compatible_printers'.
|
||||
"""
|
||||
error = 0
|
||||
vendor_path = Path(vendor_folder)
|
||||
if not vendor_path.exists():
|
||||
return 0
|
||||
|
||||
profiles = {}
|
||||
|
||||
# Use rglob to recursively find .json files.
|
||||
for file_path in vendor_path.rglob("*.json"):
|
||||
if file_path.name == 'filaments_color_codes.json': # Ignore non-profile file
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='UTF-8') as fp:
|
||||
# Use custom hook to detect duplicates.
|
||||
data = json.load(fp, object_pairs_hook=no_duplicates_object_pairs_hook)
|
||||
except ValueError as ve:
|
||||
print_error(f"Duplicate key error in {file_path}: {ve}")
|
||||
error += 1
|
||||
continue
|
||||
except Exception as e:
|
||||
print_error(f"Error processing {file_path}: {e}")
|
||||
error += 1
|
||||
continue
|
||||
|
||||
profile_name = data['name']
|
||||
if profile_name in profiles:
|
||||
print_error(f"Duplicated profile {profile_name}: {file_path}")
|
||||
error += 1
|
||||
continue
|
||||
|
||||
profiles[profile_name] = {
|
||||
'file_path': file_path,
|
||||
'content': data,
|
||||
}
|
||||
|
||||
def get_property(profile, key):
|
||||
content = profile['content']
|
||||
if key in content:
|
||||
return content[key]
|
||||
return None
|
||||
|
||||
def get_inherit_property(profile, key):
|
||||
content = profile['content']
|
||||
if key in content:
|
||||
return content[key]
|
||||
|
||||
if 'inherits' in content:
|
||||
inherits = content['inherits']
|
||||
if inherits not in profiles:
|
||||
raise ValueError(f"Parent profile not found: {inherits}, referenced in {profile['file_path']}")
|
||||
|
||||
return get_inherit_property(profiles[inherits], key)
|
||||
|
||||
return None
|
||||
|
||||
for profile in profiles.values():
|
||||
instantiation = str(profile['content'].get("instantiation", "")).lower() == "true"
|
||||
if instantiation and vendor != 'OrcaFilamentLibrary':
|
||||
try:
|
||||
compatible_printers = get_property(profile, "compatible_printers")
|
||||
if not compatible_printers or (isinstance(compatible_printers, list) and not compatible_printers):
|
||||
print_error(f"'compatible_printers' missing in {profile['file_path']}")
|
||||
error += 1
|
||||
except ValueError as ve:
|
||||
print_error(f"Unable to parse {profile['file_path']}: {ve}")
|
||||
error += 1
|
||||
continue
|
||||
|
||||
return error
|
||||
|
||||
def load_available_filament_profiles(profiles_dir, vendor_name):
|
||||
"""
|
||||
Load all available filament profiles from a vendor's directory.
|
||||
|
||||
Parameters:
|
||||
profiles_dir (Path): The directory containing vendor profile directories
|
||||
vendor_name (str): The name of the vendor directory
|
||||
|
||||
Returns:
|
||||
set: A set of filament profile names
|
||||
"""
|
||||
profiles = set()
|
||||
vendor_path = profiles_dir / vendor_name / "filament"
|
||||
|
||||
if not vendor_path.exists():
|
||||
return profiles
|
||||
|
||||
for file_path in vendor_path.rglob("*.json"):
|
||||
try:
|
||||
with open(file_path, 'r', encoding='UTF-8') as fp:
|
||||
data = json.load(fp)
|
||||
if "name" in data:
|
||||
profiles.add(data["name"])
|
||||
except Exception as e:
|
||||
print_error(f"Error loading filament profile {file_path}: {e}")
|
||||
|
||||
return profiles
|
||||
|
||||
def check_machine_default_materials(profiles_dir, vendor_name):
|
||||
"""
|
||||
Checks if default materials referenced in machine profiles exist in
|
||||
the vendor's filament library or in the global OrcaFilamentLibrary.
|
||||
|
||||
Parameters:
|
||||
profiles_dir (Path): The base profiles directory
|
||||
vendor_name (str): The vendor name to check
|
||||
|
||||
Returns:
|
||||
int: Number of missing filament references found
|
||||
int: the number of warnings found (0 or 1)
|
||||
"""
|
||||
error_count = 0
|
||||
machine_dir = profiles_dir / vendor_name / "machine"
|
||||
|
||||
if not machine_dir.exists():
|
||||
print_warning(f"No machine profiles found for vendor: {vendor_name}")
|
||||
return 0, 1
|
||||
|
||||
# Load available filament profiles
|
||||
vendor_filaments = load_available_filament_profiles(profiles_dir, vendor_name)
|
||||
global_filaments = load_available_filament_profiles(profiles_dir, "OrcaFilamentLibrary")
|
||||
all_available_filaments = vendor_filaments.union(global_filaments)
|
||||
|
||||
# Check each machine profile
|
||||
for file_path in machine_dir.rglob("*.json"):
|
||||
try:
|
||||
with open(file_path, 'r', encoding='UTF-8') as fp:
|
||||
data = json.load(fp)
|
||||
|
||||
default_materials = None
|
||||
if "default_materials" in data:
|
||||
default_materials = data["default_materials"]
|
||||
elif "default_filament_profile" in data:
|
||||
default_materials = data["default_filament_profile"]
|
||||
|
||||
if default_materials:
|
||||
if isinstance(default_materials, list):
|
||||
for material in default_materials:
|
||||
if material not in all_available_filaments:
|
||||
print_error(f"Missing filament profile: '{material}' referenced in {file_path.relative_to(profiles_dir)}")
|
||||
error_count += 1
|
||||
else:
|
||||
# Handle semicolon-separated list of materials in a string
|
||||
if ";" in default_materials:
|
||||
for material in default_materials.split(";"):
|
||||
material = material.strip()
|
||||
if material and material not in all_available_filaments:
|
||||
print_error(f"Missing filament profile: '{material}' referenced in {file_path.relative_to(profiles_dir)}")
|
||||
error_count += 1
|
||||
else:
|
||||
# Single material in a string
|
||||
if default_materials not in all_available_filaments:
|
||||
print_error(f"Missing filament profile: '{default_materials}' referenced in {file_path.relative_to(profiles_dir)}")
|
||||
error_count += 1
|
||||
|
||||
except Exception as e:
|
||||
print_error(f"Error processing machine profile {file_path}: {e}")
|
||||
error_count += 1
|
||||
|
||||
return error_count, 0
|
||||
|
||||
def check_name_consistency(profiles_dir, vendor_name):
|
||||
"""
|
||||
Make sure filament profile names match in both vendor json and subpath files.
|
||||
Filament profiles work only if the name in <vendor>.json matches the name in sub_path file,
|
||||
or if it's one of the sub_path file's `renamed_from`.
|
||||
|
||||
Parameters:
|
||||
profiles_dir (Path): Base profiles directory
|
||||
vendor_name (str): Vendor name
|
||||
|
||||
Returns:
|
||||
int: Number of errors found
|
||||
int: Number of warnings found (0 or 1)
|
||||
"""
|
||||
error_count = 0
|
||||
vendor_dir = profiles_dir / vendor_name
|
||||
vendor_file = profiles_dir / (vendor_name + ".json")
|
||||
|
||||
if not vendor_file.exists():
|
||||
print_warning(f"No profiles found for vendor: {vendor_name} at {vendor_file}")
|
||||
return 0, 1
|
||||
|
||||
try:
|
||||
with open(vendor_file, 'r', encoding='UTF-8') as fp:
|
||||
data = json.load(fp)
|
||||
except Exception as e:
|
||||
print_error(f"Error loading vendor profile {vendor_file}: {e}")
|
||||
return 1, 0
|
||||
|
||||
for section in ['filament_list', 'machine_model_list', 'machine_list', 'process_list']:
|
||||
if section not in data:
|
||||
continue
|
||||
|
||||
for child in data[section]:
|
||||
name_in_vendor = child['name']
|
||||
sub_path = child['sub_path']
|
||||
sub_file = vendor_dir / sub_path
|
||||
|
||||
if not sub_file.exists():
|
||||
print_error(f"Missing sub profile: '{sub_path}' declared in {vendor_file.relative_to(profiles_dir)}")
|
||||
error_count += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(sub_file, 'r', encoding='UTF-8') as fp:
|
||||
sub_data = json.load(fp)
|
||||
except Exception as e:
|
||||
print_error(f"Error loading profile {sub_file}: {e}")
|
||||
error_count += 1
|
||||
continue
|
||||
|
||||
name_in_sub = sub_data['name']
|
||||
|
||||
if name_in_sub == name_in_vendor:
|
||||
continue
|
||||
|
||||
# if 'renamed_from' in sub_data:
|
||||
# renamed_from = [n.strip() for n in sub_data['renamed_from'].split(';')]
|
||||
# if name_in_vendor in renamed_from:
|
||||
# continue
|
||||
|
||||
print_error(f"{section} name mismatch: required '{name_in_vendor}' in {vendor_file.relative_to(profiles_dir)} but found '{name_in_sub}' in {sub_file.relative_to(profiles_dir)}")
|
||||
error_count += 1
|
||||
|
||||
return error_count, 0
|
||||
|
||||
def check_filament_id(vendor, vendor_folder):
|
||||
"""
|
||||
Make sure filament_id is not longer than 8 characters, otherwise AMS won't work properly
|
||||
"""
|
||||
if vendor not in ('BBL', 'OrcaFilamentLibrary'):
|
||||
return 0
|
||||
|
||||
error = 0
|
||||
vendor_path = Path(vendor_folder)
|
||||
if not vendor_path.exists():
|
||||
return 0
|
||||
|
||||
# Use rglob to recursively find .json files.
|
||||
for file_path in vendor_path.rglob("*.json"):
|
||||
try:
|
||||
with open(file_path, 'r', encoding='UTF-8') as fp:
|
||||
# Use custom hook to detect duplicates.
|
||||
data = json.load(fp, object_pairs_hook=no_duplicates_object_pairs_hook)
|
||||
except ValueError as ve:
|
||||
print_error(f"Duplicate key error in {file_path}: {ve}")
|
||||
error += 1
|
||||
continue
|
||||
except Exception as e:
|
||||
print_error(f"Error processing {file_path}: {e}")
|
||||
error += 1
|
||||
continue
|
||||
|
||||
if 'filament_id' not in data:
|
||||
continue
|
||||
|
||||
filament_id = data['filament_id']
|
||||
|
||||
if len(filament_id) > 8:
|
||||
error += 1
|
||||
print_error(f"Filament id too long \"{filament_id}\": {file_path}")
|
||||
|
||||
return error
|
||||
|
||||
def check_obsolete_keys(profiles_dir, vendor_name):
|
||||
"""
|
||||
Check for obsolete keys in all filament profiles for a vendor.
|
||||
|
||||
Parameters:
|
||||
profiles_dir (Path): Base profiles directory
|
||||
vendor_name (str): Vendor name
|
||||
obsolete_keys (set): Set of obsolete key names to check
|
||||
|
||||
Returns:
|
||||
int: Number of obsolete keys found
|
||||
"""
|
||||
error_count = 0
|
||||
vendor_path = profiles_dir / vendor_name / "filament"
|
||||
|
||||
if not vendor_path.exists():
|
||||
return 0
|
||||
|
||||
for file_path in vendor_path.rglob("*.json"):
|
||||
try:
|
||||
with open(file_path, "r", encoding="UTF-8") as fp:
|
||||
data = json.load(fp)
|
||||
except Exception as e:
|
||||
print_warning(f"Error reading profile {file_path.relative_to(profiles_dir)}: {e}")
|
||||
error_count += 1
|
||||
continue
|
||||
|
||||
for key in data.keys():
|
||||
if key in OBSOLETE_KEYS:
|
||||
print_warning(f"Obsolete key: '{key}' found in {file_path.relative_to(profiles_dir)}")
|
||||
error_count += 1
|
||||
|
||||
return error_count
|
||||
|
||||
|
||||
CONFLICT_KEYS = [
|
||||
['extruder_clearance_radius', 'extruder_clearance_max_radius'],
|
||||
]
|
||||
|
||||
VECTOR_KEYS = {
|
||||
"filament_type",
|
||||
}
|
||||
|
||||
def check_vector_type_keys(profiles_dir, vendor_name):
|
||||
"""
|
||||
Check that properties expected to be vectors (JSON arrays) are not stored as scalars.
|
||||
For example, `filament_type` must be a list like ["PA-CF"], not a string "PA-CF".
|
||||
|
||||
Parameters:
|
||||
profiles_dir (Path): Base profiles directory
|
||||
vendor_name (str): Vendor name
|
||||
|
||||
Returns:
|
||||
int: Number of errors found
|
||||
"""
|
||||
error_count = 0
|
||||
vendor_path = profiles_dir / vendor_name
|
||||
|
||||
if not vendor_path.exists():
|
||||
return 0
|
||||
|
||||
for file_path in vendor_path.rglob("*.json"):
|
||||
try:
|
||||
with open(file_path, "r", encoding="UTF-8") as fp:
|
||||
data = json.load(fp)
|
||||
except Exception as e:
|
||||
print_error(f"Error processing {file_path.relative_to(profiles_dir)}: {e}")
|
||||
error_count += 1
|
||||
continue
|
||||
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
|
||||
for key in VECTOR_KEYS:
|
||||
if key in data and not isinstance(data[key], list):
|
||||
print_error(
|
||||
f"'{key}' must be an array in {file_path.relative_to(profiles_dir)}, "
|
||||
f"got {type(data[key]).__name__}: {data[key]!r}"
|
||||
)
|
||||
error_count += 1
|
||||
|
||||
return error_count
|
||||
|
||||
def check_conflict_keys(profiles_dir, vendor_name):
|
||||
"""
|
||||
Check for keys that could not be specified at the same time,
|
||||
due to option renaming & backward compatibility reasons.
|
||||
|
||||
For example, `extruder_clearance_max_radius` and `extruder_clearance_radius` cannot co-exist
|
||||
otherwise slicer won't know which one to use.
|
||||
|
||||
Parameters:
|
||||
profiles_dir (Path): Base profiles directory
|
||||
vendor_name (str): Vendor name
|
||||
|
||||
Returns:
|
||||
int: Number of errors found
|
||||
int: Number of warnings found
|
||||
"""
|
||||
error_count = 0
|
||||
warn_count = 0
|
||||
vendor_path = profiles_dir / vendor_name
|
||||
|
||||
if not vendor_path.exists():
|
||||
print_warning(f"No machine profiles found for vendor: {vendor_name}")
|
||||
return 0, 1
|
||||
|
||||
for file_path in vendor_path.rglob("*.json"):
|
||||
try:
|
||||
with open(file_path, 'r', encoding='UTF-8') as fp:
|
||||
# Use custom hook to detect duplicates.
|
||||
data = json.load(fp, object_pairs_hook=no_duplicates_object_pairs_hook)
|
||||
except ValueError as ve:
|
||||
print_error(f"Duplicate key error in {file_path.relative_to(profiles_dir)}: {ve}")
|
||||
error_count += 1
|
||||
continue
|
||||
except Exception as e:
|
||||
print_error(f"Error processing {file_path.relative_to(profiles_dir)}: {e}")
|
||||
error_count += 1
|
||||
continue
|
||||
|
||||
for key_sets in CONFLICT_KEYS:
|
||||
if sum([1 if k in data else 0 for k in key_sets]) > 1:
|
||||
print_error(f"Conflict keys {key_sets} co-exist in {file_path.relative_to(profiles_dir)}")
|
||||
error_count += 1
|
||||
|
||||
return error_count, warn_count
|
||||
|
||||
|
||||
# Bambu (BBL) keeps its authoritative "G*" cloud ids, which are NOT produced by the
|
||||
# deterministic formula, so BBL is exempt from the formula match (Rule 2) only. It is
|
||||
# still checked for presence, uniqueness, base-no-id and the typo key like every other
|
||||
# vendor. Every other vendor (incl. OrcaFilamentLibrary and Custom) must also match the
|
||||
# formula.
|
||||
SETTING_ID_FORMULA_EXEMPT_VENDORS = {"BBL"}
|
||||
PROFILE_SUBDIRS = ("filament", "process", "machine")
|
||||
|
||||
|
||||
def check_setting_id_uniqueness(profiles_dir):
|
||||
"""
|
||||
Validate setting_id across every vendor (see scripts/assign_vendor_setting_ids.py):
|
||||
1. Every instantiated preset must HAVE a setting_id. (all vendors)
|
||||
2. A stored setting_id must equal generate_preset_setting_id(vendor, type, name); a stale
|
||||
value means the JSON was edited without rerunning assign_vendor_setting_ids.py.
|
||||
(all vendors EXCEPT the formula-exempt ones, e.g. BBL)
|
||||
3. Base profiles (instantiation != "true") must not carry a setting_id. (all vendors)
|
||||
4. setting_id must be globally unique - no two files may share one. (all vendors)
|
||||
5. No profile may use the misspelled key "settings_id". (all vendors)
|
||||
Formula-exempt vendors (BBL) keep their authoritative ids, so only Rule 2 is skipped
|
||||
for them; they are still held to presence, uniqueness, base-no-id and the typo check.
|
||||
"""
|
||||
errors = 0
|
||||
owners = {} # setting_id -> list of relative_path (every vendor)
|
||||
for vendor_dir in sorted(profiles_dir.iterdir()):
|
||||
if not vendor_dir.is_dir():
|
||||
continue
|
||||
vendor = vendor_dir.name
|
||||
formula_exempt = vendor in SETTING_ID_FORMULA_EXEMPT_VENDORS
|
||||
for sub in PROFILE_SUBDIRS:
|
||||
base = vendor_dir / sub
|
||||
if not base.is_dir():
|
||||
continue
|
||||
for file_path in base.rglob("*.json"):
|
||||
try:
|
||||
data = json.loads(file_path.read_bytes())
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
rel = file_path.relative_to(profiles_dir)
|
||||
# Rule 5: catch the misspelled "settings_id" key.
|
||||
if "settings_id" in data:
|
||||
errors += 1
|
||||
print_error(
|
||||
f'profile {rel} uses the misspelled key "settings_id" '
|
||||
f'(should be "setting_id"); run assign_vendor_setting_ids.py'
|
||||
)
|
||||
sid = data.get("setting_id")
|
||||
instantiated = data.get("instantiation") == "true"
|
||||
if not instantiated:
|
||||
# Rule 3: base/template profiles must not carry a setting_id.
|
||||
if sid:
|
||||
errors += 1
|
||||
print_error(
|
||||
f'base profile {rel} (instantiation != "true") must not have a '
|
||||
f'setting_id ("{sid}"); run assign_vendor_setting_ids.py'
|
||||
)
|
||||
continue
|
||||
# Rule 1: every instantiated preset must have a setting_id.
|
||||
if not sid:
|
||||
errors += 1
|
||||
print_error(
|
||||
f"instantiated preset {rel} is missing a setting_id; "
|
||||
f"run assign_vendor_setting_ids.py"
|
||||
)
|
||||
continue
|
||||
# Rule 2: the stored id must match the deterministic rule. BBL keeps its
|
||||
# authoritative G* ids and is exempt from this check only.
|
||||
if not formula_exempt:
|
||||
expected = generate_preset_setting_id(vendor, sub, data.get("name", ""))
|
||||
if sid != expected:
|
||||
errors += 1
|
||||
print_error(
|
||||
f'setting_id "{sid}" in {rel} does not match the expected '
|
||||
f'"{expected}" for {vendor}/{sub}/{data.get("name", "")}; '
|
||||
f"run assign_vendor_setting_ids.py"
|
||||
)
|
||||
continue
|
||||
# Rule 4: collect for the global-uniqueness check below.
|
||||
owners.setdefault(sid, []).append(rel)
|
||||
|
||||
# Rule 4: a setting_id shared by two files is an error. For managed vendors this means
|
||||
# a duplicate vendor/type/name; for formula-exempt vendors (BBL) a copy-pasted id.
|
||||
for sid, locs in sorted(owners.items()):
|
||||
if len(locs) < 2:
|
||||
continue
|
||||
errors += 1
|
||||
print_error(
|
||||
f'setting_id "{sid}" is shared by {len(locs)} files ({sorted(map(str, locs))}); '
|
||||
f"setting_id must be globally unique"
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Check 3D printer profiles for common issues",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter
|
||||
)
|
||||
parser.add_argument("--vendor", type=str, help="Specify a single vendor to check")
|
||||
parser.add_argument("--check-filaments", action="store_true", help="Check 'compatible_printers' in filament profiles")
|
||||
parser.add_argument("--check-materials", action="store_true", help="Check default materials in machine profiles")
|
||||
parser.add_argument("--check-obsolete-keys", action="store_true", help="Warn if obsolete keys are found in filament profiles")
|
||||
args = parser.parse_args()
|
||||
|
||||
print_info("Checking profiles ...")
|
||||
|
||||
script_dir = Path(__file__).resolve().parent
|
||||
profiles_dir = script_dir.parent / "resources" / "profiles"
|
||||
checked_vendor_count = 0
|
||||
errors_found = 0
|
||||
warnings_found = 0
|
||||
|
||||
def run_checks(vendor_name):
|
||||
nonlocal errors_found, warnings_found, checked_vendor_count
|
||||
vendor_path = profiles_dir / vendor_name
|
||||
|
||||
if args.check_filaments or not (args.check_materials and not args.check_filaments):
|
||||
errors_found += check_filament_compatible_printers(vendor_name, vendor_path / "filament")
|
||||
|
||||
if args.check_materials:
|
||||
new_errors, new_warnings = check_machine_default_materials(profiles_dir, vendor_name)
|
||||
errors_found += new_errors
|
||||
warnings_found += new_warnings
|
||||
|
||||
if args.check_obsolete_keys:
|
||||
warnings_found += check_obsolete_keys(profiles_dir, vendor_name)
|
||||
|
||||
new_errors, new_warnings = check_name_consistency(profiles_dir, vendor_name)
|
||||
errors_found += new_errors
|
||||
warnings_found += new_warnings
|
||||
|
||||
new_errors, new_warnings = check_conflict_keys(profiles_dir, vendor_name)
|
||||
errors_found += new_errors
|
||||
warnings_found += new_warnings
|
||||
|
||||
errors_found += check_vector_type_keys(profiles_dir, vendor_name)
|
||||
|
||||
errors_found += check_filament_id(vendor_name, vendor_path / "filament")
|
||||
checked_vendor_count += 1
|
||||
|
||||
if args.vendor:
|
||||
run_checks(args.vendor)
|
||||
else:
|
||||
for vendor_dir in profiles_dir.iterdir():
|
||||
if not vendor_dir.is_dir() or vendor_dir.name == "OrcaFilamentLibrary":
|
||||
continue
|
||||
run_checks(vendor_dir.name)
|
||||
|
||||
# Global (cross-vendor) check: setting_id must be unique and stay in-namespace.
|
||||
# Runs once over the whole tree regardless of the --vendor filter.
|
||||
errors_found += check_setting_id_uniqueness(profiles_dir)
|
||||
|
||||
# ✨ Output finale in stile "compilatore"
|
||||
print("\n==================== SUMMARY ====================")
|
||||
print_info(f"Checked vendors : {checked_vendor_count}")
|
||||
if errors_found > 0:
|
||||
print_error(f"Files with errors : {errors_found}")
|
||||
else:
|
||||
print_success("Files with errors : 0")
|
||||
if warnings_found > 0:
|
||||
print_warning(f"Files with warnings : {warnings_found}")
|
||||
else:
|
||||
print_success("Files with warnings : 0")
|
||||
print("=================================================")
|
||||
if errors_found > 0 or warnings_found > 0 :
|
||||
print_warning('Issue(s) found, try `orca_filament_lib.py --fix` to fix common issues automatically')
|
||||
|
||||
exit(-1 if errors_found > 0 else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,310 +0,0 @@
|
||||
import os
|
||||
import json
|
||||
import argparse
|
||||
from collections import defaultdict
|
||||
|
||||
def create_ordered_profile(profile_dict, priority_fields=['name', 'type']):
|
||||
"""Create a new dictionary with priority fields first"""
|
||||
ordered_profile = {}
|
||||
|
||||
# Add priority fields first
|
||||
for field in priority_fields:
|
||||
if field in profile_dict:
|
||||
ordered_profile[field] = profile_dict[field]
|
||||
|
||||
# Add remaining fields
|
||||
for key, value in profile_dict.items():
|
||||
if key not in priority_fields:
|
||||
ordered_profile[key] = value
|
||||
|
||||
return ordered_profile
|
||||
|
||||
def topological_sort(filaments):
|
||||
# Build a graph of dependencies
|
||||
graph = defaultdict(list)
|
||||
in_degree = defaultdict(int)
|
||||
name_to_filament = {f['name']: f for f in filaments}
|
||||
all_names = set(name_to_filament.keys())
|
||||
|
||||
# Create the dependency graph
|
||||
processed_files = set()
|
||||
for filament in filaments:
|
||||
if 'inherits' in filament:
|
||||
parent = filament['inherits']
|
||||
child = filament['name']
|
||||
# Only create dependency if parent exists
|
||||
if parent in all_names:
|
||||
graph[parent].append(child)
|
||||
in_degree[child] += 1
|
||||
if parent not in in_degree:
|
||||
in_degree[parent] = 0
|
||||
processed_files.add(child)
|
||||
processed_files.add(parent)
|
||||
|
||||
# Initialize queue with nodes having no dependencies (now sorted)
|
||||
queue = sorted([name for name, degree in in_degree.items() if degree == 0])
|
||||
result = []
|
||||
|
||||
# Process the queue
|
||||
while queue:
|
||||
current = queue.pop(0)
|
||||
result.append(name_to_filament[current])
|
||||
processed_files.add(current)
|
||||
|
||||
# Process children (now sorted)
|
||||
children = sorted(graph[current])
|
||||
for child in children:
|
||||
in_degree[child] -= 1
|
||||
if in_degree[child] == 0:
|
||||
queue.append(child)
|
||||
|
||||
# Add remaining files that weren't part of inheritance tree (now sorted)
|
||||
remaining = sorted(all_names - processed_files)
|
||||
for name in remaining:
|
||||
result.append(name_to_filament[name])
|
||||
|
||||
return result
|
||||
|
||||
def update_profile_library(vendor="",profile_type="filament"):
|
||||
# change current working directory to the relative path(..\resources\profiles) compare to script location
|
||||
os.chdir(os.path.join(os.path.dirname(__file__), '..', 'resources', 'profiles'))
|
||||
|
||||
# Collect current profile entries
|
||||
if vendor:
|
||||
vendors = [vendor]
|
||||
else:
|
||||
profiles_dir = os.path.join(os.path.dirname(__file__), '..', 'resources', 'profiles')
|
||||
vendors = [f[:-5] for f in os.listdir(profiles_dir) if f.lower().endswith('.json')]
|
||||
for vendor in vendors:
|
||||
current_profiles = []
|
||||
base_dir = vendor
|
||||
# Orca expects machine_model to be in the machine folder
|
||||
if profile_type == 'machine_model':
|
||||
profile_dir = os.path.join(base_dir, 'machine')
|
||||
else:
|
||||
profile_dir = os.path.join(base_dir, profile_type)
|
||||
|
||||
for root, dirs, files in os.walk(profile_dir):
|
||||
for file in files:
|
||||
if file.lower().endswith('.json'):
|
||||
full_path = os.path.join(root, file)
|
||||
|
||||
# Get relative path from base directory
|
||||
sub_path = os.path.relpath(full_path, base_dir).replace('\\', '/')
|
||||
|
||||
try:
|
||||
with open(full_path, 'r', encoding='utf-8') as f:
|
||||
_profile = json.load(f)
|
||||
if _profile.get('type') != profile_type:
|
||||
continue
|
||||
name = _profile.get('name')
|
||||
inherits = _profile.get('inherits')
|
||||
|
||||
if name:
|
||||
entry = {
|
||||
"name": name,
|
||||
"sub_path": sub_path
|
||||
}
|
||||
if inherits:
|
||||
entry['inherits'] = inherits
|
||||
current_profiles.append(entry)
|
||||
else:
|
||||
print(f"Warning: Missing 'name' in {full_path}")
|
||||
except Exception as e:
|
||||
print(f"Error reading {full_path}: {str(e)}")
|
||||
continue
|
||||
|
||||
# Sort profiles based on inheritance
|
||||
sorted_profiles = topological_sort(current_profiles)
|
||||
|
||||
# Remove the inherits field as it's not needed in the final JSON
|
||||
for p in sorted_profiles:
|
||||
p.pop('inherits', None)
|
||||
|
||||
# Update library file
|
||||
lib_path = f'{vendor}.json'
|
||||
|
||||
profile_section = profile_type+'_list'
|
||||
|
||||
try:
|
||||
with open(lib_path, 'r+', encoding='utf-8') as f:
|
||||
library = json.load(f)
|
||||
library[profile_section] = sorted_profiles
|
||||
f.seek(0)
|
||||
json.dump(library, f, indent="\t", ensure_ascii=False)
|
||||
f.write('\n')
|
||||
f.truncate()
|
||||
|
||||
print(f"Profile library for {vendor} updated successfully!")
|
||||
except Exception as e:
|
||||
print(f"Error updating library file: {str(e)}")
|
||||
|
||||
def clean_up_profile(vendor="", profile_type="", force=False):
|
||||
# change current working directory to the relative path(..\resources\profiles) compare to script location
|
||||
os.chdir(os.path.join(os.path.dirname(__file__), '..', 'resources', 'profiles'))
|
||||
|
||||
# Collect current profile entries
|
||||
if vendor:
|
||||
vendors = [vendor]
|
||||
else:
|
||||
profiles_dir = os.path.join(os.path.dirname(__file__), '..', 'resources', 'profiles')
|
||||
vendors = [f[:-5] for f in os.listdir(profiles_dir) if f.lower().endswith('.json')]
|
||||
for vendor in vendors:
|
||||
current_profiles = []
|
||||
base_dir = vendor
|
||||
# Orca expects machine_model to be in the machine folder
|
||||
if profile_type == 'machine_model':
|
||||
profile_dir = os.path.join(base_dir, 'machine')
|
||||
else:
|
||||
profile_dir = os.path.join(base_dir, profile_type)
|
||||
|
||||
for root, dirs, files in os.walk(profile_dir):
|
||||
for file in files:
|
||||
if file.lower().endswith('.json'):
|
||||
if file == 'filaments_color_codes.json': # Ignore non-profile file
|
||||
continue
|
||||
|
||||
full_path = os.path.join(root, file)
|
||||
|
||||
# Get relative path from base directory
|
||||
sub_path = os.path.relpath(full_path, base_dir).replace('\\', '/')
|
||||
|
||||
try:
|
||||
with open(full_path, 'r+', encoding='utf-8') as f:
|
||||
_profile = json.load(f)
|
||||
need_update = False
|
||||
if not _profile.get('type') or _profile.get('type') == "":
|
||||
need_update = True
|
||||
name = _profile.get('name')
|
||||
inherits = _profile.get('inherits')
|
||||
if profile_type == "machine_model" or profile_type == "machine":
|
||||
if "nozzle" in name or "Nozzle" in name:
|
||||
_profile['type'] = "machine"
|
||||
else:
|
||||
_profile['type'] = "machine_model"
|
||||
else:
|
||||
_profile['type'] = profile_type
|
||||
print(f"Added type: {_profile['type']} to {file}")
|
||||
|
||||
fields_to_remove = ['version', 'is_custom_defined']
|
||||
for field in fields_to_remove:
|
||||
if _profile.get(field):
|
||||
# remove version field
|
||||
del _profile[field]
|
||||
print(f"Removed {field} field from {file}")
|
||||
need_update = True
|
||||
|
||||
# Handle `extruder_clearance_radius`.
|
||||
if 'extruder_clearance_radius' in _profile and 'extruder_clearance_max_radius' in _profile:
|
||||
# BBS renamed `extruder_clearance_radius` to `extruder_clearance_max_radius`
|
||||
# however some of their profiles have both options exists with different value, which
|
||||
# could cause very bad consequence such as toolhead collision.
|
||||
# Here we make sure only one of these options exist, and if both present, we keep
|
||||
# the one with greater value.
|
||||
need_update = True
|
||||
if float(_profile['extruder_clearance_max_radius']) > float(_profile['extruder_clearance_radius']):
|
||||
del _profile['extruder_clearance_radius']
|
||||
else:
|
||||
del _profile['extruder_clearance_max_radius']
|
||||
|
||||
# Convert filament fields to arrays if not already
|
||||
if profile_type == 'filament':
|
||||
fields_to_arrayify = ['filament_cost', 'filament_density', 'filament_type', "temperature_vitrification", "filament_max_volumetric_speed", "filament_vendor"]
|
||||
for field in fields_to_arrayify:
|
||||
if field in _profile and not isinstance(_profile[field], list):
|
||||
original_value = _profile[field]
|
||||
_profile[field] = [original_value]
|
||||
print(f"Converted {field} to array in {file}")
|
||||
need_update = True
|
||||
|
||||
# remove following fields from filament profile
|
||||
fields_to_remove = ['initial_layer_print_speed', 'outer_wall_speed', 'inner_wall_speed', 'infill_speed', 'top_surface_speed', 'travel_speed']
|
||||
for field in fields_to_remove:
|
||||
if field in _profile:
|
||||
del _profile[field]
|
||||
print(f"Removed {field} field from {file}")
|
||||
need_update = True
|
||||
|
||||
|
||||
if need_update or force:
|
||||
# write back to file
|
||||
f.seek(0)
|
||||
ordered_profile = create_ordered_profile(_profile, ['type', 'name', 'renamed_from', 'inherits', 'from', 'setting_id', 'filament_id', 'instantiation'])
|
||||
json.dump(ordered_profile, f, indent="\t", ensure_ascii=False)
|
||||
f.write('\n')
|
||||
f.truncate()
|
||||
print(f"Updated profile: {full_path}")
|
||||
except Exception as e:
|
||||
print(f"Error reading {full_path}: {str(e)}")
|
||||
continue
|
||||
|
||||
# For each JSON file, it will:
|
||||
# - Replace "BBL X1C" with "System" in the name field
|
||||
# - Empty the compatible_printers array
|
||||
# - Ensure setting_id starts with 'O'
|
||||
def rename_filament_system(vendor="OrcaFilamentLibrary"):
|
||||
# change current working directory to the relative path
|
||||
os.chdir(os.path.join(os.path.dirname(__file__), '..', 'resources', 'profiles'))
|
||||
|
||||
base_dir = vendor
|
||||
filament_dir = os.path.join(base_dir, 'filament')
|
||||
|
||||
for root, dirs, files in os.walk(filament_dir):
|
||||
for file in files:
|
||||
if file.lower().endswith('.json'):
|
||||
full_path = os.path.join(root, file)
|
||||
try:
|
||||
with open(full_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
modified = False
|
||||
|
||||
# Update name if it contains "BBL X1C"
|
||||
if 'name' in data and "BBL X1C" in data['name']:
|
||||
data['name'] = data['name'].replace("BBL X1C", "System")
|
||||
modified = True
|
||||
|
||||
# Empty compatible_printers if exists
|
||||
if 'compatible_printers' in data:
|
||||
data['compatible_printers'] = []
|
||||
modified = True
|
||||
|
||||
# Update setting_id if needed
|
||||
if 'setting_id' in data and not data['setting_id'].startswith('O'):
|
||||
data['setting_id'] = 'O' + data['setting_id']
|
||||
modified = True
|
||||
|
||||
if modified:
|
||||
with open(full_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent="\t", ensure_ascii=False)
|
||||
f.write('\n')
|
||||
print(f"Updated {full_path}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing {full_path}: {str(e)}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description='Update filament library for specified vendor')
|
||||
parser.add_argument('-v', '--vendor', type=str, default="",
|
||||
help='Vendor name (default: "" which means all vendors)')
|
||||
parser.add_argument('-u', '--update', action='store_true', help='update vendor.json')
|
||||
parser.add_argument('-p', '--profile_type', type=str, choices=['machine_model', 'process', 'filament', 'machine'], help='profile type (default: "" which means all types)')
|
||||
parser.add_argument('-f', '--fix', action='store_true', help='Fix errors like missing type field, and clean up the profile')
|
||||
parser.add_argument('--force', action='store_true', help='Force update the profile files, for --fix option')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.fix:
|
||||
if(args.profile_type):
|
||||
clean_up_profile(args.vendor, args.profile_type, args.force)
|
||||
else:
|
||||
clean_up_profile(args.vendor, 'machine_model', args.force)
|
||||
clean_up_profile(args.vendor, 'process', args.force)
|
||||
clean_up_profile(args.vendor, 'filament', args.force)
|
||||
clean_up_profile(args.vendor, 'machine', args.force)
|
||||
|
||||
if args.update:
|
||||
update_profile_library(args.vendor, 'machine_model')
|
||||
update_profile_library(args.vendor, 'process')
|
||||
update_profile_library(args.vendor, 'filament')
|
||||
update_profile_library(args.vendor, 'machine')
|
||||
# else:
|
||||
# rename_filament_system(args.vendor)
|
||||
Executable
+2419
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,9 @@
|
||||
#
|
||||
# Usage: run_unit_tests.sh [TEST_DIR] [BUILD_CONFIG]
|
||||
# TEST_DIR directory containing the built tests (default: build/tests)
|
||||
# BUILD_CONFIG configuration to run; required for multi-config generators
|
||||
# (Windows/macOS), harmless/omitted for single-config (Linux).
|
||||
# BUILD_CONFIG configuration to run; required for multi-config generators, which all
|
||||
# build scripts use (build_linux.sh too: Ninja Multi-Config). Without it,
|
||||
# tests registered with plain add_test() lose their labels and report "Not Run".
|
||||
|
||||
ROOT_DIR="$(dirname "$0")/.."
|
||||
|
||||
@@ -17,8 +18,9 @@ cd "${ROOT_DIR}" || exit 1
|
||||
TEST_DIR="${1:-build/tests}"
|
||||
BUILD_CONFIG="${2:-}"
|
||||
|
||||
# Run the whole suite, excluding tests tagged [NotWorking].
|
||||
# Run the whole suite, excluding tests tagged [NotWorking] and tests labelled RequiresApp,
|
||||
# which run the built orca-slicer binary that this directory does not contain.
|
||||
# --no-tests=error fails the job if the filter matches nothing (instead of passing green).
|
||||
args=(--test-dir "${TEST_DIR}" -LE "NotWorking" --no-tests=error --output-junit "$(pwd)/ctest_results.xml" --output-on-failure -j)
|
||||
args=(--test-dir "${TEST_DIR}" -LE "NotWorking|RequiresApp" --no-tests=error --output-junit "$(pwd)/ctest_results.xml" --output-on-failure -j)
|
||||
[ -n "${BUILD_CONFIG}" ] && args+=(--build-config "${BUILD_CONFIG}")
|
||||
ctest "${args[@]}"
|
||||
|
||||
@@ -0,0 +1,969 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Tests build_win.bat's option handling and the commands it generates.
|
||||
|
||||
.DESCRIPTION
|
||||
Cases run the script with --dry-run, so nothing is configured, built or
|
||||
deleted and the suite finishes in seconds. Each case asserts on the exit
|
||||
code and on the command lines the script echoes.
|
||||
|
||||
Adding a case means adding one row to $cases. A bare string starts a new
|
||||
group. Defaults: ExpectExit is 0 and --dry-run is appended, so a row only
|
||||
states what is unusual about it.
|
||||
|
||||
Name what the case proves, in words
|
||||
Args arguments, as an array
|
||||
ExpectExit expected exit code (default 0)
|
||||
DryRun append --dry-run (default $true)
|
||||
First regex the first output line must match
|
||||
Env environment for this case only
|
||||
Contains literal strings the output must have
|
||||
NotContains literal strings it must not have
|
||||
Match regexes; each must match at least one output line
|
||||
NotMatch regexes; none may match any output line
|
||||
NotExists paths that must not exist after the case runs
|
||||
DateStampedZip require a bundle date from during this case's invocation
|
||||
|
||||
.PARAMETER Name
|
||||
Run only the cases whose name matches this regex. Headings with no
|
||||
matching case are not printed, and a pattern that matches nothing is a
|
||||
failure rather than an empty pass.
|
||||
|
||||
.EXAMPLE
|
||||
powershell -File scripts/test_build_win.ps1
|
||||
|
||||
.EXAMPLE
|
||||
powershell -File scripts/test_build_win.ps1 -Name solution
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $Script,
|
||||
[string] $Name
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not $Script) {
|
||||
$here = $PSScriptRoot
|
||||
if (-not $here) { $here = Split-Path -Parent $MyInvocation.MyCommand.Path }
|
||||
$Script = Join-Path (Split-Path -Parent $here) 'build_win.bat'
|
||||
}
|
||||
if (-not (Test-Path $Script)) { throw "build_win.bat not found at $Script" }
|
||||
|
||||
# cmd resumes a batch file by byte offset after `call :label`, and with LF
|
||||
# endings that offset lands wrong and the label lookup fails. .gitattributes
|
||||
# pins CRLF; this catches a checkout or an editor that did not honour it.
|
||||
if ((Get-Content -Raw $Script) -match "(?<!`r)`n") {
|
||||
throw "$Script has LF line endings; cmd needs CRLF to resume after call :label"
|
||||
}
|
||||
$Script = (Resolve-Path $Script).Path
|
||||
|
||||
# Read the long options out of the script itself, so this cannot go stale when
|
||||
# an option is added. Field order is: add_arg <var> <type> <short> <long>.
|
||||
$longFlags = @(
|
||||
Select-String -Path $Script -Pattern '^call :add_arg \S+ \S+ \S+ (\S+) ' |
|
||||
ForEach-Object { '--' + $_.Matches[0].Groups[1].Value }
|
||||
)
|
||||
if ($longFlags.Count -lt 20) { throw "only found $($longFlags.Count) options in $Script; the parser above is wrong" }
|
||||
|
||||
# A winget that always fails, so the prerequisite failure path runs without
|
||||
# touching the machine. It has to be an .exe: a .bat invoked without `call`
|
||||
# transfers control and never comes back, which would end the script instead.
|
||||
# where.exe returns 1 when its patterns match nothing and never prompts.
|
||||
$fixtures = Join-Path ([IO.Path]::GetTempPath()) 'build_win_test_fixtures'
|
||||
New-Item -ItemType Directory -Force -Path $fixtures | Out-Null
|
||||
Copy-Item "$env:SystemRoot\System32\where.exe" (Join-Path $fixtures 'winget.exe') -Force
|
||||
$stubPath = "$fixtures;C:\Windows\system32;C:\Windows"
|
||||
|
||||
# Stand-in ninjas that only report a version, so the 1.12 boundary in the
|
||||
# progress format can be exercised on a machine whose real ninja is newer.
|
||||
# A dry run skips the dev shell, so PATH here is what the script sees.
|
||||
$ninjaPaths = @{}
|
||||
foreach ($v in @{ old = '1.11.1'; new = '1.12.0' }.GetEnumerator()) {
|
||||
$d = Join-Path $fixtures "ninja-$($v.Key)"
|
||||
New-Item -ItemType Directory -Force -Path $d | Out-Null
|
||||
Set-Content -Path (Join-Path $d 'ninja.bat') -Encoding ascii -Value @('@echo off', "echo $($v.Value)")
|
||||
$ninjaPaths[$v.Key] = "$d;$env:PATH"
|
||||
}
|
||||
|
||||
# A clang-cl earlier on PATH than the Visual Studio one, which is what the
|
||||
# compiler used to resolve to. Nothing runs it; the script only locates it.
|
||||
$clangDir = Join-Path $fixtures 'clang'
|
||||
New-Item -ItemType Directory -Force -Path $clangDir | Out-Null
|
||||
Copy-Item "$env:SystemRoot\System32\where.exe" (Join-Path $clangDir 'clang-cl.exe') -Force
|
||||
$clangOnPath = "$clangDir;$env:PATH"
|
||||
|
||||
# A ccache that only has to exist. Nothing runs it; the script only locates it.
|
||||
$cacheDir = Join-Path $fixtures 'cache'
|
||||
New-Item -ItemType Directory -Force -Path $cacheDir | Out-Null
|
||||
Copy-Item "$env:SystemRoot\System32\where.exe" (Join-Path $cacheDir 'ccache.exe') -Force
|
||||
$ccacheOnPath = "$cacheDir;$env:PATH"
|
||||
|
||||
# ProgramFiles(x86) is where the script looks for vswhere, so an empty one
|
||||
# stands in for a machine whose Visual Studio has no clang toolset.
|
||||
$noVs = Join-Path $fixtures 'no-vs'
|
||||
New-Item -ItemType Directory -Force -Path $noVs | Out-Null
|
||||
|
||||
# A build directory that already holds a classic solution, for the case where
|
||||
# what is on disk disagrees with what the generator would write.
|
||||
$slnDir = Join-Path $fixtures 'sln'
|
||||
New-Item -ItemType Directory -Force -Path $slnDir | Out-Null
|
||||
Set-Content -Path (Join-Path $slnDir 'OrcaSlicer.sln') -Value '' -Encoding ascii
|
||||
|
||||
$cases = @(
|
||||
'argument handling'
|
||||
@{ Name = 'no arguments prints help'; Args = @(); DryRun = $false
|
||||
Contains = @('Usage: build_win.bat [options]', '--clang-cl') }
|
||||
@{ Name = "--help lists all $($longFlags.Count) options the script defines"; Args = @('--help'); DryRun = $false
|
||||
Contains = $longFlags }
|
||||
@{ Name = 'help is grouped and shows usage, examples and environment'; Args = @('--help'); DryRun = $false
|
||||
Contains = @('Usage: build_win.bat [options]', 'Actions:', 'Build configuration:', 'Toolchain:',
|
||||
'How much gets rebuilt:', 'Paths and extra arguments:', 'Diagnostics:',
|
||||
'Examples:', 'Environment:') }
|
||||
@{ Name = 'the environment section shows what to set'; Args = @('--help'); DryRun = $false
|
||||
Contains = @('ORCA_DEPS_CMAKE_ARGS', 'ORCA_SLICER_CMAKE_ARGS', 'ORCA_UPDATER_SIG_KEY', 'NINJA_STATUS',
|
||||
'set ORCA_SLICER_CMAKE_ARGS=-DSLIC3R_BUILD_SANDBOXES=ON', '(PowerShell)', 'debugscript') }
|
||||
@{ Name = 'section headers do not widen the flag column'; Args = @('--help'); DryRun = $false
|
||||
Match = @('^ -d, --deps +Download') }
|
||||
# Windows Terminal opens at 120 columns and wraps at 120, so 119 is the
|
||||
# limit. Anyone still on the old conhost gets 80 and will see wrapping.
|
||||
@{ Name = 'every help line fits a 120 column console'; Args = @('--help'); DryRun = $false
|
||||
NotMatch = @('^.{120,}$') }
|
||||
@{ Name = 'an unknown long option is rejected'; Args = @('--nonsense'); ExpectExit = 1
|
||||
Contains = @('Failed to find arg') }
|
||||
@{ Name = 'an unknown short option is rejected'; Args = @('-Z'); ExpectExit = 1
|
||||
Contains = @('Failed to find arg') }
|
||||
@{ Name = 'a bare argument is rejected'; Args = @('deps'); ExpectExit = 1
|
||||
Contains = @('Unknown argument') }
|
||||
@{ Name = 'an unknown architecture is rejected'; Args = @('-d', '--arch', 'sparc'); ExpectExit = 1
|
||||
Contains = @('Unknown architecture') }
|
||||
@{ Name = 'a string option without a value is rejected'; Args = @('-d', '--arch'); ExpectExit = 1
|
||||
Contains = @('requires a value') }
|
||||
@{ Name = 'short options can be bundled'; Args = @('-dx')
|
||||
Contains = @('-G "Ninja Multi-Config"', '--target deps') }
|
||||
|
||||
'generator and compiler selection'
|
||||
@{ Name = 'deps default to the Visual Studio generator'; Args = @('-d')
|
||||
Contains = @('-G "Visual Studio', '-A x64', '--target deps')
|
||||
NotContains = @('Ninja', 'clang-cl') }
|
||||
@{ Name = '-x selects Ninja without changing compiler'; Args = @('-d', '-x')
|
||||
Contains = @('-G "Ninja Multi-Config"')
|
||||
NotContains = @('clang-cl', '-A x64') }
|
||||
@{ Name = '-l -x builds with clang-cl under Ninja'; Args = @('-d', '-l', '-x')
|
||||
Contains = @('-G "Ninja Multi-Config"')
|
||||
Match = @('-DCMAKE_C_COMPILER="[^"]+/clang-cl\.exe"', '-DCMAKE_CXX_COMPILER="[^"]+/clang-cl\.exe"') }
|
||||
# PATH order used to decide the compiler. VsDevCmd appends the Visual
|
||||
# Studio LLVM directory to the end of PATH, so a standalone LLVM already
|
||||
# there was resolved instead, and an old one failed the compiler check.
|
||||
@{ Name = 'the compiler is resolved from Visual Studio, not PATH'; Args = @('-s', '-l', '-x')
|
||||
Env = @{ PATH = $clangOnPath }
|
||||
Match = @('^Compiler: .*/VC/Tools/Llvm/[^/]+/bin/clang-cl\.exe$') }
|
||||
@{ Name = 'msvc names no compiler, having resolved none'; Args = @('-s')
|
||||
NotContains = @('Compiler: ') }
|
||||
@{ Name = '-l without -x names none either, the toolset picks it'; Args = @('-s', '-l')
|
||||
NotContains = @('Compiler: ') }
|
||||
# An empty ProgramFiles(x86) puts vswhere out of reach, which is a machine
|
||||
# whose Visual Studio has no clang toolset.
|
||||
@{ Name = 'without a Visual Studio clang the one on PATH is used and named'; Args = @('-s', '-l', '-x')
|
||||
Env = @{ 'ProgramFiles(x86)' = $noVs; PATH = $clangOnPath }
|
||||
Contains = @('Visual Studio has no clang-cl')
|
||||
Match = @('^Compiler: .*/clang/clang-cl\.exe$') }
|
||||
@{ Name = 'no clang-cl anywhere stops before configuring'; Args = @('-s', '-l', '-x'); ExpectExit = 1
|
||||
Env = @{ 'ProgramFiles(x86)' = $noVs; PATH = 'C:\Windows\system32;C:\Windows' }
|
||||
Contains = @('No clang-cl found', '--install-vs ide -l')
|
||||
NotContains = @('cmake -B') }
|
||||
# Only a configure passes the compiler to CMake, so an action that does
|
||||
# not configure resolves none, and cannot start needing one installed.
|
||||
@{ Name = 'packing resolves no compiler'; Args = @('-p', '-l', '-x')
|
||||
Contains = @('Packing the dependencies')
|
||||
NotContains = @('Compiler: ') }
|
||||
@{ Name = '--no-configure resolves none either'; Args = @('-s', '-l', '-x', '--no-configure')
|
||||
Contains = @('cmake --build "build-clang"')
|
||||
NotContains = @('Compiler: ') }
|
||||
@{ Name = '-l alone uses the ClangCL toolset on the VS generator'; Args = @('-d', '-l')
|
||||
Contains = @('-G "Visual Studio', '-T ClangCL')
|
||||
NotContains = @('-DCMAKE_C_COMPILER') }
|
||||
# --msvc and --msbuild name the defaults, so a caller can be explicit and a
|
||||
# contradictory pair can be caught rather than silently resolved.
|
||||
@{ Name = '--msvc is the compiler default spelled out'; Args = @('-d', '--msvc')
|
||||
Contains = @('-G "Visual Studio', '-A x64')
|
||||
NotContains = @('clang-cl') }
|
||||
@{ Name = '--msbuild is the generator default spelled out'; Args = @('-d', '--msbuild')
|
||||
Contains = @('-G "Visual Studio')
|
||||
NotContains = @('Ninja') }
|
||||
@{ Name = '--msvc with -x gives Ninja driving cl'; Args = @('-d', '--msvc', '-x')
|
||||
Contains = @('-G "Ninja Multi-Config"')
|
||||
NotContains = @('clang-cl') }
|
||||
@{ Name = '--msbuild with -l gives the VS generator and the ClangCL toolset'; Args = @('-d', '--msbuild', '-l')
|
||||
Contains = @('-G "Visual Studio', '-T ClangCL') }
|
||||
# A developer with a standalone LLVM points at it, and the path is passed
|
||||
# with forward slashes so CMake cannot read a backslash as an escape.
|
||||
@{ Name = '--clang-path names the compiler, quoted for its spaces'; Args = @('-d', '-x', '--clang-path', 'C:\Program Files\LLVM\bin\clang-cl.exe')
|
||||
Contains = @('-DCMAKE_C_COMPILER="C:/Program Files/LLVM/bin/clang-cl.exe"',
|
||||
'-DCMAKE_CXX_COMPILER="C:/Program Files/LLVM/bin/clang-cl.exe"') }
|
||||
@{ Name = '--clang-path beats the Visual Studio clang'; Args = @('-s', '-x', '--clang-path', 'C:\Program Files\LLVM\bin\clang-cl.exe')
|
||||
Contains = @('Compiler: C:/Program Files/LLVM/bin/clang-cl.exe') }
|
||||
@{ Name = '--clang-path is a clang request on its own'; Args = @('-d', '-x', '--clang-path', 'C:\Program Files\LLVM\bin\clang-cl.exe')
|
||||
Contains = @('deps/build-clang') }
|
||||
@{ Name = '--clang-path needs Ninja to take effect'; Args = @('-d', '--clang-path', 'C:\Program Files\LLVM\bin\clang-cl.exe'); ExpectExit = 1
|
||||
Contains = @('needs the Ninja generator') }
|
||||
# `exist` is true for a directory too, and a directory would reach CMake
|
||||
# as the compiler.
|
||||
@{ Name = '--clang-path must name the exe, not its folder'; Args = @('-d', '-x', '--clang-path', 'C:\Program Files\LLVM'); ExpectExit = 1
|
||||
Contains = @('is a directory') }
|
||||
@{ Name = 'a clang-cl that is not there is caught early'; Args = @('-d', '-x', '--clang-path', 'C:\nope\clang-cl.exe'); ExpectExit = 1
|
||||
Contains = @('No clang-cl at')
|
||||
NotContains = @('cmake -S deps') }
|
||||
@{ Name = '--clang-path contradicting --msvc is rejected'; Args = @('-d', '-x', '--msvc', '--clang-path', 'C:\Program Files\LLVM\bin\clang-cl.exe'); ExpectExit = 1
|
||||
Contains = @('select different compilers') }
|
||||
@{ Name = '--clang-cl and --msvc together are rejected'; Args = @('-d', '-l', '--msvc'); ExpectExit = 1
|
||||
Contains = @('select different compilers') }
|
||||
@{ Name = '--ninja and --msbuild together are rejected'; Args = @('-d', '-x', '--msbuild'); ExpectExit = 1
|
||||
Contains = @('select different generators') }
|
||||
# One option with a value, driven by a table, rather than a flag per
|
||||
# release. Adding a release should not need a new flag.
|
||||
@{ Name = '--vs 2019 pins that release and skips autodetect'; Args = @('-d', '--vs', '2019')
|
||||
Contains = @('-G "Visual Studio 16 2019"')
|
||||
NotContains = @('Detecting Visual Studio') }
|
||||
@{ Name = '--vs 2022 pins that release'; Args = @('-d', '--vs', '2022')
|
||||
Contains = @('-G "Visual Studio 17 2022"') }
|
||||
@{ Name = '--vs 2026 pins that release'; Args = @('-d', '--vs', '2026')
|
||||
Contains = @('-G "Visual Studio 18 2026"') }
|
||||
@{ Name = 'an unknown release is rejected and the known ones listed'; Args = @('-d', '--vs', '2015'); ExpectExit = 1
|
||||
Contains = @('Unknown Visual Studio release', '2019, 2022, 2026') }
|
||||
@{ Name = '--vs and --ninja together are rejected'; Args = @('-d', '--vs', '2022', '-x'); ExpectExit = 1
|
||||
Contains = @('select different generators') }
|
||||
@{ Name = 'without --vs the release is autodetected'; Args = @('-d')
|
||||
Contains = @('Detecting Visual Studio') }
|
||||
|
||||
'architecture'
|
||||
@{ Name = 'x64 is the default'; Args = @('-d')
|
||||
Contains = @('Configuration: Release, x64')
|
||||
NotContains = @('build-arm64') }
|
||||
@{ Name = 'arm64 sets the generator platform and deps tree'; Args = @('-d', '--arch', 'arm64')
|
||||
Contains = @('-A ARM64', 'deps/build-arm64') }
|
||||
@{ Name = 'the architecture is matched case-insensitively'; Args = @('-d', '--arch', 'ARM64')
|
||||
Contains = @('-A ARM64', 'deps/build-arm64') }
|
||||
@{ Name = 'arm64 under Ninja has no -A but keeps the arm64 tree'; Args = @('-d', '--arch', 'arm64', '-x', '-l')
|
||||
Contains = @('deps/build-clang-arm64')
|
||||
Match = @('-DCMAKE_C_COMPILER="[^"]+/clang-cl\.exe"')
|
||||
NotContains = @('-A ') }
|
||||
|
||||
'build configurations'
|
||||
# One option with a value, driven by a table, so Release is named rather
|
||||
# than being whatever is left when no flag is passed.
|
||||
@{ Name = 'release is the default'; Args = @('-s')
|
||||
Contains = @('-DCMAKE_BUILD_TYPE=Release', 'cmake -B "build" ') }
|
||||
@{ Name = '--config release is the default spelled out'; Args = @('-s', '--config', 'release')
|
||||
Contains = @('-DCMAKE_BUILD_TYPE=Release', 'cmake -B "build" ') }
|
||||
@{ Name = '--config debug builds into build-dbg'; Args = @('-s', '--config', 'debug')
|
||||
Contains = @('-DCMAKE_BUILD_TYPE=Debug', 'cmake -B "build-dbg" ') }
|
||||
@{ Name = '--config relwithdebinfo builds into build-dbginfo'; Args = @('-s', '--config', 'relwithdebinfo')
|
||||
Contains = @('-DCMAKE_BUILD_TYPE=RelWithDebInfo', 'cmake -B "build-dbginfo" ') }
|
||||
@{ Name = '--config minsizerel builds into build-minsize'; Args = @('-s', '--config', 'minsizerel')
|
||||
Contains = @('-DCMAKE_BUILD_TYPE=MinSizeRel', 'cmake -B "build-minsize" ') }
|
||||
# Batch variable names are case-insensitive, so the table lookup is too.
|
||||
@{ Name = 'the configuration name is matched case-insensitively'; Args = @('-s', '--config', 'RelWithDebInfo')
|
||||
Contains = @('-DCMAKE_BUILD_TYPE=RelWithDebInfo') }
|
||||
@{ Name = 'an unknown configuration is rejected and the known ones listed'; Args = @('-s', '--config', 'bogus'); ExpectExit = 1
|
||||
Contains = @('Unknown configuration', 'release, debug, relwithdebinfo, minsizerel') }
|
||||
# Release, RelWithDebInfo and MinSizeRel all link the /MD dependencies, so
|
||||
# one tree serves all three. Debug is /MDd and cannot share.
|
||||
@{ Name = 'minsizerel builds against the release deps'; Args = @('-d', '-s', '--config', 'minsizerel')
|
||||
Contains = @('cmake -S deps -B "deps/build"', 'cmake -B "build-minsize" ')
|
||||
NotContains = @('deps/build-minsize') }
|
||||
@{ Name = 'relwithdebinfo builds against them too'; Args = @('-d', '-s', '-l', '--config', 'relwithdebinfo')
|
||||
Contains = @('cmake -S deps -B "deps/build-clang"', 'cmake -B "build-dbginfo-clang" ')
|
||||
NotContains = @('deps/build-dbginfo') }
|
||||
@{ Name = 'debug keeps a dependency tree of its own'; Args = @('-d', '--config', 'debug')
|
||||
Contains = @('cmake -S deps -B "deps/build-dbg"') }
|
||||
# The build tree and the deps tree now have different names, so the
|
||||
# derivation from the binary directory name cannot work and it is named.
|
||||
@{ Name = 'a shared deps tree is named outright'; Args = @('-s', '-l', '--config', 'relwithdebinfo')
|
||||
Match = @('-DDEP_BUILD_DIR="[A-Za-z]:\\.*\\deps\\build-clang"') }
|
||||
@{ Name = 'configuration and arch combine into build-dbg-arm64'; Args = @('-s', '--config', 'debug', '--arch', 'arm64')
|
||||
Contains = @('cmake -B "build-dbg-arm64" ') }
|
||||
|
||||
'what gets built'
|
||||
@{ Name = 'the deps target defaults to deps'; Args = @('-d')
|
||||
Contains = @('--target deps') }
|
||||
@{ Name = '-t overrides the deps target'; Args = @('-d', '-t', 'dep_Boost')
|
||||
Contains = @('--target dep_Boost') }
|
||||
@{ Name = 'unit tests are off unless asked for'; Args = @('-s')
|
||||
Contains = @('-DBUILD_TESTS=OFF') }
|
||||
@{ Name = '--tests turns the unit tests on'; Args = @('-s', '--tests')
|
||||
Contains = @('-DBUILD_TESTS=ON') }
|
||||
@{ Name = '-a enables ASAN for the slicer'; Args = @('-s', '-a')
|
||||
Contains = @('-DSLIC3R_ASAN=ON') }
|
||||
@{ Name = '--no-pch turns the precompiled header off'; Args = @('-s', '--no-pch')
|
||||
Contains = @('-DSLIC3R_PCH=OFF') }
|
||||
@{ Name = '--no-pch says so in the banner'; Args = @('-s', '--no-pch')
|
||||
Contains = @('Precompiled header: off') }
|
||||
@{ Name = 'the precompiled header is on unless asked'; Args = @('-s')
|
||||
NotContains = @('SLIC3R_PCH') }
|
||||
@{ Name = '--no-pch works without a cache'; Args = @('-s', '--no-pch')
|
||||
NotContains = @('COMPILER_LAUNCHER') }
|
||||
@{ Name = 'the slicer build runs gettext'; Args = @('-s')
|
||||
Contains = @('run_gettext.bat') }
|
||||
# tools\7z.exe needs a 7z.dll beside it, which the repo does not carry,
|
||||
# so the pack falls back to the bsdtar Windows ships. Either is correct;
|
||||
# what matters is that one is chosen and handed the right names.
|
||||
@{ Name = '-p packs the deps tree with whichever archiver is usable'; Args = @('-d', '-p')
|
||||
Match = @('^\+ .*(7z\.exe a|tar\.exe -a -c -f) OrcaSlicer_dep_win-\S+\.zip OrcaSlicer_dep$') }
|
||||
# The bundle is only good for what built it, so it carries the same three
|
||||
# axes as the tree. Release x64 on cl keeps the historical plain name.
|
||||
@{ Name = 'the bundle name carries compiler and deps flavour'; Args = @('-p', '-l', '--config', 'debug', '--arch', 'arm64')
|
||||
Contains = @('OrcaSlicer_dep_win-ARM64-clang-dbg_') }
|
||||
@{ Name = 'a relwithdebinfo pack is the release bundle'; Args = @('-p', '--config', 'relwithdebinfo')
|
||||
Contains = @('OrcaSlicer_dep_win-x64_')
|
||||
NotContains = @('-dbg', 'dbginfo') }
|
||||
@{ Name = 'a plain release bundle keeps its old name'; Args = @('-p')
|
||||
Contains = @('OrcaSlicer_dep_win-x64_')
|
||||
NotContains = @('-clang', '-Release') }
|
||||
@{ Name = 'the bundle is stamped with today, not a shuffled date'; Args = @('-p')
|
||||
DateStampedZip = $true }
|
||||
# powershell.exe is not in System32 itself, so a trimmed PATH used to
|
||||
# leave the stamp empty and the bundle named OrcaSlicer_dep_win-x64_.zip.
|
||||
@{ Name = 'the bundle is stamped even with a bare PATH'; Args = @('-p')
|
||||
Env = @{ PATH = 'C:\Windows\system32;C:\Windows' }
|
||||
DateStampedZip = $true }
|
||||
@{ Name = '-p packs without rebuilding'; Args = @('-p')
|
||||
Match = @('^\+ .*(7z\.exe a|tar\.exe -a -c -f) ')
|
||||
NotContains = @('cmake -S deps') }
|
||||
@{ Name = 'deps and slicer build in one invocation'; Args = @('-d', '-s', '-x', '-l')
|
||||
Contains = @('cmake -S deps', 'cmake -B "build-clang" ') }
|
||||
|
||||
'the compiler cache'
|
||||
@{ Name = '--cache needs clang-cl and Ninja'; Args = @('-s', '--cache', 'ccache'); ExpectExit = 1
|
||||
Contains = @('needs clang-cl and Ninja') }
|
||||
# cl.exe is out of scope, since ccache refuses every compile under /Zi.
|
||||
@{ Name = '--cache under Ninja still needs clang-cl'; Args = @('-s', '-x', '--cache', 'ccache'); ExpectExit = 1
|
||||
Contains = @('needs clang-cl and Ninja') }
|
||||
@{ Name = 'an unknown --cache value is rejected'; Args = @('-s', '-x', '--cache', 'nope'); ExpectExit = 1
|
||||
Contains = @('Expected ccache, sccache or off') }
|
||||
# A bare PATH, since the machine running the tests may have sccache installed.
|
||||
@{ Name = 'a --cache tool that is not there is caught early'; Args = @('-s', '-l', '-x', '--cache', 'sccache'); ExpectExit = 1
|
||||
Env = @{ PATH = 'C:\Windows\system32;C:\Windows' }
|
||||
Contains = @('is not on PATH') }
|
||||
@{ Name = '--cache takes any casing'; Args = @('-s', '-l', '-x', '--cache', 'CCACHE')
|
||||
Env = @{ PATH = $ccacheOnPath }
|
||||
Contains = @('ccache.exe') }
|
||||
@{ Name = '--cache off asks for no launcher'; Args = @('-s', '-x', '--cache', 'off')
|
||||
NotContains = @('COMPILER_LAUNCHER') }
|
||||
@{ Name = 'no --cache asks for no launcher'; Args = @('-s', '-x')
|
||||
NotContains = @('COMPILER_LAUNCHER') }
|
||||
@{ Name = '--cache turns the precompiled header off'; Args = @('-s', '-l', '-x', '--cache', 'ccache')
|
||||
Env = @{ PATH = $ccacheOnPath }
|
||||
Contains = @('-DSLIC3R_PCH=OFF', 'COMPILER_LAUNCHER') }
|
||||
# Without it the objects name the build directory and only that tree can use them.
|
||||
@{ Name = '--cache asks for relative debug paths'; Args = @('-s', '-l', '-x', '--cache', 'ccache')
|
||||
Env = @{ PATH = $ccacheOnPath }
|
||||
Contains = @('-DSLIC3R_RELATIVE_DEBUG_PATHS=ON') }
|
||||
@{ Name = 'no --cache leaves the debug paths alone'; Args = @('-s', '-l', '-x')
|
||||
NotContains = @('SLIC3R_RELATIVE_DEBUG_PATHS') }
|
||||
# The resolved path, not the bare name, so PATH cannot change it later.
|
||||
@{ Name = '--cache names the resolved path in the banner'; Args = @('-s', '-l', '-x', '--cache', 'ccache')
|
||||
Env = @{ PATH = $ccacheOnPath }
|
||||
Match = @('^Compiler cache: .*/ccache\.exe$') }
|
||||
@{ Name = '--cache reaches the dependency configure too'; Args = @('-d', '-l', '-x', '--cache', 'ccache')
|
||||
Env = @{ PATH = $ccacheOnPath }
|
||||
Contains = @('-DCMAKE_C_COMPILER_LAUNCHER=') }
|
||||
# Nothing records a launcher without a configure, so the tool is not needed.
|
||||
# Reaching the cmake check on a bare PATH is what proves it was skipped.
|
||||
@{ Name = '--no-configure asks for no cache tool'; Args = @('-s', '-l', '-x', '--no-configure', '--cache', 'ccache'); ExpectExit = 1
|
||||
Env = @{ PATH = 'C:\Windows\system32;C:\Windows' }
|
||||
Contains = @('CMake was not found')
|
||||
NotContains = @('is not on PATH') }
|
||||
|
||||
'the developer loop'
|
||||
@{ Name = '--slicer-target builds one target'; Args = @('-s', '--slicer-target', 'libslic3r')
|
||||
Contains = @('--config Release --target libslic3r') }
|
||||
@{ Name = '--no-configure skips the slicer configure'; Args = @('-s', '--no-configure')
|
||||
Contains = @('cmake --build "build"')
|
||||
NotContains = @('cmake -B "build" ') }
|
||||
@{ Name = '--no-configure skips the deps configure'; Args = @('-d', '--no-configure')
|
||||
Contains = @('cmake --build "deps/build"')
|
||||
NotContains = @('cmake -S deps') }
|
||||
@{ Name = '--no-gettext skips the translation step'; Args = @('-s', '--no-gettext')
|
||||
Contains = @('cmake --build "build"')
|
||||
NotContains = @('run_gettext') }
|
||||
# Installing copies the whole tree again for a layout only releases need,
|
||||
# so it is asked for rather than assumed.
|
||||
@{ Name = 'nothing is installed unless asked'; Args = @('-s')
|
||||
Contains = @('run_gettext')
|
||||
NotContains = @('--target install') }
|
||||
@{ Name = '-i adds the install step'; Args = @('-s', '-i')
|
||||
Contains = @('--target install') }
|
||||
# install(TARGETS OrcaSlicer) has no OPTIONAL, so this would fail partway
|
||||
# through a build instead of before it.
|
||||
@{ Name = 'installing a tree with no executable is refused'; Args = @('-s', '-i', '--slicer-target', 'glad'); ExpectExit = 1
|
||||
Contains = @('--install needs the executable')
|
||||
NotContains = @('cmake --build') }
|
||||
# Without -s there is no install step, so there is nothing to refuse.
|
||||
@{ Name = 'the same flags without a slicer build are left alone'; Args = @('-d', '-i', '--slicer-target', 'glad')
|
||||
Contains = @('cmake -S deps')
|
||||
NotContains = @('--install needs the executable') }
|
||||
@{ Name = 'naming the executable target is allowed'; Args = @('-s', '-i', '--slicer-target', 'OrcaSlicer')
|
||||
Contains = @('--target install') }
|
||||
# Ninja counts compilers, so -j goes on the command line. MSBuild's -j
|
||||
# counts projects while /MP still runs one cl per core inside each, so the
|
||||
# cap goes to CL_MPCount in the environment instead. A dry run can only
|
||||
# show that no -j is passed.
|
||||
@{ Name = '-j on Ninja passes -j to cmake'; Args = @('-s', '-x', '-j', '4')
|
||||
Contains = @('Parallel jobs: 4', '--config Release -j 4') }
|
||||
@{ Name = '-j on Ninja reaches the deps build'; Args = @('-d', '-x', '-j', '2')
|
||||
Contains = @('--target deps -j 2') }
|
||||
@{ Name = '-j on MSBuild does not pass -j, which would count projects'; Args = @('-s', '-j', '4')
|
||||
Contains = @('Parallel jobs: 4')
|
||||
NotContains = @('-j 4') }
|
||||
@{ Name = '-j on MSBuild leaves the deps build without -j too'; Args = @('-d', '-j', '2')
|
||||
Contains = @('--target deps')
|
||||
NotContains = @('-j 2') }
|
||||
@{ Name = 'no -j means no job limit'; Args = @('-s')
|
||||
NotContains = @('parallel jobs', '-j ') }
|
||||
@{ Name = 'a non-numeric -j is rejected'; Args = @('-s', '-j', 'abc'); ExpectExit = 1
|
||||
Contains = @('Invalid --jobs value') }
|
||||
@{ Name = 'a zero -j is rejected'; Args = @('-s', '-j', '0'); ExpectExit = 1
|
||||
Contains = @('Invalid --jobs value') }
|
||||
@{ Name = 'the loop options combine into a single build command'; Args = @('-s', '-x', '--no-configure', '--no-gettext', '--slicer-target', 'libslic3r_tests', '-j', '8')
|
||||
Contains = @('--target libslic3r_tests -j 8')
|
||||
NotContains = @('cmake -B "build" ', 'run_gettext', '--target install') }
|
||||
|
||||
'one tree per configuration, compiler and architecture'
|
||||
# CMake resets its cache and carries on when the compiler changes under an
|
||||
# existing tree, leaving stamps from the old toolchain. Give each its own.
|
||||
@{ Name = 'clang builds land in their own trees'; Args = @('-d', '-s', '-l')
|
||||
Contains = @('cmake -S deps -B "deps/build-clang"', 'cmake -B "build-clang" ') }
|
||||
@{ Name = 'MSVC keeps the historical plain names'; Args = @('-d', '-s', '--msvc')
|
||||
Contains = @('cmake -S deps -B "deps/build"', 'cmake -B "build" ')
|
||||
NotContains = @('build-clang') }
|
||||
@{ Name = 'configuration, compiler and arch all name the tree'; Args = @('-d', '-s', '-l', '--config', 'debug', '--arch', 'arm64')
|
||||
Contains = @('cmake -S deps -B "deps/build-dbg-clang-arm64"', 'cmake -B "build-dbg-clang-arm64" ') }
|
||||
|
||||
'locating the dependency tree'
|
||||
# Without --deps-dir nothing is passed, so CMakeLists derives the path from
|
||||
# the binary directory name as it always has.
|
||||
@{ Name = 'the deps path is left to CMake by default'; Args = @('-s')
|
||||
NotContains = @('-DDEP_BUILD_DIR') }
|
||||
@{ Name = '--deps-dir tells the slicer where the deps are'; Args = @('-s', '--deps-dir', 'D:\orca-deps')
|
||||
Contains = @('-DDEP_BUILD_DIR="D:\orca-deps"') }
|
||||
@{ Name = '--deps-dir also redirects the deps build'; Args = @('-d', '--deps-dir', 'D:\orca-deps')
|
||||
Contains = @('cmake -S deps -B "D:\orca-deps"', 'cmake --build "D:\orca-deps"')
|
||||
NotContains = @('deps/build') }
|
||||
# Paths are quoted throughout, so one with spaces survives the whole run.
|
||||
@{ Name = 'a deps path with spaces survives'; Args = @('-d', '-s', '--deps-dir', 'C:\Program Files\deps')
|
||||
Contains = @('-B "C:\Program Files\deps"', '-DDEP_BUILD_DIR="C:\Program Files\deps"') }
|
||||
@{ Name = '--deps-dir also redirects the pack'; Args = @('-p', '--deps-dir', 'D:\orca-deps')
|
||||
Contains = @('cd /d "D:\orca-deps"') }
|
||||
@{ Name = 'pack uses an absolute default path'; Args = @('-p')
|
||||
Match = @('^\+ cd /d "[A-Za-z]:\\.*\\deps\\build"') }
|
||||
# CMakeLists derives DEP_BUILD_DIR from the build directory's name, so
|
||||
# pointing the build elsewhere has to name the deps tree outright.
|
||||
@{ Name = '--build-dir moves the slicer build'; Args = @('-s', '-l', '-x', '--build-dir', 'out/build/x64-clang')
|
||||
Contains = @('cmake -B "out/build/x64-clang" ') }
|
||||
@{ Name = '--build-dir still names the deps tree'; Args = @('-s', '-l', '-x', '--build-dir', 'out/build/x64-clang')
|
||||
Match = @('-DDEP_BUILD_DIR="[A-Za-z]:\\.*\\deps\\build-clang"') }
|
||||
@{ Name = '--deps-dir wins over the derived tree'; Args = @('-s', '--build-dir', 'out/build/x64-clang', '--deps-dir', 'D:\orca-deps')
|
||||
Contains = @('-DDEP_BUILD_DIR="D:\orca-deps"') }
|
||||
# A value ending in a backslash escapes the closing quote it is spliced
|
||||
# into, so cmake would receive D:\tree" and swallow the next argument.
|
||||
@{ Name = 'a trailing backslash is trimmed off a path'; Args = @('-s', '--build-dir', 'D:\tree\')
|
||||
Contains = @('cmake -B "D:\tree" ') }
|
||||
@{ Name = 'a build directory with spaces survives'; Args = @('-s', '--build-dir', 'C:\Program Files\tree')
|
||||
Contains = @('cmake -B "C:\Program Files\tree" ') }
|
||||
@{ Name = 'the default build names no deps tree'; Args = @('-s')
|
||||
NotContains = @('DEP_BUILD_DIR') }
|
||||
|
||||
'saying what mode and toolchain are in play'
|
||||
@{ Name = 'a dry run announces itself before the first command'; Args = @('-u')
|
||||
First = '^Dry run: printing commands without running them\.$' }
|
||||
@{ Name = 'the announcement leads even a full build'; Args = @('-ds')
|
||||
First = '^Dry run: ' }
|
||||
# Autodetect only runs for the Visual Studio generator, and only when no
|
||||
# release was pinned, so it needs a Visual Studio on the machine.
|
||||
@{ Name = 'the detected Visual Studio names its release year'; Args = @('-s')
|
||||
Match = @('^Detected Visual Studio \d+ \(20\d\d\)$') }
|
||||
@{ Name = 'pinning a release skips detection'; Args = @('-s', '--vs', '2022')
|
||||
NotContains = @('Detected Visual Studio') }
|
||||
|
||||
'an action has to be asked for'
|
||||
# Neither can happen without building the slicer, so they stand alone the
|
||||
# way --install-vs does.
|
||||
@{ Name = '--run-tests is an action on its own'; Args = @('--run-tests')
|
||||
Contains = @('-DBUILD_TESTS=ON', 'ctest --test-dir')
|
||||
NotContains = @('Nothing to do') }
|
||||
@{ Name = '--tests is too'; Args = @('--tests')
|
||||
Contains = @('-DBUILD_TESTS=ON')
|
||||
NotContains = @('Nothing to do', 'ctest --test-dir') }
|
||||
# Naming an action means that action, not a fuller build.
|
||||
@{ Name = 'they do not add a slicer build to one already asked for'; Args = @('-d', '--tests')
|
||||
Contains = @('cmake -S deps')
|
||||
NotContains = @('cmake -B "build"') }
|
||||
@{ Name = 'shaping options alone are not an action'; Args = @('--config', 'debug'); ExpectExit = 1
|
||||
Contains = @('Nothing to do.')
|
||||
NotContains = @('Build completed') }
|
||||
@{ Name = '-j alone is not an action either'; Args = @('-j', '4'); ExpectExit = 1
|
||||
Contains = @('Nothing to do.') }
|
||||
@{ Name = '--install-vs counts as an action on its own'; Args = @('--install-vs', 'ide')
|
||||
NotContains = @('Nothing to do.') }
|
||||
|
||||
'diagnostics'
|
||||
@{ Name = '-v asks cmake for the command lines'; Args = @('-s', '-v')
|
||||
Contains = @('--verbose') }
|
||||
@{ Name = '-v applies to the deps build too'; Args = @('-d', '-v')
|
||||
Contains = @('--target deps', '--verbose') }
|
||||
# ninja's own default shows neither a percentage nor a time. %w and %W
|
||||
# need ninja 1.12, and an unknown placeholder is fatal, so the format is
|
||||
# chosen from the version rather than hardcoded.
|
||||
@{ Name = '-v names the ninja progress format'; Args = @('-s', '-x', '-v')
|
||||
Match = @('^Ninja progress format: \[.*%p.*\]') }
|
||||
@{ Name = 'a ninja older than 1.12 gets the format it understands'; Args = @('-s', '-x', '-v')
|
||||
Env = @{ PATH = $ninjaPaths['old'] }
|
||||
Contains = @('Ninja progress format: [%s/%t %p :: %e]') }
|
||||
@{ Name = 'ninja 1.12 gets elapsed and remaining'; Args = @('-s', '-x', '-v')
|
||||
Env = @{ PATH = $ninjaPaths['new'] }
|
||||
Contains = @('Ninja progress format: [%f/%t %p :: %w / %W]') }
|
||||
@{ Name = 'a format you set yourself is left alone'; Args = @('-s', '-x', '-v')
|
||||
Env = @{ NINJA_STATUS = '[mine] ' }
|
||||
Contains = @('Ninja progress format: [mine]') }
|
||||
@{ Name = 'MSBuild builds mention no ninja format'; Args = @('-s', '-v')
|
||||
NotContains = @('Ninja progress format') }
|
||||
@{ Name = 'builds are quiet without -v'; Args = @('-s')
|
||||
NotContains = @('--verbose') }
|
||||
|
||||
'installing prerequisites'
|
||||
# -u installs CMake, Perl and Git. Visual Studio is a separate ask, because
|
||||
# most people already have it, and which one you want is a real choice.
|
||||
@{ Name = '-u alone installs no Visual Studio'; Args = @('-u')
|
||||
Contains = @('Kitware.CMake', 'StrawberryPerl', 'Git.Git')
|
||||
NotContains = @('Microsoft.VisualStudio') }
|
||||
# Asking for Visual Studio is asking to install prerequisites, so it does
|
||||
# not also need -u; requiring both meant --install-vs alone did nothing.
|
||||
@{ Name = '--install-vs works without -u'; Args = @('--install-vs', 'ide')
|
||||
Contains = @('id=Microsoft.VisualStudio.Community', 'Kitware.CMake', 'Git.Git') }
|
||||
@{ Name = '--install-vs buildtools asks for the build tools'; Args = @('-u', '--install-vs', 'buildtools')
|
||||
Contains = @('id=Microsoft.VisualStudio.BuildTools')
|
||||
NotContains = @('VC.CoreIde') }
|
||||
@{ Name = '--install-vs ide asks for Community with the IDE component'; Args = @('-u', '--install-vs', 'ide')
|
||||
Contains = @('id=Microsoft.VisualStudio.Community', 'VC.CoreIde') }
|
||||
@{ Name = 'an unknown edition is rejected and the known ones listed'; Args = @('-u', '--install-vs', 'bogus'); ExpectExit = 1
|
||||
Contains = @('Unknown Visual Studio edition', 'buildtools, ide') }
|
||||
@{ Name = '--vs picks which release to install'; Args = @('-u', '--vs', '2022', '--install-vs', 'buildtools')
|
||||
Contains = @('id=Microsoft.VisualStudio.2022.BuildTools') }
|
||||
@{ Name = '-l adds the clang compiler and the MSBuild toolset'; Args = @('-u', '--install-vs', 'buildtools', '-l')
|
||||
Contains = @('VC.Llvm.Clang ', 'VC.Llvm.ClangToolset') }
|
||||
# CMake 4.x breaks Boost.Context on ARM64, so the installer pins 3.31 there
|
||||
# and leaves x64 on the current release.
|
||||
@{ Name = 'CMake is pinned to 3.31 when installing for arm64'; Args = @('-u', '--arch', 'arm64')
|
||||
Contains = @('Kitware.CMake --version 3.31.8') }
|
||||
@{ Name = 'CMake is not pinned for x64'; Args = @('-u', '--arch', 'x64')
|
||||
Contains = @('Kitware.CMake')
|
||||
NotContains = @('--version') }
|
||||
@{ Name = 'installing for arm64 asks for the ARM64 toolset'; Args = @('--install-vs', 'buildtools', '--arch', 'arm64')
|
||||
Contains = @('Microsoft.VisualStudio.Component.VC.Tools.ARM64') }
|
||||
@{ Name = 'an x64 install asks only for the x64 toolset'; Args = @('--install-vs', 'buildtools')
|
||||
Contains = @('Microsoft.VisualStudio.Component.VC.Tools.x86.x64')
|
||||
NotContains = @('VC.Tools.ARM64') }
|
||||
|
||||
'dry run changes nothing'
|
||||
# clean_tree resolves the path before removing it, so the line echoed is
|
||||
# absolute. It is the last thing printed before a directory goes.
|
||||
@{ Name = '-c echoes the deps rmdir rather than running it'; Args = @('-d', '-c')
|
||||
Match = @('^\+ rmdir /S /Q "[A-Za-z]:\\.*\\deps\\build"$') }
|
||||
@{ Name = '-c removes the tree --deps-dir named, not the default one'; Args = @('-d', '-c', '--deps-dir', 'D:\orca-deps')
|
||||
Match = @('^\+ rmdir /S /Q "D:\\orca-deps"$')
|
||||
NotContains = @('\deps\build') }
|
||||
@{ Name = '-c echoes the slicer rmdir rather than running it'; Args = @('-s', '-c')
|
||||
Match = @('^\+ rmdir /S /Q "[A-Za-z]:\\.*\\build"$') }
|
||||
@{ Name = 'cleaning a slicer build leaves the deps tree alone'; Args = @('-s', '-c')
|
||||
NotMatch = @('rmdir.*\\deps\\') }
|
||||
@{ Name = 'cleaning both builds removes both trees'; Args = @('-d', '-s', '-c')
|
||||
Match = @('^\+ rmdir /S /Q "[A-Za-z]:\\[^"]*\\deps\\build"$',
|
||||
'^\+ rmdir /S /Q "[A-Za-z]:\\(?!.*\\deps\\)[^"]*\\build"$') }
|
||||
|
||||
# Nothing below should be reachable. They are a floor under a bug that
|
||||
# hands clean_tree a path far shorter than it looks.
|
||||
@{ Name = 'a configuration whose lookup comes back empty is rejected'; Args = @('-d', '-c', '--config', 'release '); ExpectExit = 1
|
||||
Contains = @('Unknown configuration')
|
||||
NotContains = @('rmdir') }
|
||||
@{ Name = 'a drive root is refused'; Args = @('-d', '-c', '--deps-dir', 'D:\'); ExpectExit = 1
|
||||
Contains = @('that is a drive root')
|
||||
NotContains = @('+ rmdir') }
|
||||
@{ Name = 'the repository itself is refused'; Args = @('-d', '-c', '--deps-dir', '.'); ExpectExit = 1
|
||||
Contains = @('that is the repository itself')
|
||||
NotContains = @('+ rmdir') }
|
||||
@{ Name = '-k echoes the taskkills rather than running them'; Args = @('-k')
|
||||
Contains = @('+ taskkill /F /IM MSBuild.exe', '+ taskkill /F /IM cl.exe') }
|
||||
@{ Name = '-k also covers the Ninja toolchain'; Args = @('-k')
|
||||
Contains = @('+ taskkill /F /IM ninja.exe', '+ taskkill /F /IM clang-cl.exe') }
|
||||
@{ Name = '-k announces the dry run like every other action'; Args = @('-k')
|
||||
First = '^Dry run: ' }
|
||||
@{ Name = '-u echoes the winget installs rather than running them'; Args = @('-u')
|
||||
Contains = @('+ winget install', 'Kitware.CMake')
|
||||
NotContains = @('cmake -S deps') }
|
||||
# Not a dry run: winget is a stub that fails, so nothing is installed.
|
||||
@{ Name = 'a failed install is reported, not claimed as success'; Args = @('-u')
|
||||
DryRun = $false; ExpectExit = 1
|
||||
Env = @{ PATH = $stubPath }
|
||||
Contains = @('Failed to install:', 'CMake', 'Perl', 'Git')
|
||||
NotContains = @('Installed the prerequisites') }
|
||||
# The reason belongs inside the frame. Every command this path ran had
|
||||
# already succeeded, so naming the last one would point at the wrong thing.
|
||||
@{ Name = 'a failed install names the reason, not the last command'; Args = @('-u')
|
||||
DryRun = $false; ExpectExit = 1
|
||||
Env = @{ PATH = $stubPath }
|
||||
Contains = @('####', 'Failed to install:')
|
||||
NotContains = @('Failed: winget') }
|
||||
@{ Name = 'a dry run does not claim the install happened'; Args = @('-u')
|
||||
Contains = @('Dry run: nothing was installed.')
|
||||
NotContains = @('are in place') }
|
||||
@{ Name = '-u with a build defers that build, not a fuller one'; Args = @('-u', '-d')
|
||||
Contains = @('build_win.bat -d')
|
||||
NotContains = @('build_win.bat -ds') }
|
||||
@{ Name = '-u on its own suggests the whole build'; Args = @('-u')
|
||||
Contains = @('build_win.bat -ds') }
|
||||
@{ Name = 'a dry run only echoes, it never configures'; Args = @('-d')
|
||||
Contains = @('+ cmake')
|
||||
NotContains = @('CMake Error', 'Configuring done') }
|
||||
|
||||
'extra configure arguments'
|
||||
# --deps-args and --slicer-args are declared "rawstring", so a value that
|
||||
# looks like an option is allowed through. Plain string options still
|
||||
# reject one, since there it almost always means a forgotten value.
|
||||
@{ Name = '--deps-args reaches the deps configure'; Args = @('-d', '--deps-args', 'FOO')
|
||||
Contains = @('-DCMAKE_BUILD_TYPE=Release FOO') }
|
||||
@{ Name = '--slicer-args reaches the slicer configure'; Args = @('-s', '--slicer-args', 'BAZ')
|
||||
Contains = @('BAZ') }
|
||||
@{ Name = '--deps-args accepts a value that starts with a dash'; Args = @('-d', '--deps-args', '-DFOO')
|
||||
Contains = @('-DFOO') }
|
||||
@{ Name = 'a plain string option still rejects a dash-leading value'; Args = @('-d', '--deps-target', '--tests'); ExpectExit = 1
|
||||
Contains = @('looks like another option') }
|
||||
# cmd splits arguments on "=" as well as spaces, so an unquoted -D reaches
|
||||
# the script as two arguments however it was invoked.
|
||||
@{ Name = 'an unquoted value containing = is rejected'; Args = @('-d', '--deps-args', 'FOO=BAR'); ExpectExit = 1
|
||||
Contains = @('Unknown argument') }
|
||||
# The environment overrides exist for that reason; nothing tokenises them.
|
||||
@{ Name = 'ORCA_DEPS_CMAKE_ARGS reaches the deps configure'; Args = @('-d')
|
||||
Env = @{ ORCA_DEPS_CMAKE_ARGS = '-DFOO=BAR -DBAZ=QUX' }
|
||||
Contains = @('-DFOO=BAR -DBAZ=QUX') }
|
||||
@{ Name = 'ORCA_SLICER_CMAKE_ARGS reaches the slicer configure'; Args = @('-s')
|
||||
Env = @{ ORCA_SLICER_CMAKE_ARGS = '-DWANTED=1' }
|
||||
Contains = @('-DWANTED=1') }
|
||||
@{ Name = 'the deps override does not leak into the slicer configure'; Args = @('-s')
|
||||
Env = @{ ORCA_DEPS_CMAKE_ARGS = '-DDEPSONLY=1' }
|
||||
NotContains = @('-DDEPSONLY=1') }
|
||||
@{ Name = 'the help points at the environment for a spaced argument'; Args = @('--help'); DryRun = $false
|
||||
Contains = @('Neither form supports a value containing an ampersand') }
|
||||
@{ Name = 'the help lists the environment overrides'; Args = @('--help'); DryRun = $false
|
||||
Contains = @('Environment:', 'ORCA_DEPS_CMAKE_ARGS', 'ORCA_SLICER_CMAKE_ARGS') }
|
||||
|
||||
'running the unit tests'
|
||||
@{ Name = '--tests builds them without running them'; Args = @('-s', '--tests')
|
||||
Contains = @('-DBUILD_TESTS=ON')
|
||||
NotContains = @('ctest') }
|
||||
@{ Name = '--run-tests builds and runs them'; Args = @('-s', '--run-tests')
|
||||
Contains = @('-DBUILD_TESTS=ON', 'ctest --test-dir "build/tests" -C Release --output-on-failure') }
|
||||
@{ Name = '--run-tests follows the build type and directory'; Args = @('-s', '--run-tests', '--config', 'debug')
|
||||
Contains = @('ctest --test-dir "build-dbg/tests" -C Debug') }
|
||||
@{ Name = 'no tests are run by default'; Args = @('-s')
|
||||
NotContains = @('ctest') }
|
||||
|
||||
'failures are reported'
|
||||
@{ Name = 'a missing cmake is caught and exits non-zero'; Args = @('-d'); ExpectExit = 1
|
||||
Env = @{ PATH = 'C:\Windows\system32;C:\Windows' }
|
||||
Contains = @('CMake was not found') }
|
||||
@{ Name = 'packing does not need cmake, only an archiver'; Args = @('-p')
|
||||
Env = @{ PATH = 'C:\Windows\system32;C:\Windows' }
|
||||
NotContains = @('CMake was not found') }
|
||||
# Not a dry run: a cd to a missing drive is a real failure inside a
|
||||
# parenthesised block, which is where exit /b silently loses its code.
|
||||
# Without the jump to :die this exits 0 and a failed build reads as a
|
||||
# successful one.
|
||||
@{ Name = 'a failure inside a build block reaches the caller'; Args = @('-p', '--deps-dir', 'Z:\nope')
|
||||
DryRun = $false; ExpectExit = 1
|
||||
Contains = @('Exit code 1.', '####')
|
||||
NotContains = @('Build completed', 'Try') }
|
||||
# The retry follows the stage that failed. Offering it for the whole run
|
||||
# would clean a dependency tree that was not at fault.
|
||||
@{ Name = 'a failure names a retry scoped to the stage that failed'; Args = @('-d', '-s', '--deps-dir', 'Z:\nope')
|
||||
DryRun = $false; ExpectExit = 1
|
||||
Contains = @('build_win.bat -d --deps-dir "Z:\nope" -c')
|
||||
NotContains = @('build_win.bat -ds') }
|
||||
# CMake's own failure here is hundreds of lines about package resolution.
|
||||
@{ Name = 'a missing dependency tree is named, not left to CMake'; Args = @('-s', '--deps-dir', 'Z:\nope')
|
||||
DryRun = $false; ExpectExit = 1
|
||||
Contains = @('Dependencies not found at', 'Build them with build_win.bat -d --deps-dir "Z:\nope"')
|
||||
NotContains = @('cmake -B', 'Try') }
|
||||
# Every other suggestion carries the flags that reproduce the run; a bare
|
||||
# -d would point at the MSVC tree after a clang build.
|
||||
@{ Name = 'the missing-deps hint names this toolchain'; Args = @('-s', '-l', '-x', '--deps-dir', 'deps/not-built')
|
||||
DryRun = $false; ExpectExit = 1
|
||||
Contains = @('Build them with build_win.bat -d -l -x --deps-dir "deps/not-built"')
|
||||
NotExists = @('deps/not-built') }
|
||||
# A dry run configures nothing, so it must not depend on which trees happen
|
||||
# to exist on the machine running the suite.
|
||||
@{ Name = 'a dry run does not check for the deps tree'; Args = @('-s', '--deps-dir', 'Z:\nope')
|
||||
Contains = @('cmake -B "build"')
|
||||
NotContains = @('Dependencies not found') }
|
||||
# -d is about to build them, so there is nothing to report yet.
|
||||
@{ Name = 'building the deps in the same run skips the check'; Args = @('-d', '-s', '--deps-dir', 'Z:\nope')
|
||||
DryRun = $false; ExpectExit = 1
|
||||
NotContains = @('Dependencies not found') }
|
||||
# A configure fails before any compiler runs, so -v has nothing to show.
|
||||
@{ Name = 'a configure failure is not offered a verbose rebuild'; Args = @('-d', '--deps-dir', 'Z:\nope')
|
||||
DryRun = $false; ExpectExit = 1
|
||||
Contains = @('-c discard that tree')
|
||||
NotContains = @('-v show the failing') }
|
||||
# A bare --no-configure succeeds on a machine that already has a usable
|
||||
# build tree, so name one that cannot exist instead.
|
||||
@{ Name = 'a build failure is'; Args = @('-s', '--no-configure', '--build-dir', 'deps/no-such-tree')
|
||||
DryRun = $false; ExpectExit = 1
|
||||
Contains = @('-v show the failing')
|
||||
NotExists = @('deps/no-such-tree') }
|
||||
# --build-dir names the slicer tree, which a deps failure has nothing to do
|
||||
# with. --deps-dir stays, because that is the tree that failed.
|
||||
@{ Name = 'a deps retry leaves out the slicer tree'; Args = @('-d', '-s', '--deps-dir', 'Z:\nope', '--build-dir', 'D:\b')
|
||||
DryRun = $false; ExpectExit = 1
|
||||
Contains = @('build_win.bat -d --deps-dir "Z:\nope" -c')
|
||||
NotContains = @('--build-dir') }
|
||||
@{ Name = 'an unknown configuration stays a single line'; Args = @('-s', '--config', 'bogus'); ExpectExit = 1
|
||||
Contains = @('Unknown configuration')
|
||||
NotContains = @('####', 'Try') }
|
||||
@{ Name = 'a bad --jobs value is not framed either'; Args = @('-s', '-j', 'x'); ExpectExit = 1
|
||||
Contains = @('Invalid --jobs value')
|
||||
NotContains = @('####') }
|
||||
|
||||
'the summary says what to do next'
|
||||
# Every suggested command carries the flags that reproduce this run.
|
||||
@{ Name = 'a deps build points at the slicer build'; Args = @('-d', '-l')
|
||||
Contains = @('Build the slicer build_win.bat -s -l')
|
||||
NotContains = @('Run it') }
|
||||
@{ Name = 'a ninja slicer build offers a single target'; Args = @('-s', '-l', '-x')
|
||||
Contains = @('Rebuild after edits build_win.bat -s -l -x --no-configure', 'Rebuild one target')
|
||||
NotContains = @('Solution', 'Open in Visual Studio') }
|
||||
@{ Name = 'a visual studio build names the solution instead'; Args = @('-s')
|
||||
Contains = @('Solution ', 'Open in Visual Studio build\OrcaSlicer.sln',
|
||||
'Rebuild after edits build_win.bat -s --no-configure')
|
||||
NotContains = @('Rebuild one target') }
|
||||
@{ Name = 'the configuration and architecture come back'; Args = @('-s', '-l', '-x', '--config', 'debug', '--arch', 'arm64')
|
||||
Contains = @('build_win.bat -s -l -x --config debug --arch arm64 --no-configure') }
|
||||
@{ Name = 'the tree overrides come back quoted'; Args = @('-s', '--deps-dir', 'D:\d', '--build-dir', 'D:\b')
|
||||
Contains = @('--deps-dir "D:\d" --build-dir "D:\b"') }
|
||||
@{ Name = 'a pinned visual studio release comes back'; Args = @('-s', '--vs', '2022')
|
||||
Contains = @('build_win.bat -s --vs 2022 --no-configure') }
|
||||
# Autodetection writes what it found into the same variable, so a detected
|
||||
# release must not come back as though it had been asked for.
|
||||
@{ Name = 'a detected release does not'; Args = @('-s')
|
||||
NotContains = @('--vs') }
|
||||
@{ Name = 'the binary is named in the build tree it was built in'; Args = @('-s', '-l', '-x')
|
||||
Contains = @('build-clang\src\Release\orca-slicer.exe') }
|
||||
@{ Name = 'installing names the installed copy instead'; Args = @('-s', '-l', '-x', '-i')
|
||||
Contains = @('build-clang\OrcaSlicer\orca-slicer.exe') }
|
||||
# -i changes where the binary lands, so a rebuild that dropped it would
|
||||
# leave the path above pointing at a stale copy.
|
||||
@{ Name = 'the rebuild suggestion keeps -i'; Args = @('-s', '-l', '-x', '-i')
|
||||
Contains = @('Rebuild after edits build_win.bat -s -l -x -i --no-configure') }
|
||||
# A deps retry has no install step to repeat.
|
||||
@{ Name = 'a deps retry drops it'; Args = @('-d', '-s', '-i', '--deps-dir', 'Z:\nope')
|
||||
DryRun = $false; ExpectExit = 1
|
||||
Contains = @('build_win.bat -d --deps-dir "Z:\nope" -c')
|
||||
NotContains = @('-d -i') }
|
||||
# Naming a target builds it and its dependencies, not its dependents, so
|
||||
# the binary on disk is whatever the last full build left there.
|
||||
@{ Name = 'a single-target build does not claim the whole binary'; Args = @('-s', '-l', '-x', '--slicer-target', 'glad')
|
||||
Contains = @('Target glad', 'Relink the binary build_win.bat -s -l -x --no-configure')
|
||||
NotContains = @('Run it', 'orca-slicer.exe', 'Rebuild after edits') }
|
||||
# The executable has a target of its own, and naming that one does relink.
|
||||
@{ Name = 'naming the executable target still claims the binary'; Args = @('-s', '-l', '-x', '--slicer-target', 'OrcaSlicer')
|
||||
Contains = @('Run it', 'orca-slicer.exe', 'Rebuild after edits')
|
||||
NotContains = @('Target OrcaSlicer', 'Relink the binary') }
|
||||
@{ Name = '--run-tests offers the ctest line'; Args = @('-s', '-l', '-x', '--run-tests')
|
||||
Contains = @('Re-run the tests ctest --test-dir build-clang/tests -C Release') }
|
||||
@{ Name = 'packing names the bundle and how to use it'; Args = @('-p', '-l')
|
||||
Contains = @('Bundle ', 'Share the bundle') }
|
||||
# The line supplies its own tree, so the one this run used must not ride
|
||||
# along and contradict it.
|
||||
@{ Name = 'the bundle line names one tree, not two'; Args = @('-s', '-p', '-l', '-x', '--deps-dir', 'D:\shared')
|
||||
Contains = @('then build_win.bat -s -l -x --deps-dir <path>')
|
||||
NotContains = @('--deps-dir "D:\shared" --deps-dir') }
|
||||
@{ Name = 'installing prerequisites suggests the build that follows'; Args = @('-u', '-l')
|
||||
Contains = @('Restart this shell', 'build_win.bat -ds -l')
|
||||
NotContains = @('Run it') }
|
||||
# Everything below the header line is worked out the same way in either
|
||||
# run, which is why a dry run can cover it.
|
||||
@{ Name = 'a dry run does not claim a build happened'; Args = @('-s', '-l', '-x')
|
||||
Contains = @('Dry run: nothing was built.')
|
||||
NotContains = @('Build completed in') }
|
||||
# --no-configure is the iteration loop and still gets the block; four
|
||||
# lines after a rebuild is not enough to be worth suppressing.
|
||||
@{ Name = '--no-configure still gets the summary'; Args = @('-s', '-l', '-x', '--no-configure')
|
||||
Contains = @('Next', 'Rebuild after edits') }
|
||||
|
||||
'pointing at the solution'
|
||||
# The extension follows the generator, so these two pin the release and a
|
||||
# build directory that cannot already hold a solution of either kind.
|
||||
@{ Name = 'the 2026 generator gets the XML solution'; Args = @('-s', '--vs', '2026', '--build-dir', 'D:\tree')
|
||||
Contains = @('Solution D:\tree\OrcaSlicer.slnx', 'Open in Visual Studio D:\tree\OrcaSlicer.slnx') }
|
||||
@{ Name = 'the releases before it get the classic one'; Args = @('-s', '--vs', '2022', '--build-dir', 'D:\tree')
|
||||
Contains = @('Solution D:\tree\OrcaSlicer.sln', 'Open in Visual Studio D:\tree\OrcaSlicer.sln') }
|
||||
@{ Name = 'a solution already on disk wins over the generator'; Args = @('-s', '--vs', '2026', '--build-dir', $slnDir)
|
||||
Match = @('^ Solution .*\\OrcaSlicer\.sln$') }
|
||||
# Extension-agnostic from here: these cases are about the directory, and
|
||||
# the release is whatever is installed.
|
||||
@{ Name = 'the VS generator says where the solution is'; Args = @('-s')
|
||||
Match = @('^ Solution .*\\build\\OrcaSlicer\.slnx?$') }
|
||||
@{ Name = 'the solution path follows the configuration'; Args = @('-s', '--config', 'debug')
|
||||
Match = @('^ Solution .*\\build-dbg\\OrcaSlicer\.slnx?$') }
|
||||
@{ Name = 'the solution line survives an install'; Args = @('-s', '-i')
|
||||
Contains = @(' Solution ') }
|
||||
# The path is resolved, not pasted onto the repository root, so it is
|
||||
# right whether --build-dir came absolute or with forward slashes.
|
||||
@{ Name = 'a moved build still prints one real path'; Args = @('-s', '--build-dir', 'out/build/x64-clang')
|
||||
Match = @('^ Solution [A-Za-z]:\\[^/]+\\OrcaSlicer\.slnx?$') }
|
||||
@{ Name = 'an absolute --build-dir is not glued onto the repo root'; Args = @('-s', '--build-dir', 'D:\tree')
|
||||
Match = @('^ Solution D:\\tree\\OrcaSlicer\.slnx?$') }
|
||||
)
|
||||
|
||||
function Invoke-BuildScript {
|
||||
param([string[]] $Arguments, [hashtable] $Environment)
|
||||
|
||||
$saved = @{}
|
||||
if ($Environment) {
|
||||
foreach ($key in $Environment.Keys) {
|
||||
$saved[$key] = [Environment]::GetEnvironmentVariable($key)
|
||||
Set-Item -Path "env:$key" -Value $Environment[$key]
|
||||
}
|
||||
}
|
||||
try {
|
||||
# 'Stop' turns a native command's stderr into a terminating error, and
|
||||
# a case that exercises a real failure writes to stderr. Let the output
|
||||
# through and judge the run by its exit code instead. The assignment is
|
||||
# scoped to this function, so the rest of the suite keeps 'Stop'.
|
||||
$ErrorActionPreference = 'Continue'
|
||||
if ($Arguments.Count -eq 0) {
|
||||
$out = & $Script 2>&1 | Out-String
|
||||
} else {
|
||||
$out = & $Script @Arguments 2>&1 | Out-String
|
||||
}
|
||||
return [pscustomobject]@{ Output = $out; Exit = $LASTEXITCODE }
|
||||
} finally {
|
||||
foreach ($key in $saved.Keys) {
|
||||
if ($null -eq $saved[$key]) { Remove-Item -Path "env:$key" -ErrorAction SilentlyContinue }
|
||||
else { Set-Item -Path "env:$key" -Value $saved[$key] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$knownFields = @(
|
||||
'Name', 'Args', 'ExpectExit', 'DryRun', 'First', 'Env',
|
||||
'Contains', 'NotContains', 'Match', 'NotMatch', 'NotExists', 'DateStampedZip'
|
||||
)
|
||||
|
||||
function Test-Case {
|
||||
param([hashtable] $Case)
|
||||
|
||||
# Read fields with the indexer, not dot notation. A hashtable exposes its
|
||||
# own members too, so $Case.Contains returns the Contains *method* whenever
|
||||
# the case has no key by that name.
|
||||
$argv = @($Case['Args'])
|
||||
if (-not $Case.ContainsKey('DryRun') -or $Case['DryRun']) { $argv += '--dry-run' }
|
||||
|
||||
$expect = 0
|
||||
if ($Case.ContainsKey('ExpectExit')) { $expect = $Case['ExpectExit'] }
|
||||
|
||||
$started = Get-Date
|
||||
$result = Invoke-BuildScript -Arguments $argv -Environment $Case['Env']
|
||||
$finished = Get-Date
|
||||
|
||||
$problems = @()
|
||||
|
||||
# A misspelled field is silently ignored by the checks below, which
|
||||
# leaves the case asserting nothing at all and passing.
|
||||
foreach ($field in $Case.Keys) {
|
||||
if ($knownFields -notcontains $field) { $problems += "unknown field '$field'" }
|
||||
}
|
||||
|
||||
if ($result.Exit -ne $expect) { $problems += "exit $($result.Exit), expected $expect" }
|
||||
foreach ($needle in $Case['Contains']) {
|
||||
if (-not $result.Output.Contains($needle)) { $problems += "missing '$needle'" }
|
||||
}
|
||||
foreach ($needle in $Case['NotContains']) {
|
||||
if ($result.Output.Contains($needle)) { $problems += "unexpected '$needle'" }
|
||||
}
|
||||
$lines = $result.Output -split "`r?`n"
|
||||
if ($Case['First'] -and $lines[0] -notmatch $Case['First']) {
|
||||
$problems += "first line was '$($lines[0])'"
|
||||
}
|
||||
foreach ($pattern in $Case['Match']) {
|
||||
if (@($lines | Where-Object { $_ -match $pattern }).Count -eq 0) {
|
||||
$problems += "no line matching /$pattern/"
|
||||
}
|
||||
}
|
||||
if ($Case['DateStampedZip']) {
|
||||
# Bound the accepted dates to this invocation so crossing midnight is
|
||||
# valid without allowing an unrelated past or future date.
|
||||
$dateStamps = @($started.ToString('yyyyMMdd'), $finished.ToString('yyyyMMdd')) | Select-Object -Unique
|
||||
$pattern = '_(' + ($dateStamps -join '|') + ')\.zip$'
|
||||
if (@($lines | Where-Object { $_ -match $pattern }).Count -eq 0) {
|
||||
$problems += "no line matching /$pattern/"
|
||||
}
|
||||
}
|
||||
foreach ($pattern in $Case['NotMatch']) {
|
||||
foreach ($line in @($lines | Where-Object { $_ -match $pattern })) {
|
||||
$problems += "line matches /$pattern/: $line"
|
||||
}
|
||||
}
|
||||
# Output cannot show what a run did not create.
|
||||
foreach ($path in $Case['NotExists']) {
|
||||
$full = Join-Path (Split-Path -Parent $Script) $path
|
||||
if (Test-Path $full) {
|
||||
$problems += "created '$path'"
|
||||
}
|
||||
}
|
||||
return ,$problems
|
||||
}
|
||||
|
||||
$pass = 0
|
||||
$failed = @()
|
||||
# Held back so a filtered run does not print headings for groups it skipped.
|
||||
$heading = $null
|
||||
|
||||
foreach ($case in $cases) {
|
||||
if ($case -is [string]) {
|
||||
$heading = $case
|
||||
continue
|
||||
}
|
||||
if ($Name -and $case['Name'] -notmatch $Name) { continue }
|
||||
if ($heading) {
|
||||
Write-Host ''
|
||||
Write-Host $heading -ForegroundColor Cyan
|
||||
$heading = $null
|
||||
}
|
||||
|
||||
$problems = Test-Case -Case $case
|
||||
if ($problems.Count -eq 0) {
|
||||
$pass++
|
||||
Write-Host (' ok ' + $case['Name'])
|
||||
} else {
|
||||
$failed += $case['Name']
|
||||
Write-Host (' FAIL ' + $case['Name']) -ForegroundColor Red
|
||||
foreach ($problem in $problems) { Write-Host (' ' + $problem) -ForegroundColor Red }
|
||||
Write-Host (' args: ' + (@($case['Args']) -join ' '))
|
||||
}
|
||||
}
|
||||
|
||||
Remove-Item -Recurse -Force $fixtures -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Host ''
|
||||
# A pattern that matched nothing has proved nothing, so do not report it as
|
||||
# a clean run.
|
||||
if ($Name -and $pass -eq 0 -and $failed.Count -eq 0) {
|
||||
Write-Host "no case matched /$Name/" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host "$pass passed, $($failed.Count) failed"
|
||||
if ($failed.Count -gt 0) {
|
||||
foreach ($name in $failed) { Write-Host " failed: $name" -ForegroundColor Red }
|
||||
exit 1
|
||||
}
|
||||
exit 0
|
||||
@@ -5,10 +5,15 @@ Inserts/deletes/modifies random lane data in Moonraker database,
|
||||
then reads back and displays with colored output.
|
||||
"""
|
||||
|
||||
import requests
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
requests = None # only needed for live-printer operations, not --check-ofl-map
|
||||
import random
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import sys
|
||||
|
||||
@@ -16,6 +21,11 @@ import sys
|
||||
DEFAULT_HOST = "192.168.88.9"
|
||||
DEFAULT_PORT = 7125
|
||||
NAMESPACE = "lane_data"
|
||||
|
||||
# Repo-relative paths for the offline generic-map check
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
MOONRAKER_AGENT_CPP = os.path.join(REPO_ROOT, "src", "slic3r", "Utils", "MoonrakerPrinterAgent.cpp")
|
||||
OFL_FILAMENT_DIR = os.path.join(REPO_ROOT, "resources", "profiles", "OrcaFilamentLibrary", "filament")
|
||||
LANE_KEYS = [f"lane{i}" for i in range(1, 9)] # lane1-lane8
|
||||
MATERIALS = ["PLA", "ABS", "PETG", "ASA", "ASA Sparkle", "TPU", ""]
|
||||
|
||||
@@ -30,6 +40,95 @@ MATERIAL_TEMPS = {
|
||||
"": {"nozzle": None, "bed": None},
|
||||
}
|
||||
|
||||
def parse_cpp_type_map():
|
||||
"""Extract the normalized-type -> OFL generic family table from MoonrakerPrinterAgent.cpp.
|
||||
|
||||
Reads MoonrakerPrinterAgent::map_filament_type_to_generic_id's type_to_ofl_family
|
||||
initializer so the check tracks the C++ normalization without a duplicated list.
|
||||
"""
|
||||
with open(MOONRAKER_AGENT_CPP, encoding="utf-8") as f:
|
||||
src = f.read()
|
||||
m = re.search(r"type_to_ofl_family\s*=\s*\{(.*?)\n\s*\};", src, re.DOTALL)
|
||||
if not m:
|
||||
raise RuntimeError(f"type_to_ofl_family table not found in {MOONRAKER_AGENT_CPP}")
|
||||
pairs = re.findall(r'\{\s*"([^"]+)"\s*,\s*"([^"]+)"\s*\}', m.group(1))
|
||||
if not pairs:
|
||||
raise RuntimeError("type_to_ofl_family table parsed empty")
|
||||
return dict(pairs)
|
||||
|
||||
def load_ofl_presets():
|
||||
"""Map preset name -> parsed JSON for every OrcaFilamentLibrary filament profile."""
|
||||
presets = {}
|
||||
for root, _dirs, files in os.walk(OFL_FILAMENT_DIR):
|
||||
for fn in files:
|
||||
if not fn.endswith(".json"):
|
||||
continue
|
||||
try:
|
||||
with open(os.path.join(root, fn), encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
name = data.get("name")
|
||||
if isinstance(name, str) and name:
|
||||
presets[name] = data
|
||||
return presets
|
||||
|
||||
def resolve_ofl_filament_id(presets, name):
|
||||
"""Follow the inherits chain (within OFL) until a filament_id is declared."""
|
||||
seen = set()
|
||||
while name and name not in seen:
|
||||
seen.add(name)
|
||||
preset = presets.get(name)
|
||||
if preset is None:
|
||||
return None
|
||||
fid = preset.get("filament_id")
|
||||
if fid:
|
||||
return fid
|
||||
name = preset.get("inherits")
|
||||
return None
|
||||
|
||||
def check_ofl_generic_map():
|
||||
"""Assert every material type the C++ normalization handles resolves to a shipped
|
||||
OrcaFilamentLibrary generic preset carrying a filament_id.
|
||||
|
||||
Expectations are derived from the shipped profiles, not pinned id literals, so the
|
||||
check stays valid across filament_id re-mints.
|
||||
"""
|
||||
print("Checking C++ generic-type map against shipped OrcaFilamentLibrary presets...")
|
||||
try:
|
||||
type_map = parse_cpp_type_map()
|
||||
except (OSError, RuntimeError) as e:
|
||||
print(f" FAIL: {e}")
|
||||
return False
|
||||
presets = load_ofl_presets()
|
||||
if not presets:
|
||||
print(f" FAIL: no OFL filament profiles found under {OFL_FILAMENT_DIR}")
|
||||
return False
|
||||
errors = []
|
||||
for family in sorted(set(type_map.values())):
|
||||
preset_name = f"Generic {family} @System"
|
||||
if preset_name not in presets:
|
||||
errors.append(f"{preset_name}: no such OFL preset")
|
||||
continue
|
||||
if str(presets[preset_name].get("instantiation", "")).lower() != "true":
|
||||
errors.append(f"{preset_name}: not instantiated — the C++ runtime lookup "
|
||||
f"only sees presets loaded into the PresetBundle")
|
||||
continue
|
||||
fid = resolve_ofl_filament_id(presets, preset_name)
|
||||
if not fid:
|
||||
errors.append(f"{preset_name}: no filament_id resolvable through inherits")
|
||||
continue
|
||||
aliases = ", ".join(sorted(t for t, fam in type_map.items() if fam == family))
|
||||
print(f" {fid:10s} {preset_name:32s} <- {aliases}")
|
||||
if errors:
|
||||
for e in errors:
|
||||
print(f" FAIL: {e}")
|
||||
print(f"OFL generic map check FAILED ({len(errors)} error(s))")
|
||||
return False
|
||||
print(f"OFL generic map check passed: {len(type_map)} type aliases, "
|
||||
f"{len(set(type_map.values()))} OFL generic presets\n")
|
||||
return True
|
||||
|
||||
def test_connection(host, port, api_key=None, verbose=False):
|
||||
"""Test basic connectivity to Moonraker."""
|
||||
url = f"http://{host}:{port}/server/info"
|
||||
@@ -404,11 +503,25 @@ def main():
|
||||
help="Only read and display current lane data")
|
||||
parser.add_argument("--load", metavar="FILE",
|
||||
help="Load lane data from JSON file and overwrite printer lanes")
|
||||
parser.add_argument("--check-ofl-map", action="store_true",
|
||||
help="Only run the offline check that the C++ generic-type map "
|
||||
"resolves against shipped OrcaFilamentLibrary presets")
|
||||
parser.add_argument("--verbose", "-v", action="store_true",
|
||||
help="Verbose output for debugging")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Offline check first: the C++ type normalization must resolve against shipped
|
||||
# OFL presets (no printer needed).
|
||||
if not check_ofl_generic_map():
|
||||
return 1
|
||||
if args.check_ofl_map:
|
||||
return 0
|
||||
|
||||
if requests is None:
|
||||
print("The 'requests' module is required for live printer operations (pip install requests).")
|
||||
return 1
|
||||
|
||||
print(f"\nConnecting to Moonraker at {args.host}:{args.port}...")
|
||||
|
||||
# First test basic connectivity
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,942 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the tree-maintenance half of scripts/orca_profile_tool.py: the
|
||||
normalize, trim, update-index and check commands, and the subcommand dispatch that
|
||||
reaches them (stdlib unittest, no external deps).
|
||||
|
||||
The id halves are covered by test_filament_id.py and test_setting_id.py.
|
||||
|
||||
Run from the repo root: python -m unittest discover -s scripts/tests -v
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
import orca_profile_tool as apt # noqa: E402
|
||||
|
||||
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
REAL_PROFILES = os.path.join(REPO_ROOT, "resources", "profiles")
|
||||
|
||||
|
||||
class Tree:
|
||||
"""A throwaway resources/profiles-shaped directory built one file at a time.
|
||||
|
||||
Nothing is written implicitly: index entries are added by index(), so a test
|
||||
can produce exactly the mismatch it is about (a file no list references, a
|
||||
list naming a file that is not there, a preset whose name disagrees with the
|
||||
index).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.dir = tempfile.mkdtemp(prefix="profile_tool_test_")
|
||||
self.profiles = os.path.join(self.dir, "profiles")
|
||||
os.makedirs(self.profiles)
|
||||
|
||||
def cleanup(self):
|
||||
shutil.rmtree(self.dir, ignore_errors=True)
|
||||
|
||||
def index_path(self, vendor):
|
||||
return os.path.join(self.profiles, vendor + ".json")
|
||||
|
||||
def add_vendor(self, vendor):
|
||||
for sub in apt.PROFILE_SUBDIRS:
|
||||
os.makedirs(os.path.join(self.profiles, vendor, sub), exist_ok=True)
|
||||
if not os.path.exists(self.index_path(vendor)):
|
||||
self.write_index(vendor, {"name": vendor, "version": "01.00.00.00"})
|
||||
return self
|
||||
|
||||
def write_index(self, vendor, index):
|
||||
with open(self.index_path(vendor), "w", encoding="utf-8", newline="\n") as f:
|
||||
json.dump(index, f, indent=4, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
|
||||
def read_index(self, vendor):
|
||||
with open(self.index_path(vendor), encoding="utf-8-sig") as f:
|
||||
return json.load(f)
|
||||
|
||||
def index(self, vendor, section, name, sub_path):
|
||||
index = self.read_index(vendor)
|
||||
index.setdefault(section + "_list", []).append(
|
||||
{"name": name, "sub_path": sub_path})
|
||||
self.write_index(vendor, index)
|
||||
|
||||
def path(self, vendor, rel):
|
||||
return os.path.join(self.profiles, vendor, rel.replace("/", os.sep))
|
||||
|
||||
def write(self, vendor, rel, data):
|
||||
"""Write a preset at <vendor>/<rel>; returns its path."""
|
||||
self.add_vendor(vendor)
|
||||
path = self.path(vendor, rel)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8", newline="\n") as f:
|
||||
json.dump(data, f, indent=4, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
return path
|
||||
|
||||
def write_raw(self, vendor, rel, raw):
|
||||
self.add_vendor(vendor)
|
||||
path = self.path(vendor, rel)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "wb") as f:
|
||||
f.write(raw)
|
||||
return path
|
||||
|
||||
def read(self, vendor, rel):
|
||||
with open(self.path(vendor, rel), encoding="utf-8-sig") as f:
|
||||
return json.load(f)
|
||||
|
||||
def raw(self, vendor, rel):
|
||||
with open(self.path(vendor, rel), "rb") as f:
|
||||
return f.read()
|
||||
|
||||
def bytes_map(self):
|
||||
"""Every file in the tree -> its bytes, for "nothing was written" asserts."""
|
||||
out = {}
|
||||
for root, dirs, files in os.walk(self.profiles):
|
||||
dirs.sort()
|
||||
for name in sorted(files):
|
||||
path = os.path.join(root, name)
|
||||
with open(path, "rb") as f:
|
||||
out[os.path.relpath(path, self.profiles)] = f.read()
|
||||
return out
|
||||
|
||||
|
||||
class TreeCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.t = Tree()
|
||||
self.addCleanup(self.t.cleanup)
|
||||
|
||||
def run_command(self, *argv):
|
||||
"""main() against this tree, capturing stdout."""
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
rc = apt.main([*argv, "--profiles", self.t.profiles])
|
||||
return rc, buf.getvalue()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# normalize
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestObsoleteKeys(unittest.TestCase):
|
||||
def test_obsolete_keys_match_the_loader_ignore_set(self):
|
||||
path = os.path.join(REPO_ROOT, "src", "libslic3r", "PrintConfig.cpp")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
source = f.read()
|
||||
match = re.search(
|
||||
r"void PrintConfigDef::handle_legacy\(.*?"
|
||||
r"static\s+std::set<std::string>\s+ignore\s*=\s*\{(.*?)\};",
|
||||
source, re.DOTALL)
|
||||
self.assertIsNotNone(match, "Could not locate the loader's obsolete-key set")
|
||||
keys = re.sub(r"//[^\n]*|/\*.*?\*/", "", match.group(1), flags=re.DOTALL)
|
||||
self.assertEqual(apt.OBSOLETE_KEYS, set(re.findall(r'"([^"\n]+)"', keys)))
|
||||
|
||||
|
||||
class TestNormalize(TreeCase):
|
||||
def test_a_missing_type_is_filled_in_from_the_directory(self):
|
||||
self.t.write("V", "filament/A.json", {"name": "A"})
|
||||
self.t.write("V", "process/B.json", {"name": "B"})
|
||||
rc, out = self.run_command("normalize")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertEqual(self.t.read("V", "filament/A.json")["type"], "filament")
|
||||
self.assertEqual(self.t.read("V", "process/B.json")["type"], "process")
|
||||
|
||||
def test_the_machine_folder_splits_on_the_preset_name(self):
|
||||
# Orca keeps machine models in machine/ next to the nozzle variants that
|
||||
# are machines; only the name tells them apart.
|
||||
self.t.write("V", "machine/M.json", {"name": "V Printer"})
|
||||
self.t.write("V", "machine/N.json", {"name": "V Printer 0.4 nozzle"})
|
||||
rc, out = self.run_command("normalize")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertEqual(self.t.read("V", "machine/M.json")["type"], "machine_model")
|
||||
self.assertEqual(self.t.read("V", "machine/N.json")["type"], "machine")
|
||||
|
||||
def test_dropped_keys_go_and_filament_vectors_are_arrayified(self):
|
||||
self.t.write("V", "filament/A.json", {
|
||||
"type": "filament", "name": "A", "version": "1.2.3",
|
||||
"is_custom_defined": "1", "filament_type": "PLA",
|
||||
"filament_vendor": "AV", "travel_speed": 200,
|
||||
"filament_load_time": ["15"], "filament_unload_time": "0"})
|
||||
rc, out = self.run_command("normalize")
|
||||
self.assertEqual(rc, 0, out)
|
||||
data = self.t.read("V", "filament/A.json")
|
||||
self.assertNotIn("version", data)
|
||||
self.assertNotIn("is_custom_defined", data)
|
||||
self.assertNotIn("travel_speed", data) # a process setting, not a filament one
|
||||
self.assertNotIn("filament_load_time", data)
|
||||
self.assertNotIn("filament_unload_time", data)
|
||||
self.assertEqual(data["filament_type"], ["PLA"])
|
||||
self.assertEqual(data["filament_vendor"], ["AV"])
|
||||
|
||||
def test_obsolete_keys_are_removed_from_every_profile_type(self):
|
||||
for sub in ("filament", "process", "machine"):
|
||||
with self.subTest(profile_type=sub):
|
||||
expected = {"type": sub, "name": "A"}
|
||||
self.t.write("V", f"{sub}/A.json", {
|
||||
**expected, "silent_mode": "", "adaptive_layer_height": "0",
|
||||
"anisotropic_surfaces": "1", "filament_load_time": ["0"],
|
||||
"filament_unload_time": "0"})
|
||||
rc, out = self.run_command("normalize")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertEqual(self.t.read("V", f"{sub}/A.json"), expected)
|
||||
before = self.t.bytes_map()
|
||||
rc, out = self.run_command("normalize")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("0 profile(s) normalized", out)
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
|
||||
def test_the_larger_extruder_clearance_wins(self):
|
||||
# Keeping the smaller one would licence a toolhead collision.
|
||||
self.t.write("V", "machine/M.json", {
|
||||
"type": "machine", "name": "M 0.4 nozzle",
|
||||
"extruder_clearance_radius": "45", "extruder_clearance_max_radius": "68"})
|
||||
self.t.write("V", "machine/N.json", {
|
||||
"type": "machine", "name": "N 0.4 nozzle",
|
||||
"extruder_clearance_radius": "68", "extruder_clearance_max_radius": "45"})
|
||||
rc, out = self.run_command("normalize")
|
||||
self.assertEqual(rc, 0, out)
|
||||
kept = self.t.read("V", "machine/M.json")
|
||||
self.assertNotIn("extruder_clearance_radius", kept)
|
||||
self.assertEqual(kept["extruder_clearance_max_radius"], "68")
|
||||
kept = self.t.read("V", "machine/N.json")
|
||||
self.assertNotIn("extruder_clearance_max_radius", kept)
|
||||
self.assertEqual(kept["extruder_clearance_radius"], "68")
|
||||
|
||||
def test_a_rewritten_file_leads_with_its_identifying_keys(self):
|
||||
self.t.write("V", "filament/A.json", {
|
||||
"filament_cost": [20], "name": "A", "instantiation": "true",
|
||||
"inherits": "base", "version": "1"})
|
||||
rc, out = self.run_command("normalize")
|
||||
self.assertEqual(rc, 0, out)
|
||||
keys = list(self.t.read("V", "filament/A.json"))
|
||||
self.assertEqual(keys[:4], ["type", "name", "inherits", "instantiation"])
|
||||
|
||||
def test_a_conforming_tree_is_left_byte_identical(self):
|
||||
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
|
||||
# These used to be misclassified as obsolete: one is active, the other
|
||||
# is a legacy alias that still supplies the toolhead clearance on load.
|
||||
self.t.write("V", "machine/M.json", {
|
||||
"type": "machine", "name": "M", "extruder_type": ["Direct Drive"],
|
||||
"extruder_clearance_max_radius": "68", "machine_load_filament_time": "15",
|
||||
"machine_unload_filament_time": "10"})
|
||||
self.t.write("V", "process/P.json", {
|
||||
"type": "process", "name": "P", "travel_speed": "200",
|
||||
"top_surface_fill_order": "outward"})
|
||||
before = self.t.bytes_map()
|
||||
rc, out = self.run_command("normalize")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
|
||||
def test_force_rewrites_even_a_conforming_file(self):
|
||||
self.t.write_raw("V", "filament/A.json",
|
||||
b'{"name":"A","type":"filament"}')
|
||||
rc, out = self.run_command("normalize", "--force")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertEqual(self.t.raw("V", "filament/A.json"),
|
||||
b'{\n\t"type": "filament",\n\t"name": "A"\n}\n')
|
||||
|
||||
def test_dry_run_writes_nothing(self):
|
||||
self.t.write("V", "filament/A.json", {"name": "A", "bed_temperature": ["60"]})
|
||||
before = self.t.bytes_map()
|
||||
rc, out = self.run_command("normalize", "--dry-run")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("would be", out)
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
|
||||
def test_profile_type_confines_the_run(self):
|
||||
self.t.write("V", "filament/A.json", {"name": "A"})
|
||||
self.t.write("V", "process/B.json", {"name": "B"})
|
||||
rc, out = self.run_command("normalize", "--profile-type", "filament")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertEqual(self.t.read("V", "filament/A.json")["type"], "filament")
|
||||
self.assertNotIn("type", self.t.read("V", "process/B.json"))
|
||||
|
||||
def test_an_unreadable_profile_is_reported_not_swallowed(self):
|
||||
self.t.write_raw("V", "filament/A.json", b"{ not json")
|
||||
rc, out = self.run_command("normalize")
|
||||
self.assertEqual(rc, 1, out)
|
||||
self.assertIn("ERROR", out)
|
||||
self.assertEqual(self.t.raw("V", "filament/A.json"), b"{ not json")
|
||||
|
||||
def test_a_directory_without_an_index_is_not_a_bundle(self):
|
||||
# resources/profiles also holds non-bundle entries (blacklist.json, the
|
||||
# untracked user/ directory); only a directory WITH an index is a vendor.
|
||||
stray = os.path.join(self.t.profiles, "user", "filament")
|
||||
os.makedirs(stray)
|
||||
with open(os.path.join(stray, "A.json"), "wb") as f:
|
||||
f.write(b'{"name": "A"}')
|
||||
self.t.write("V", "filament/A.json", {"name": "A"})
|
||||
rc, out = self.run_command("normalize")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertFalse(os.path.exists(os.path.join(self.t.profiles, "user.json")))
|
||||
with open(os.path.join(stray, "A.json"), "rb") as f:
|
||||
self.assertEqual(f.read(), b'{"name": "A"}')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# trim
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTrim(TreeCase):
|
||||
def bundle(self):
|
||||
self.t.write("V", "filament/Listed.json",
|
||||
{"type": "filament", "name": "Listed"})
|
||||
self.t.index("V", "filament", "Listed", "filament/Listed.json")
|
||||
return self.t
|
||||
|
||||
def test_an_unindexed_preset_is_removed(self):
|
||||
self.bundle().write("V", "filament/Orphan.json",
|
||||
{"type": "filament", "name": "Orphan"})
|
||||
rc, out = self.run_command("trim")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertTrue(os.path.exists(self.t.path("V", "filament/Listed.json")))
|
||||
self.assertFalse(os.path.exists(self.t.path("V", "filament/Orphan.json")))
|
||||
|
||||
def test_a_dotted_sub_path_still_names_its_file(self):
|
||||
# Index entries are hand-written; "filament/./X.json" is the same file.
|
||||
self.bundle()
|
||||
self.t.write("V", "filament/Dotted.json",
|
||||
{"type": "filament", "name": "Dotted"})
|
||||
self.t.index("V", "filament", "Dotted", "filament/./Dotted.json")
|
||||
rc, out = self.run_command("trim")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertTrue(os.path.exists(self.t.path("V", "filament/Dotted.json")))
|
||||
|
||||
def test_an_unparsable_file_is_kept_and_reported(self):
|
||||
self.bundle().write_raw("V", "filament/Broken.json", b"{ not json")
|
||||
rc, out = self.run_command("trim")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("WARNING", out)
|
||||
self.assertTrue(os.path.exists(self.t.path("V", "filament/Broken.json")))
|
||||
|
||||
def test_a_data_file_is_not_a_preset(self):
|
||||
self.bundle().write("V", "filament/filaments_color_codes.json",
|
||||
{"data": [], "total": 0})
|
||||
rc, out = self.run_command("trim")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertTrue(os.path.exists(
|
||||
self.t.path("V", "filament/filaments_color_codes.json")))
|
||||
|
||||
def test_an_inherited_base_is_kept_and_reported(self):
|
||||
# Neither file loads -- the loader only reads indexed sub_paths -- but
|
||||
# deleting the parent destroys the only record of what the indexed child
|
||||
# was written against, so that is a maintainer's call, not trim's.
|
||||
self.bundle()
|
||||
self.t.write("V", "machine/base.json",
|
||||
{"type": "machine", "name": "V base"})
|
||||
self.t.write("V", "machine/mid.json",
|
||||
{"type": "machine", "name": "V mid", "inherits": "V base"})
|
||||
self.t.write("V", "machine/M.json",
|
||||
{"type": "machine", "name": "M 0.4 nozzle", "inherits": "V mid"})
|
||||
self.t.index("V", "machine", "M 0.4 nozzle", "machine/M.json")
|
||||
rc, out = self.run_command("trim")
|
||||
self.assertEqual(rc, 0, out)
|
||||
# ... and the chain is followed: mid rescues base in a second pass.
|
||||
self.assertTrue(os.path.exists(self.t.path("V", "machine/mid.json")))
|
||||
self.assertTrue(os.path.exists(self.t.path("V", "machine/base.json")))
|
||||
self.assertIn("inherited from", out)
|
||||
|
||||
def test_a_stale_copy_of_an_indexed_profile_is_removed(self):
|
||||
# "inherits" resolves by preset name, so the indexed base is the parent the
|
||||
# child actually gets; the unindexed twin is a leftover the loader never
|
||||
# reaches, and being named in an inherits does not earn it a reprieve.
|
||||
self.bundle()
|
||||
self.t.write("V", "machine/HSN/base.json",
|
||||
{"type": "machine", "name": "V base"})
|
||||
self.t.index("V", "machine", "V base", "machine/HSN/base.json")
|
||||
self.t.write("V", "machine/base.json",
|
||||
{"type": "machine", "name": "V base"})
|
||||
self.t.write("V", "machine/M.json",
|
||||
{"type": "machine", "name": "M 0.4 nozzle", "inherits": "V base"})
|
||||
self.t.index("V", "machine", "M 0.4 nozzle", "machine/M.json")
|
||||
rc, out = self.run_command("trim")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertTrue(os.path.exists(self.t.path("V", "machine/HSN/base.json")))
|
||||
self.assertFalse(os.path.exists(self.t.path("V", "machine/base.json")))
|
||||
self.assertNotIn("WARNING", out)
|
||||
self.assertIn('machine/HSN/base.json is the profile named "V base"', out)
|
||||
|
||||
def test_dry_run_deletes_nothing(self):
|
||||
self.bundle().write("V", "filament/Orphan.json",
|
||||
{"type": "filament", "name": "Orphan"})
|
||||
before = self.t.bytes_map()
|
||||
rc, out = self.run_command("trim", "--dry-run")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("would be removed", out)
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# update-index
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUpdateIndex(TreeCase):
|
||||
def test_every_profile_on_disk_lands_in_its_own_section(self):
|
||||
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
|
||||
self.t.write("V", "process/B.json", {"type": "process", "name": "B"})
|
||||
self.t.write("V", "machine/M.json", {"type": "machine", "name": "M"})
|
||||
self.t.write("V", "machine/MM.json", {"type": "machine_model", "name": "MM"})
|
||||
rc, out = self.run_command("update-index")
|
||||
self.assertEqual(rc, 0, out)
|
||||
index = self.t.read_index("V")
|
||||
self.assertEqual(index["filament_list"],
|
||||
[{"name": "A", "sub_path": "filament/A.json"}])
|
||||
self.assertEqual(index["process_list"],
|
||||
[{"name": "B", "sub_path": "process/B.json"}])
|
||||
self.assertEqual(index["machine_list"],
|
||||
[{"name": "M", "sub_path": "machine/M.json"}])
|
||||
self.assertEqual(index["machine_model_list"],
|
||||
[{"name": "MM", "sub_path": "machine/MM.json"}])
|
||||
|
||||
def test_parents_are_listed_before_their_children(self):
|
||||
# The loader resolves inherits in one pass over the list.
|
||||
for name, parent in (("C", "B"), ("A", None), ("B", "A")):
|
||||
data = {"type": "filament", "name": name}
|
||||
if parent:
|
||||
data["inherits"] = parent
|
||||
self.t.write("V", f"filament/{name}.json", data)
|
||||
rc, out = self.run_command("update-index")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertEqual([e["name"] for e in self.t.read_index("V")["filament_list"]],
|
||||
["A", "B", "C"])
|
||||
# inherits is ordering input only; it never lands in the index.
|
||||
for entry in self.t.read_index("V")["filament_list"]:
|
||||
self.assertEqual(sorted(entry), ["name", "sub_path"])
|
||||
|
||||
def test_a_profile_with_no_usable_type_is_reported_not_dropped(self):
|
||||
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
|
||||
self.t.write("V", "filament/B.json", {"name": "B"})
|
||||
rc, out = self.run_command("update-index")
|
||||
self.assertEqual(rc, 1, out)
|
||||
self.assertIn("cannot be indexed", out)
|
||||
self.assertIn("filament/B.json", out)
|
||||
|
||||
def test_two_profiles_claiming_one_name_leave_the_index_alone(self):
|
||||
# The bundle holds one profile per name, so a rebuild would pick a winner by
|
||||
# directory order and drop the other without a word.
|
||||
self.t.write("V", "machine/base.json", {"type": "machine", "name": "base"})
|
||||
self.t.write("V", "machine/HSN/base.json", {"type": "machine", "name": "base"})
|
||||
self.t.index("V", "machine", "base", "machine/HSN/base.json")
|
||||
before = self.t.bytes_map()
|
||||
rc, out = self.run_command("update-index")
|
||||
self.assertEqual(rc, 1, out)
|
||||
self.assertIn('2 profiles are named "base"', out)
|
||||
self.assertIn("machine/base.json", out)
|
||||
self.assertIn("machine/HSN/base.json", out)
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
|
||||
def test_profile_type_rebuilds_only_that_section(self):
|
||||
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
|
||||
self.t.write("V", "process/B.json", {"type": "process", "name": "B"})
|
||||
rc, out = self.run_command("update-index", "--profile-type", "filament")
|
||||
self.assertEqual(rc, 0, out)
|
||||
index = self.t.read_index("V")
|
||||
self.assertEqual([e["name"] for e in index["filament_list"]], ["A"])
|
||||
self.assertNotIn("process_list", index)
|
||||
|
||||
def test_an_up_to_date_index_is_left_byte_identical(self):
|
||||
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
|
||||
self.run_command("update-index")
|
||||
before = self.t.bytes_map()
|
||||
rc, out = self.run_command("update-index")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
|
||||
def test_dry_run_writes_nothing(self):
|
||||
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
|
||||
before = self.t.bytes_map()
|
||||
rc, out = self.run_command("update-index", "--dry-run")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("would be rebuilt", out)
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
|
||||
def test_a_json_file_with_no_bundle_is_never_touched(self):
|
||||
# resources/profiles/blacklist.json is a .json with no directory beside
|
||||
# it. Enumerating vendors by stem once wrote four empty *_list keys into it.
|
||||
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
|
||||
stray = os.path.join(self.t.profiles, "blacklist.json")
|
||||
with open(stray, "wb") as f:
|
||||
f.write(b'{"filament": ["GFSA03"]}')
|
||||
rc, out = self.run_command("update-index")
|
||||
self.assertEqual(rc, 0, out)
|
||||
with open(stray, "rb") as f:
|
||||
self.assertEqual(f.read(), b'{"filament": ["GFSA03"]}')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCheck(TreeCase):
|
||||
def bundle(self):
|
||||
"""A bundle that passes every per-vendor check."""
|
||||
self.t.write("V", "filament/A.json", {
|
||||
"type": "filament", "name": "A", "instantiation": "true",
|
||||
"filament_id": "OFaaaaaa", "filament_type": ["PLA"],
|
||||
"filament_vendor": ["AV"], "compatible_printers": ["M 0.4 nozzle"],
|
||||
"setting_id": apt.generate_preset_setting_id("V", "filament", "A")})
|
||||
self.t.index("V", "filament", "A", "filament/A.json")
|
||||
return self.t
|
||||
|
||||
def per_vendor_errors(self, *argv):
|
||||
"""Run the per-vendor checks alone, which is what --vendor narrows."""
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
errors = apt.check_filament_compatible_printers(self.t.profiles, "V")
|
||||
name_errors, _warn = apt.check_name_consistency(self.t.profiles, "V")
|
||||
errors += name_errors
|
||||
errors += apt.check_vector_type_keys(self.t.profiles, "V")
|
||||
errors += apt.check_filament_id_length(self.t.profiles, "V")
|
||||
conflict, _warn = apt.check_conflict_keys(self.t.profiles, "V")
|
||||
errors += conflict
|
||||
materials, _warn = apt.check_machine_default_materials(self.t.profiles, "V")
|
||||
errors += materials
|
||||
return errors, buf.getvalue()
|
||||
|
||||
def test_a_clean_bundle_reports_nothing(self):
|
||||
self.bundle()
|
||||
errors, out = self.per_vendor_errors()
|
||||
self.assertEqual(errors, 0, out)
|
||||
|
||||
def test_an_instantiated_filament_needs_compatible_printers(self):
|
||||
self.bundle().write("V", "filament/B.json", {
|
||||
"type": "filament", "name": "B", "instantiation": "true"})
|
||||
errors, out = self.per_vendor_errors()
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("'compatible_printers' missing", out)
|
||||
|
||||
def test_a_library_filament_may_leave_compatible_printers_empty(self):
|
||||
# The shared library is exempt from that rule and nothing else.
|
||||
self.t.write(apt.OFL, "filament/A.json",
|
||||
{"type": "filament", "name": "A", "instantiation": "true"})
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
errors = apt.check_filament_compatible_printers(self.t.profiles, apt.OFL)
|
||||
self.assertEqual(errors, 0, buf.getvalue())
|
||||
|
||||
def test_the_library_is_checked_like_any_other_bundle(self):
|
||||
# A file the library's own index does not reference must fail plain
|
||||
# `check`, now that the per-vendor pass no longer skips it.
|
||||
self.t.write(apt.OFL, "filament/Stray.json",
|
||||
{"type": "filament", "name": "Stray"})
|
||||
rc, out = self.run_command("check")
|
||||
self.assertEqual(rc, 1, out)
|
||||
self.assertIn(f"{apt.OFL}/filament/Stray.json: no {apt.OFL}.json list "
|
||||
f"references it", out)
|
||||
|
||||
def test_a_duplicate_key_is_an_error(self):
|
||||
self.bundle().write_raw("V", "filament/B.json",
|
||||
b'{"type":"filament","name":"B","name":"B2"}')
|
||||
errors, out = self.per_vendor_errors()
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("Duplicate key", out)
|
||||
|
||||
def test_the_index_and_the_file_must_agree_on_the_name(self):
|
||||
self.bundle()
|
||||
self.t.write("V", "filament/C.json", {"type": "filament", "name": "Other"})
|
||||
self.t.index("V", "filament", "C", "filament/C.json")
|
||||
errors, out = self.per_vendor_errors()
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("name mismatch", out)
|
||||
|
||||
def test_an_index_entry_with_no_file_is_an_error(self):
|
||||
self.bundle()
|
||||
self.t.index("V", "filament", "Gone", "filament/Gone.json")
|
||||
errors, out = self.per_vendor_errors()
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("Missing sub profile", out)
|
||||
|
||||
def test_a_vector_option_may_not_be_a_scalar(self):
|
||||
self.bundle().write("V", "filament/B.json", {
|
||||
"type": "filament", "name": "B", "filament_type": "PLA"})
|
||||
errors, out = self.per_vendor_errors()
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("must be an array", out)
|
||||
|
||||
def test_renamed_and_old_option_may_not_co_exist(self):
|
||||
self.bundle().write("V", "machine/M.json", {
|
||||
"type": "machine", "name": "M 0.4 nozzle",
|
||||
"extruder_clearance_radius": "45", "extruder_clearance_max_radius": "68"})
|
||||
errors, out = self.per_vendor_errors()
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("Conflict keys", out)
|
||||
|
||||
def test_the_length_rule_only_binds_indexed_presets(self):
|
||||
# A file the index never loads cannot break AMS matching, and some
|
||||
# bundles ship such orphans from before the rule existed.
|
||||
self.bundle().write("V", "filament/Long.json", {
|
||||
"type": "filament", "name": "Long", "filament_id": "OFtoolongforams"})
|
||||
errors, out = self.per_vendor_errors()
|
||||
self.assertEqual(errors, 0, out)
|
||||
self.t.index("V", "filament", "Long", "filament/Long.json")
|
||||
errors, out = self.per_vendor_errors()
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("Filament id too long", out)
|
||||
|
||||
def test_obsolete_key_warnings_exclude_active_and_renamed_options(self):
|
||||
self.bundle().write("V", "filament/B.json", {
|
||||
"type": "filament", "name": "B", "silent_mode": "0",
|
||||
"anisotropic_surfaces": "0", "extruder_type": ["Direct Drive"],
|
||||
"extruder_clearance_max_radius": "68"})
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
warnings = apt.check_obsolete_keys(self.t.profiles, "V")
|
||||
self.assertEqual(warnings, 2)
|
||||
self.assertIn("Obsolete key", buf.getvalue())
|
||||
|
||||
def test_obsolete_key_warnings_run_without_a_flag(self):
|
||||
self.t.write("V", "filament/A.json", {
|
||||
"type": "filament", "name": "A", "silent_mode": "0"})
|
||||
self.run_command("update-index")
|
||||
rc, out = self.run_command("check")
|
||||
self.assertEqual(rc, 1, out) # normalization also rejects the obsolete key
|
||||
self.assertIn("Obsolete key: 'silent_mode' found in V/filament/A.json", out)
|
||||
self.assertIn("Files with warnings : 1", out)
|
||||
|
||||
def test_a_default_material_must_exist_somewhere(self):
|
||||
self.bundle().write("V", "machine/M.json", {
|
||||
"type": "machine", "name": "M 0.4 nozzle",
|
||||
"default_materials": "A;Nope"})
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
errors, _warn = apt.check_machine_default_materials(self.t.profiles, "V")
|
||||
self.assertEqual(errors, 1)
|
||||
self.assertIn("'Nope'", buf.getvalue())
|
||||
|
||||
def test_a_default_material_fails_check_without_a_flag(self):
|
||||
# The reference check is part of the default run, not an opt-in: a
|
||||
# dangling name has to fail plain `check`.
|
||||
self.bundle()
|
||||
self.t.write("V", "machine/M.json", {
|
||||
"type": "machine", "name": "M 0.4 nozzle",
|
||||
"default_filament_profile": ["A", "Nope"]})
|
||||
self.t.index("V", "machine", "M 0.4 nozzle", "machine/M.json")
|
||||
rc, out = self.run_command("check")
|
||||
self.assertEqual(rc, 1, out)
|
||||
self.assertIn("Missing filament profile: 'Nope'", out)
|
||||
|
||||
def test_the_stray_user_directory_is_not_a_vendor(self):
|
||||
# A local validator run leaves resources/profiles/user/ behind; an
|
||||
# unscoped check must not count it as a bundle and warn about it.
|
||||
self.bundle()
|
||||
for sub in apt.PROFILE_SUBDIRS:
|
||||
os.makedirs(os.path.join(self.t.profiles, apt.USER_DIR, "default", sub))
|
||||
_rc, out = self.run_command("check")
|
||||
self.assertIn("Checked vendors : 1", out)
|
||||
self.assertNotIn("user", out)
|
||||
|
||||
def names(self, vendor="V"):
|
||||
"""The preset name check for one bundle, which is what --vendor narrows."""
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
errors = apt.check_preset_name_uniqueness(self.t.profiles, vendor)
|
||||
return errors, buf.getvalue()
|
||||
|
||||
def test_one_bundle_may_not_hold_two_profiles_of_a_name(self):
|
||||
self.bundle().write("V", "filament/dup.json", {
|
||||
"type": "filament", "name": "A", "instantiation": "false"})
|
||||
errors, out = self.names()
|
||||
self.assertEqual(errors, 1, out)
|
||||
self.assertIn('V has 2 filament profiles named "A"', out)
|
||||
|
||||
def test_an_unindexed_twin_counts_as_a_duplicate(self):
|
||||
# The case this check was written for: a stale copy of a base profile in
|
||||
# machine/, which no per-vendor check walked, one index edit away from
|
||||
# silently deciding which of the two a whole bundle inherits from.
|
||||
self.bundle()
|
||||
self.t.write("V", "machine/HSN/base.json",
|
||||
{"type": "machine", "name": "V base"})
|
||||
self.t.index("V", "machine", "V base", "machine/HSN/base.json")
|
||||
self.t.write("V", "machine/base.json", {"type": "machine", "name": "V base"})
|
||||
errors, out = self.names()
|
||||
self.assertEqual(errors, 1, out)
|
||||
self.assertIn("machine/HSN/base.json", out)
|
||||
self.assertIn("machine/base.json", out)
|
||||
|
||||
def test_one_name_in_two_types_is_not_a_clash(self):
|
||||
self.bundle()
|
||||
self.t.write("V", "process/same.json", {"type": "process", "name": "A"})
|
||||
errors, out = self.names()
|
||||
self.assertEqual(errors, 0, out)
|
||||
|
||||
def test_a_name_is_per_bundle_not_global(self):
|
||||
# fdm_machine_common exists in 60 shipped bundles; the name is scoped to the
|
||||
# bundle that resolves it, so sharing one across vendors is not a clash.
|
||||
for vendor in ("V", "W"):
|
||||
self.t.write(vendor, "machine/common.json",
|
||||
{"type": "machine", "name": "fdm_machine_common"})
|
||||
self.t.index(vendor, "machine", "fdm_machine_common", "machine/common.json")
|
||||
for vendor in ("V", "W"):
|
||||
errors, out = self.names(vendor)
|
||||
self.assertEqual(errors, 0, out)
|
||||
|
||||
def coverage(self, vendor="V"):
|
||||
"""The index-coverage check for one bundle: (errors, gaps, output)."""
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
errors, gaps = apt.check_index_coverage(self.t.profiles, vendor)
|
||||
return errors, gaps, buf.getvalue()
|
||||
|
||||
def test_a_file_no_list_references_is_an_error(self):
|
||||
self.bundle().write("V", "filament/Stray.json",
|
||||
{"type": "filament", "name": "Stray"})
|
||||
errors, gaps, out = self.coverage()
|
||||
self.assertEqual(errors, 1, out)
|
||||
self.assertEqual(gaps["unindexed"], 1)
|
||||
self.assertIn("no V.json list references it", out)
|
||||
|
||||
def test_a_file_with_no_type_is_its_own_category(self):
|
||||
# update-index cannot place it, so "add it to the index" is not the remedy.
|
||||
self.bundle().write("V", "filament/Stray.json", {"name": "Stray"})
|
||||
errors, gaps, out = self.coverage()
|
||||
self.assertEqual(errors, 1, out)
|
||||
self.assertEqual(gaps["unindexable"], 1)
|
||||
self.assertIn("declares no profile type", out)
|
||||
|
||||
def test_an_unparsable_unlisted_file_is_reported_too(self):
|
||||
self.bundle().write_raw("V", "filament/Broken.json", b"{ not json")
|
||||
errors, gaps, out = self.coverage()
|
||||
self.assertEqual(errors, 1, out)
|
||||
self.assertEqual(gaps["unindexable"], 1)
|
||||
|
||||
def test_a_dotted_sub_path_still_counts_as_listed(self):
|
||||
self.bundle()
|
||||
self.t.write("V", "filament/Dotted.json",
|
||||
{"type": "filament", "name": "Dotted"})
|
||||
self.t.index("V", "filament", "Dotted", "filament/./Dotted.json")
|
||||
errors, _gaps, out = self.coverage()
|
||||
self.assertEqual(errors, 0, out)
|
||||
|
||||
def test_a_data_file_is_not_expected_in_the_index(self):
|
||||
self.bundle().write("V", "filament/filaments_color_codes.json",
|
||||
{"data": [], "total": 0})
|
||||
errors, _gaps, out = self.coverage()
|
||||
self.assertEqual(errors, 0, out)
|
||||
|
||||
def test_a_bundle_with_no_index_is_left_to_the_name_check(self):
|
||||
# Every file unlisted because there is no list at all is one problem, not
|
||||
# one per file; check_name_consistency reports the missing index.
|
||||
self.t.write("W", "filament/A.json", {"type": "filament", "name": "A"})
|
||||
os.remove(self.t.index_path("W"))
|
||||
errors, _gaps, out = self.coverage("W")
|
||||
self.assertEqual(errors, 0, out)
|
||||
|
||||
def test_the_remedy_is_printed_once_not_once_per_file(self):
|
||||
self.bundle()
|
||||
for n in range(5):
|
||||
self.t.write("V", f"filament/Stray{n}.json",
|
||||
{"type": "filament", "name": f"Stray{n}"})
|
||||
self.t.write("V", "filament/NoType.json", {"name": "NoType"})
|
||||
rc, out = self.run_command("check")
|
||||
self.assertEqual(rc, 1, out)
|
||||
self.assertEqual(out.count("update-index\" to add them"), 1, out)
|
||||
self.assertEqual(out.count("or delete them"), 1, out)
|
||||
self.assertIn("5 unreferenced file(s)", out)
|
||||
self.assertIn("1 unreferenced file(s)", out)
|
||||
|
||||
def test_setting_id_uniqueness_is_tree_wide(self):
|
||||
# Two presets sharing vendor/type/name mint one id, so the collision
|
||||
# only shows up in a pass that has seen the whole tree.
|
||||
shared = apt.generate_preset_setting_id("V", "filament", "A")
|
||||
for rel in ("filament/A.json", "filament/nested/A.json"):
|
||||
self.t.write("V", rel, {"type": "filament", "name": "A",
|
||||
"instantiation": "true", "setting_id": shared})
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
errors = apt.check_setting_id_uniqueness(self.t.profiles)
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("globally unique", buf.getvalue())
|
||||
|
||||
def test_a_base_profile_must_not_carry_a_setting_id(self):
|
||||
self.t.write("V", "filament/base.json", {
|
||||
"type": "filament", "name": "base", "instantiation": "false",
|
||||
"setting_id": apt.generate_preset_setting_id("V", "filament", "base")})
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
errors = apt.check_setting_id_uniqueness(self.t.profiles)
|
||||
self.assertEqual(errors, 1)
|
||||
self.assertIn("must not have a", buf.getvalue())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check: normalize and update-index would change nothing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNormalized(TreeCase):
|
||||
"""The pass that holds a bundle to the shape normalize and update-index write."""
|
||||
|
||||
def normalize(self):
|
||||
"""Put the tree in that shape, the way a contributor is told to."""
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
apt.main(["normalize", "--profiles", self.t.profiles])
|
||||
apt.main(["update-index", "--profiles", self.t.profiles])
|
||||
return buf.getvalue()
|
||||
|
||||
def normalized(self, vendor="V"):
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
errors, gaps = apt.check_normalized(self.t.profiles, vendor)
|
||||
return errors, gaps, buf.getvalue()
|
||||
|
||||
def test_a_bundle_the_two_commands_just_wrote_reports_nothing(self):
|
||||
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
|
||||
self.t.write("V", "process/B.json", {"type": "process", "name": "B"})
|
||||
self.normalize()
|
||||
errors, _gaps, out = self.normalized()
|
||||
self.assertEqual(errors, 0, out)
|
||||
|
||||
def test_a_profile_fix_would_rewrite_is_an_error(self):
|
||||
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
|
||||
self.normalize()
|
||||
# version belongs to the bundle, in <vendor>.json, never to a preset.
|
||||
data = self.t.read("V", "filament/A.json")
|
||||
data["version"] = "01.00.00.00"
|
||||
self.t.write("V", "filament/A.json", data)
|
||||
errors, gaps, out = self.normalized()
|
||||
self.assertEqual(errors, 1, out)
|
||||
self.assertEqual(gaps["unnormalized"], 1, out)
|
||||
self.assertIn("V/filament/A.json: normalize would remove version", out)
|
||||
|
||||
def test_an_index_update_index_would_rebuild_is_an_error(self):
|
||||
for name, parent in (("B", "A"), ("A", None)):
|
||||
data = {"type": "filament", "name": name}
|
||||
if parent:
|
||||
data["inherits"] = parent
|
||||
self.t.write("V", f"filament/{name}.json", data)
|
||||
self.normalize()
|
||||
# Parents-first is what lets the loader resolve inherits in one pass; a
|
||||
# hand-edited list that puts the child first still names every file.
|
||||
index = self.t.read_index("V")
|
||||
index["filament_list"].reverse()
|
||||
self.t.write_index("V", index)
|
||||
errors, gaps, out = self.normalized()
|
||||
self.assertEqual(errors, 1, out)
|
||||
self.assertEqual(gaps["stale_index"], 1, out)
|
||||
self.assertIn("V.json: update-index would rebuild filament_list", out)
|
||||
|
||||
def test_an_unbuildable_index_is_left_to_the_checks_that_name_it(self):
|
||||
# update-index refuses to rebuild a bundle where two files claim one name,
|
||||
# so "would be rebuilt" on top of the duplicate-name error would be noise.
|
||||
self.t.write("V", "machine/base.json", {"type": "machine", "name": "base"})
|
||||
self.normalize()
|
||||
self.t.write("V", "machine/HSN/base.json", {"type": "machine", "name": "base"})
|
||||
errors, gaps, out = self.normalized()
|
||||
self.assertEqual(errors, 0, out)
|
||||
self.assertEqual(gaps["stale_index"], 0, out)
|
||||
|
||||
def test_a_bundle_with_no_index_still_has_its_files_checked(self):
|
||||
self.t.write("V", "filament/A.json",
|
||||
{"type": "filament", "name": "A", "is_custom_defined": "0"})
|
||||
os.remove(self.t.index_path("V"))
|
||||
errors, gaps, out = self.normalized()
|
||||
self.assertEqual(errors, 1, out)
|
||||
self.assertEqual(gaps["unnormalized"], 1, out)
|
||||
self.assertEqual(gaps["stale_index"], 0, out)
|
||||
|
||||
def test_the_shared_base_bundle_is_covered_too(self):
|
||||
# normalize and update-index own the shape of every bundle, the shared
|
||||
# library included.
|
||||
self.t.write(apt.OFL, "filament/A.json",
|
||||
{"type": "filament", "name": "A", "version": "01.00.00.00"})
|
||||
rc, out = self.run_command("check")
|
||||
self.assertEqual(rc, 1, out)
|
||||
self.assertIn(f"{apt.OFL}/filament/A.json: normalize would remove version", out)
|
||||
|
||||
def test_each_remedy_is_printed_once_for_the_whole_run(self):
|
||||
for n in range(3):
|
||||
self.t.write("V", f"filament/A{n}.json",
|
||||
{"type": "filament", "name": f"A{n}",
|
||||
"version": "01.00.00.00"})
|
||||
self.t.write("W", "filament/B.json", {"type": "filament", "name": "B"})
|
||||
rc, out = self.run_command("check")
|
||||
self.assertEqual(rc, 1, out)
|
||||
self.assertIn("3 profile file(s) above are not what", out)
|
||||
self.assertEqual(out.count('normalize" writes: run it and commit'), 1, out)
|
||||
self.assertIn("2 vendor index(es) above are not what", out)
|
||||
self.assertEqual(out.count('update-index" writes: run it and commit'), 1, out)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDispatch(TreeCase):
|
||||
def test_each_command_reaches_its_own_writer(self):
|
||||
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
|
||||
for command, expected in (("normalize", "normalized"),
|
||||
("trim", "unreferenced"),
|
||||
("update-index", "vendor index")):
|
||||
with self.subTest(command=command):
|
||||
rc, out = self.run_command(command, "--dry-run")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn(expected, out)
|
||||
|
||||
def test_an_option_belongs_to_one_command_only(self):
|
||||
for argv in (["trim", "--force"],
|
||||
["update-index", "--filament-id"],
|
||||
["check", "--profile-type", "filament"]):
|
||||
with self.subTest(argv=argv):
|
||||
with self.assertRaises(SystemExit) as cm, \
|
||||
contextlib.redirect_stdout(io.StringIO()), \
|
||||
contextlib.redirect_stderr(io.StringIO()):
|
||||
apt.main([*argv, "--profiles", self.t.profiles])
|
||||
self.assertEqual(cm.exception.code, 2)
|
||||
|
||||
def test_an_unknown_vendor_stops_the_run(self):
|
||||
self.t.write("V", "filament/A.json", {"name": "A"})
|
||||
before = self.t.bytes_map()
|
||||
rc, out = self.run_command("normalize", "--vendor", "Nope")
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("unknown vendor", out)
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
|
||||
def test_an_empty_vendor_means_every_vendor(self):
|
||||
self.t.write("V", "filament/A.json", {"name": "A"})
|
||||
rc, out = self.run_command("normalize", "--vendor", "")
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertEqual(self.t.read("V", "filament/A.json")["type"], "filament")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the real tree
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@unittest.skipUnless(os.path.isdir(REAL_PROFILES), "resources/profiles not present")
|
||||
class TestRealTree(unittest.TestCase):
|
||||
def test_check_passes(self):
|
||||
# The exact CI invocation, return code included.
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
rc = apt.main(["check"])
|
||||
self.assertEqual(rc, 0, buf.getvalue())
|
||||
|
||||
def test_the_shipped_tree_needs_no_fix(self):
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
changed, errors = apt.normalize_profiles(REAL_PROFILES, dry_run=True)
|
||||
self.assertEqual(errors, 0, buf.getvalue())
|
||||
self.assertEqual(changed, 0, buf.getvalue())
|
||||
|
||||
def test_the_shipped_indexes_need_no_rebuild(self):
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
changed, errors = apt.update_profile_indexes(REAL_PROFILES, dry_run=True)
|
||||
self.assertEqual(errors, 0, buf.getvalue())
|
||||
self.assertEqual(changed, 0, buf.getvalue())
|
||||
|
||||
def test_no_shipped_bundle_is_a_stray_json_file(self):
|
||||
# blacklist.json has no directory beside it, so it is not a vendor.
|
||||
self.assertNotIn("blacklist", apt.list_vendor_names(REAL_PROFILES))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,899 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the setting_id half of scripts/orca_profile_tool.py (stdlib unittest, no
|
||||
external deps).
|
||||
|
||||
Run from the repo root: python -m unittest discover -s scripts/tests -v
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import uuid
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
import orca_profile_tool as afi # noqa: E402
|
||||
|
||||
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
REAL_PROFILES = os.path.join(REPO_ROOT, "resources", "profiles")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# helpers: synthetic profile trees
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def preset(name, instantiation=True, setting_id=None, settings_id=None,
|
||||
filament_id=None, type_name="filament", **extra):
|
||||
"""A preset in the canonical key order the shipped profiles use."""
|
||||
data = {"type": type_name, "name": name, "from": "system"}
|
||||
if setting_id is not None:
|
||||
data["setting_id"] = setting_id
|
||||
if settings_id is not None:
|
||||
data["settings_id"] = settings_id
|
||||
if filament_id is not None:
|
||||
data["filament_id"] = filament_id
|
||||
data["instantiation"] = "true" if instantiation else "false"
|
||||
data.update(extra)
|
||||
return data
|
||||
|
||||
|
||||
class SettingTree:
|
||||
"""A throwaway resources/profiles-shaped directory of setting_id-bearing bundles.
|
||||
|
||||
A bundle is a `<vendor>/` directory plus the sibling `<vendor>.json` index
|
||||
that makes list_vendor_names() see it; presets live under filament/,
|
||||
process/ and machine/, the three subdirs generate_setting_ids walks.
|
||||
|
||||
write() also registers filament presets in the index's filament_list, the
|
||||
way a shipped bundle does. generate_setting_ids never reads that list, but
|
||||
generate_filament_ids does: without it the filament half of the tool is a
|
||||
no-op on this tree, and the tests that assert --setting-id leaves
|
||||
filament_ids alone would hold for the wrong reason.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.dir = tempfile.mkdtemp(prefix="setting_id_test_")
|
||||
self.profiles = os.path.join(self.dir, "profiles")
|
||||
os.makedirs(self.profiles)
|
||||
|
||||
def cleanup(self):
|
||||
shutil.rmtree(self.dir, ignore_errors=True)
|
||||
|
||||
def index_path(self, vendor):
|
||||
return os.path.join(self.profiles, vendor + ".json")
|
||||
|
||||
def add_vendor(self, vendor):
|
||||
"""Create the bundle dir and its index; idempotent, keeps the list."""
|
||||
for sub in afi.PROFILE_SUBDIRS:
|
||||
os.makedirs(os.path.join(self.profiles, vendor, sub), exist_ok=True)
|
||||
if not os.path.exists(self.index_path(vendor)):
|
||||
self._write_index(vendor, {"name": vendor, "version": "01.00.00.00",
|
||||
"filament_list": []})
|
||||
|
||||
def _write_index(self, vendor, index):
|
||||
with open(self.index_path(vendor), "w", encoding="utf-8",
|
||||
newline="\n") as f:
|
||||
json.dump(index, f, indent=4, ensure_ascii=False)
|
||||
|
||||
def register(self, vendor, subdir, name):
|
||||
"""Add a filament preset to the bundle index's filament_list."""
|
||||
with open(self.index_path(vendor), encoding="utf-8") as f:
|
||||
index = json.load(f)
|
||||
sub_path = os.path.join(subdir, name + ".json").replace(os.sep, "/")
|
||||
index["filament_list"].append({"name": name, "sub_path": sub_path})
|
||||
self._write_index(vendor, index)
|
||||
|
||||
def path(self, vendor, subdir, name):
|
||||
return os.path.join(self.profiles, vendor, subdir, name + ".json")
|
||||
|
||||
def write(self, vendor, subdir, data, name=None):
|
||||
"""Write a preset as indented LF JSON; returns its path."""
|
||||
self.add_vendor(vendor)
|
||||
file_name = name if name is not None else data["name"]
|
||||
path = self.path(vendor, subdir, file_name)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8", newline="\n") as f:
|
||||
json.dump(data, f, indent=4, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
if subdir.split(os.sep)[0] == "filament" and data.get("name"):
|
||||
self.register(vendor, subdir, file_name)
|
||||
return path
|
||||
|
||||
def write_raw(self, vendor, subdir, name, raw):
|
||||
"""Write exact bytes (BOM, CRLF, tabs, broken JSON); returns its path."""
|
||||
self.add_vendor(vendor)
|
||||
path = self.path(vendor, subdir, name)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "wb") as f:
|
||||
f.write(raw)
|
||||
return path
|
||||
|
||||
def read(self, vendor, subdir, name):
|
||||
with open(self.path(vendor, subdir, name), encoding="utf-8-sig") as f:
|
||||
return json.load(f)
|
||||
|
||||
def raw(self, vendor, subdir, name):
|
||||
with open(self.path(vendor, subdir, name), "rb") as f:
|
||||
return f.read()
|
||||
|
||||
def bytes_map(self):
|
||||
"""relative path -> bytes, for every file in the tree."""
|
||||
out = {}
|
||||
for root, dirs, files in os.walk(self.profiles):
|
||||
dirs.sort()
|
||||
for name in sorted(files):
|
||||
path = os.path.join(root, name)
|
||||
with open(path, "rb") as f:
|
||||
out[os.path.relpath(path, self.profiles)] = f.read()
|
||||
return out
|
||||
|
||||
# -- pipeline wrappers ---------------------------------------------------
|
||||
|
||||
def run(self, vendors=None, dry_run=False):
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
changed, errors = afi.generate_setting_ids(self.profiles, vendors, dry_run)
|
||||
return changed, errors, buf.getvalue()
|
||||
|
||||
def run_filament_ids(self, vendors=None, dry_run=False):
|
||||
"""The OTHER half of --generate."""
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
changed, errors = afi.generate_filament_ids(self.profiles, vendors, dry_run)
|
||||
return changed, errors, buf.getvalue()
|
||||
|
||||
|
||||
class SettingTreeCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.t = SettingTree()
|
||||
self.addCleanup(self.t.cleanup)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# mint: the C++/Python byte-identity contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSettingIdMint(unittest.TestCase):
|
||||
# Copied verbatim from tests/libslic3r/test_preset_setting_id.cpp: the C++
|
||||
# generate_preset_setting_id() recomputes these ids on the fly, so the two
|
||||
# implementations must stay byte-identical.
|
||||
GOLDEN = [
|
||||
("Afinia", "filament", "Afinia ABS @Afinia H400", "TL34qSVkppBvMvgH"),
|
||||
("Afinia", "process", "0.20mm Standard @Afinia H400", "FzmtNsy7XQvpd7w0"),
|
||||
("Afinia", "machine", "Afinia H400 0.4 nozzle", "r4FZagW0S8uoaJPd"),
|
||||
("Anycubic", "filament", "Generic PLA @Anycubic Kobra 2", "YIWGGLQ8Oepd30Fv"),
|
||||
("Creality", "process", "0.16mm Optimal @Creality Ender-3 V3", "2Nrbq8PxssUPBLza"),
|
||||
("Elegoo", "machine", "Elegoo Neptune 4 0.4 nozzle", "69QdWuRQwAZk9rFu"),
|
||||
]
|
||||
|
||||
def test_golden_vectors(self):
|
||||
for vendor, type_name, name, expected in self.GOLDEN:
|
||||
with self.subTest(name=name):
|
||||
self.assertEqual(
|
||||
afi.generate_preset_setting_id(vendor, type_name, name), expected)
|
||||
|
||||
def test_namespace_and_length_are_frozen(self):
|
||||
# Baked into the C++ side and into every shipped profile; never change it.
|
||||
self.assertEqual(afi.NAMESPACE,
|
||||
uuid.UUID("c1f4d9e2-7a3b-5c8d-9e0f-1a2b3c4d5e6f"))
|
||||
self.assertEqual(afi.SETTING_ID_LENGTH, 16)
|
||||
self.assertEqual(
|
||||
afi.ALPHABET,
|
||||
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
|
||||
self.assertEqual(len(afi.ALPHABET), 62)
|
||||
|
||||
def test_format_is_sixteen_alphabet_chars(self):
|
||||
for vendor, type_name, name in [("Creality", "filament", "CR PLA @Ender"),
|
||||
("Elegoo", "machine", "Elegoo Neptune 4"),
|
||||
("拓竹", "filament", "拓竹 PLA @P1")]:
|
||||
with self.subTest(name=name):
|
||||
sid = afi.generate_preset_setting_id(vendor, type_name, name)
|
||||
self.assertEqual(len(sid), afi.SETTING_ID_LENGTH)
|
||||
self.assertTrue(set(sid) <= set(afi.ALPHABET), sid)
|
||||
|
||||
def test_is_the_low_base62_digits_of_the_uuid5(self):
|
||||
# Independent re-implementation of the whole rule, key layout included.
|
||||
for vendor, type_name, name, _expected in self.GOLDEN:
|
||||
u = uuid.uuid5(afi.NAMESPACE, f"{vendor}/{type_name}/{name}")
|
||||
n = int.from_bytes(u.bytes, "big")
|
||||
digits = ""
|
||||
for _ in range(afi.SETTING_ID_LENGTH):
|
||||
digits = afi.ALPHABET[n % 62] + digits
|
||||
n //= 62
|
||||
self.assertEqual(afi.generate_preset_setting_id(vendor, type_name, name),
|
||||
digits)
|
||||
|
||||
def test_deterministic(self):
|
||||
a = afi.generate_preset_setting_id("VendorX", "filament", "My PLA")
|
||||
self.assertEqual(a, afi.generate_preset_setting_id("VendorX", "filament", "My PLA"))
|
||||
|
||||
def test_every_identity_component_changes_the_id(self):
|
||||
base = afi.generate_preset_setting_id("VendorX", "filament", "My PLA")
|
||||
self.assertNotEqual(base, afi.generate_preset_setting_id("VendorY", "filament", "My PLA"))
|
||||
self.assertNotEqual(base, afi.generate_preset_setting_id("VendorX", "process", "My PLA"))
|
||||
self.assertNotEqual(base, afi.generate_preset_setting_id("VendorX", "filament", "My PETG"))
|
||||
|
||||
def test_key_is_a_flat_slash_join(self):
|
||||
# The mint key is "<vendor>/<type>/<name>" with no escaping, so a "/" in
|
||||
# a component shifts the split — harmless in practice (vendor is a
|
||||
# directory name and type is one of PROFILE_SUBDIRS), but it is what the
|
||||
# C++ side does too and the two must agree byte for byte.
|
||||
self.assertEqual(afi.generate_preset_setting_id("A/B", "filament", "C"),
|
||||
afi.generate_preset_setting_id("A", "B/filament", "C"))
|
||||
|
||||
|
||||
class TestBase62Tail(unittest.TestCase):
|
||||
def test_hand_computed_digits(self):
|
||||
self.assertEqual(afi._base62_tail(0, 4), "0000")
|
||||
self.assertEqual(afi._base62_tail(61, 1), "z") # last alphabet char
|
||||
self.assertEqual(afi._base62_tail(62, 2), "10") # 1*62 + 0
|
||||
self.assertEqual(afi._base62_tail(3843, 2), "zz") # 61*62 + 61
|
||||
self.assertEqual(afi._base62_tail(3907, 3), "111") # 62^2 + 62 + 1
|
||||
|
||||
def test_keeps_only_the_low_digits(self):
|
||||
self.assertEqual(afi._base62_tail(62, 1), "0") # high digit dropped
|
||||
self.assertEqual(afi._base62_tail(3907, 2), "11")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# assignment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAssignment(SettingTreeCase):
|
||||
def test_instantiation_is_read_exactly_as_the_validator_reads_it(self):
|
||||
# check_setting_id_uniqueness tests `instantiation == "true"` strictly.
|
||||
# Anything looser here would hand an id to a preset the validator calls
|
||||
# a base profile, and the two would fight over it on every run.
|
||||
for name, value in [("Boolean", True), ("Capitalised", "True"),
|
||||
("Numeric", 1), ("Absent", None)]:
|
||||
with self.subTest(instantiation=value):
|
||||
data = {"type": "filament", "name": name, "from": "system"}
|
||||
if value is not None:
|
||||
data["instantiation"] = value
|
||||
self.t.write("VendorA", "filament", data)
|
||||
before = self.t.bytes_map()
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (0, 0), out)
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
|
||||
def test_a_bundle_without_an_index_is_still_assigned(self):
|
||||
# setting_id is a per-file property, and the validator walks every
|
||||
# directory. A bundle whose index has not landed yet must be fixable,
|
||||
# or the validator flags files this tool refuses to touch.
|
||||
path = os.path.join(self.t.profiles, "Noindex", "process", "Q.json")
|
||||
os.makedirs(os.path.dirname(path))
|
||||
with open(path, "w", encoding="utf-8", newline="\n") as f:
|
||||
json.dump(preset("Q", type_name="process"), f, indent=4)
|
||||
self.assertFalse(os.path.exists(self.t.index_path("Noindex")))
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
self.assertEqual(json.load(f)["setting_id"],
|
||||
afi.generate_preset_setting_id("Noindex", "process", "Q"))
|
||||
|
||||
def test_assigns_across_every_profile_subdir(self):
|
||||
self.t.write("VendorA", "filament", preset("A PLA @P1"))
|
||||
self.t.write("VendorA", "process",
|
||||
preset("0.20mm Standard @P1", type_name="process"))
|
||||
self.t.write("VendorA", "machine",
|
||||
preset("P1 0.4 nozzle", type_name="machine"))
|
||||
# os.walk recursion: a preset in a nested directory is walked too.
|
||||
self.t.write("VendorA", os.path.join("filament", "nested"),
|
||||
preset("A PETG @P1"))
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (4, 0), out)
|
||||
for subdir, type_name, name in [
|
||||
("filament", "filament", "A PLA @P1"),
|
||||
("process", "process", "0.20mm Standard @P1"),
|
||||
("machine", "machine", "P1 0.4 nozzle"),
|
||||
(os.path.join("filament", "nested"), "filament", "A PETG @P1")]:
|
||||
self.assertEqual(
|
||||
self.t.read("VendorA", subdir, name)["setting_id"],
|
||||
afi.generate_preset_setting_id("VendorA", type_name, name),
|
||||
msg=name)
|
||||
|
||||
def test_type_comes_from_the_subdirectory_not_the_type_field(self):
|
||||
# The subdir name is the type name (Preset::get_type_string()); a stale
|
||||
# "type" field inside the file does not enter the id.
|
||||
self.t.write("VendorA", "process", preset("Odd @P1", type_name="filament"))
|
||||
changed, errors, out = self.t.run()
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
self.assertEqual(self.t.read("VendorA", "process", "Odd @P1")["setting_id"],
|
||||
afi.generate_preset_setting_id("VendorA", "process", "Odd @P1"))
|
||||
|
||||
def test_idempotent(self):
|
||||
self.t.write("VendorA", "filament", preset("A PLA @P1"))
|
||||
self.t.write("VendorA", "process",
|
||||
preset("0.20mm Standard @P1", type_name="process"))
|
||||
self.t.write("VendorA", "filament", preset("A PLA @base", instantiation=False,
|
||||
setting_id="LEFTOVER00000000"))
|
||||
changed, errors, out = self.t.run()
|
||||
self.assertEqual((changed, errors), (3, 0), out)
|
||||
|
||||
after_first = self.t.bytes_map()
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (0, 0), out)
|
||||
self.assertEqual(self.t.bytes_map(), after_first)
|
||||
|
||||
def test_stale_value_is_replaced_in_place(self):
|
||||
path = self.t.write("VendorA", "filament",
|
||||
preset("A PLA @P1", setting_id="0000000000000000",
|
||||
filament_id="OFabc123"))
|
||||
before = self.t.raw("VendorA", "filament", "A PLA @P1")
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
want = afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1")
|
||||
self.assertEqual(self.t.read("VendorA", "filament", "A PLA @P1")["setting_id"],
|
||||
want)
|
||||
raw = self.t.raw("VendorA", "filament", "A PLA @P1")
|
||||
self.assertEqual(raw.count(b'"setting_id"'), 1) # replaced, not appended
|
||||
self.assertEqual(raw, before.replace(b'"0000000000000000"',
|
||||
b'"%s"' % want.encode()))
|
||||
self.assertTrue(os.path.isfile(path))
|
||||
|
||||
def test_missing_value_is_inserted_before_filament_id(self):
|
||||
self.t.write("VendorA", "filament",
|
||||
preset("A PLA @P1", filament_id="OFabc123"))
|
||||
changed, errors, out = self.t.run()
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
text = self.t.raw("VendorA", "filament", "A PLA @P1").decode("utf-8")
|
||||
want = afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1")
|
||||
self.assertIn(f'"setting_id": "{want}"', text)
|
||||
self.assertLess(text.index('"setting_id"'), text.index('"filament_id"'))
|
||||
|
||||
def test_base_profiles_are_stripped(self):
|
||||
self.t.write("VendorA", "filament",
|
||||
preset("A PLA @base", instantiation=False,
|
||||
setting_id="0000000000000000", filament_id="OFabc123"))
|
||||
changed, errors, out = self.t.run()
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
data = self.t.read("VendorA", "filament", "A PLA @base")
|
||||
self.assertNotIn("setting_id", data)
|
||||
self.assertEqual(data["filament_id"], "OFabc123") # nothing else touched
|
||||
self.assertEqual(data["instantiation"], "false")
|
||||
|
||||
def test_base_profile_without_instantiation_key_is_stripped(self):
|
||||
# No "instantiation" key at all == not instantiated (str(None) != "true").
|
||||
data = {"type": "filament", "name": "A PLA @root", "from": "system",
|
||||
"setting_id": "0000000000000000"}
|
||||
self.t.write("VendorA", "filament", data)
|
||||
changed, errors, out = self.t.run()
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
self.assertNotIn("setting_id", self.t.read("VendorA", "filament", "A PLA @root"))
|
||||
|
||||
def test_misspelled_settings_id_is_dropped(self):
|
||||
self.t.write("VendorA", "filament",
|
||||
preset("A PLA @base", instantiation=False,
|
||||
settings_id="0000000000000000"))
|
||||
changed, errors, out = self.t.run()
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
data = self.t.read("VendorA", "filament", "A PLA @base")
|
||||
self.assertNotIn("settings_id", data)
|
||||
self.assertNotIn("setting_id", data)
|
||||
|
||||
def test_typo_drop_and_assignment_are_one_file_change(self):
|
||||
self.t.write("VendorA", "filament",
|
||||
preset("A PLA @P1", settings_id="0000000000000000",
|
||||
filament_id="OFabc123"))
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (1, 0), out) # ONE counted file change
|
||||
data = self.t.read("VendorA", "filament", "A PLA @P1")
|
||||
self.assertNotIn("settings_id", data)
|
||||
self.assertEqual(data["setting_id"],
|
||||
afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1"))
|
||||
|
||||
def test_reserved_vendor_keeps_instantiated_ids_and_loses_base_ones(self):
|
||||
# BBL owns the authoritative "G*" cloud id space: its instantiated
|
||||
# presets are never rewritten, its base declarations still are stripped.
|
||||
self.t.write("BBL", "filament",
|
||||
preset("Bambu ABS @BBL A1", setting_id="GFSB00_07"))
|
||||
self.t.write("BBL", "filament", preset("Bambu ABS @P1 no id"))
|
||||
self.t.write("BBL", "filament",
|
||||
preset("Bambu ABS @base", instantiation=False,
|
||||
setting_id="GFSB00_00"))
|
||||
kept = self.t.raw("BBL", "filament", "Bambu ABS @BBL A1")
|
||||
kept_idless = self.t.raw("BBL", "filament", "Bambu ABS @P1 no id")
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (1, 0), out) # only the base profile
|
||||
self.assertEqual(self.t.raw("BBL", "filament", "Bambu ABS @BBL A1"), kept)
|
||||
self.assertEqual(self.t.raw("BBL", "filament", "Bambu ABS @P1 no id"),
|
||||
kept_idless)
|
||||
self.assertNotIn("setting_id",
|
||||
self.t.read("BBL", "filament", "Bambu ABS @base"))
|
||||
|
||||
def test_reserved_vendors_misspelled_key_is_corrected_not_dropped(self):
|
||||
# A reserved vendor's id is authoritative, so there is no formula to
|
||||
# fall back on. Dropping the typo and stopping there would leave the
|
||||
# preset with no setting_id at all and no way for the tool to give it
|
||||
# one - a validator error nothing can clear. Fix the key, keep the value.
|
||||
self.t.write("BBL", "filament",
|
||||
preset("Bambu PLA @P1", settings_id="GFSA00_01"))
|
||||
# A base profile still just loses the key; it may not carry an id.
|
||||
self.t.write("BBL", "filament",
|
||||
preset("Bambu PLA @base", instantiation=False,
|
||||
settings_id="GFSA00_00"))
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (2, 0), out)
|
||||
fixed = self.t.read("BBL", "filament", "Bambu PLA @P1")
|
||||
self.assertNotIn("settings_id", fixed)
|
||||
self.assertEqual(fixed["setting_id"], "GFSA00_01")
|
||||
base = self.t.read("BBL", "filament", "Bambu PLA @base")
|
||||
self.assertNotIn("settings_id", base)
|
||||
self.assertNotIn("setting_id", base)
|
||||
# Idempotent: the corrected file is what the next run expects to see.
|
||||
self.assertEqual(self.t.run()[:2], (0, 0))
|
||||
|
||||
def test_a_managed_vendors_misspelled_key_is_still_replaced_by_the_mint(self):
|
||||
self.t.write("VendorA", "filament",
|
||||
preset("A PLA @P1", settings_id="whatever"))
|
||||
changed, errors, out = self.t.run()
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
data = self.t.read("VendorA", "filament", "A PLA @P1")
|
||||
self.assertNotIn("settings_id", data)
|
||||
self.assertEqual(data["setting_id"],
|
||||
afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1"))
|
||||
|
||||
def test_reserved_vendors_constant(self):
|
||||
self.assertEqual(afi.RESERVED_VENDORS, {"BBL"})
|
||||
self.assertEqual(afi.PROFILE_SUBDIRS, ("filament", "process", "machine"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --vendor narrowing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestVendorNarrowing(SettingTreeCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.t.write("VendorA", "filament", preset("A PLA @P1"))
|
||||
self.t.write("VendorB", "filament", preset("B PLA @P1"))
|
||||
|
||||
def test_restricts_writes_to_the_named_vendor(self):
|
||||
before = self.t.bytes_map()
|
||||
|
||||
changed, errors, out = self.t.run(vendors=["VendorA"])
|
||||
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
self.assertEqual(self.t.read("VendorA", "filament", "A PLA @P1")["setting_id"],
|
||||
afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1"))
|
||||
self.assertEqual(self.t.raw("VendorB", "filament", "B PLA @P1"),
|
||||
before[os.path.join("VendorB", "filament", "B PLA @P1.json")])
|
||||
# ... and the vendor left out is written by a later run, unchanged in kind.
|
||||
changed, errors, out = self.t.run()
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
self.assertEqual(self.t.read("VendorB", "filament", "B PLA @P1")["setting_id"],
|
||||
afi.generate_preset_setting_id("VendorB", "filament", "B PLA @P1"))
|
||||
|
||||
def test_unknown_vendor_reports_and_writes_nothing(self):
|
||||
before = self.t.bytes_map()
|
||||
|
||||
changed, errors, out = self.t.run(vendors=["Nope"])
|
||||
|
||||
self.assertEqual((changed, errors), (0, 1))
|
||||
self.assertIn("Nope", out)
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
|
||||
def test_unknown_vendor_blocks_the_known_ones_too(self):
|
||||
before = self.t.bytes_map()
|
||||
changed, errors, _out = self.t.run(vendors=["VendorA", "Nope"])
|
||||
self.assertEqual((changed, errors), (0, 1))
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --dry-run
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDryRun(SettingTreeCase):
|
||||
def test_writes_nothing_and_previews_the_real_run(self):
|
||||
self.t.write("VendorA", "filament", preset("A PLA @P1"))
|
||||
self.t.write("VendorA", "process",
|
||||
preset("0.20mm Standard @P1", type_name="process"))
|
||||
self.t.write("VendorA", "filament",
|
||||
preset("A PLA @base", instantiation=False,
|
||||
setting_id="0000000000000000"))
|
||||
before = self.t.bytes_map()
|
||||
|
||||
dry_changed, dry_errors, out = self.t.run(dry_run=True)
|
||||
|
||||
self.assertEqual((dry_changed, dry_errors), (3, 0), out)
|
||||
self.assertIn("would", out)
|
||||
self.assertEqual(self.t.bytes_map(), before) # nothing written
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (dry_changed, dry_errors), out)
|
||||
self.assertNotEqual(self.t.bytes_map(), before)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# byte preservation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBytePreservation(SettingTreeCase):
|
||||
CRLF_TEXT = (
|
||||
'{\r\n'
|
||||
'\t"type": "filament",\r\n'
|
||||
'\t"name": "CRLF PLA @P1",\r\n'
|
||||
'\t"from": "system",\r\n'
|
||||
'\t"filament_id": "OFabc123",\r\n'
|
||||
'\t"instantiation": "true",\r\n'
|
||||
'\t"filament_type": [\r\n'
|
||||
'\t\t"PLA"\r\n'
|
||||
'\t]\r\n'
|
||||
'}\r\n'
|
||||
)
|
||||
|
||||
def test_crlf_and_tab_indentation_survive(self):
|
||||
self.t.write_raw("VendorA", "filament", "CRLF PLA @P1",
|
||||
self.CRLF_TEXT.encode("utf-8"))
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
raw = self.t.raw("VendorA", "filament", "CRLF PLA @P1")
|
||||
self.assertEqual(raw.count(b"\n"), raw.count(b"\r\n")) # still CRLF-only
|
||||
want = afi.generate_preset_setting_id("VendorA", "filament", "CRLF PLA @P1")
|
||||
inserted = ('\t"setting_id": "%s",\r\n' % want).encode("utf-8")
|
||||
# Every original byte survives: dropping the inserted line restores the file.
|
||||
self.assertEqual(raw.replace(inserted, b"", 1),
|
||||
self.CRLF_TEXT.encode("utf-8"))
|
||||
|
||||
def test_bom_survives(self):
|
||||
raw_in = b"\xef\xbb\xbf" + json.dumps(
|
||||
preset("BOM PLA @P1", filament_id="OFabc123"),
|
||||
indent=4, ensure_ascii=False).encode("utf-8") + b"\n"
|
||||
self.t.write_raw("VendorA", "filament", "BOM PLA @P1", raw_in)
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
raw = self.t.raw("VendorA", "filament", "BOM PLA @P1")
|
||||
self.assertTrue(raw.startswith(b"\xef\xbb\xbf"))
|
||||
self.assertEqual(raw.count(b"\xef\xbb\xbf"), 1)
|
||||
want = afi.generate_preset_setting_id("VendorA", "filament", "BOM PLA @P1")
|
||||
self.assertEqual(self.t.read("VendorA", "filament", "BOM PLA @P1")["setting_id"],
|
||||
want)
|
||||
self.assertEqual(
|
||||
raw.replace((' "setting_id": "%s",\n' % want).encode("utf-8"), b"", 1),
|
||||
raw_in)
|
||||
|
||||
def test_non_ascii_name_round_trips(self):
|
||||
name = "拓竹 PLA @P1 0.4 nozzle"
|
||||
raw_in = (json.dumps(preset(name, filament_id="OFabc123"), indent=4,
|
||||
ensure_ascii=False).encode("utf-8") + b"\n")
|
||||
self.t.write_raw("VendorA", "filament", name, raw_in)
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
raw = self.t.raw("VendorA", "filament", name)
|
||||
self.assertIn(name.encode("utf-8"), raw) # not escaped to \uXXXX
|
||||
data = self.t.read("VendorA", "filament", name)
|
||||
self.assertEqual(data["name"], name)
|
||||
self.assertEqual(data["setting_id"],
|
||||
afi.generate_preset_setting_id("VendorA", "filament", name))
|
||||
|
||||
def test_surrounding_formatting_is_untouched_on_a_strip(self):
|
||||
text = ('{\r\n'
|
||||
'\t"type": "filament",\r\n'
|
||||
'\t"name": "Odd @base",\r\n'
|
||||
'\t"setting_id": "0000000000000000",\r\n'
|
||||
'\t"instantiation": "false",\r\n'
|
||||
'\t"compatible_printers": []\r\n'
|
||||
'}\r\n')
|
||||
self.t.write_raw("VendorA", "filament", "Odd @base", text.encode("utf-8"))
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
self.assertEqual(
|
||||
self.t.raw("VendorA", "filament", "Odd @base").decode("utf-8"),
|
||||
text.replace('\t"setting_id": "0000000000000000",\r\n', "", 1))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# byte-preserving key edits
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestInsertAnchor(unittest.TestCase):
|
||||
WITH_BOTH = ('{\n "name": "K @P1",\n "filament_id": "OFabc123",\n'
|
||||
' "instantiation": "true"\n}\n')
|
||||
WITH_INSTANTIATION = ('{\n "name": "K @P1",\n'
|
||||
' "instantiation": "true"\n}\n')
|
||||
|
||||
def test_inserts_before_filament_id(self):
|
||||
text, n = afi.insert_setting_id(self.WITH_BOTH, "0123456789abcdef")
|
||||
self.assertEqual(n, 1)
|
||||
json.loads(text)
|
||||
self.assertEqual(text, self.WITH_BOTH.replace(
|
||||
' "filament_id"',
|
||||
' "setting_id": "0123456789abcdef",\n "filament_id"', 1))
|
||||
|
||||
def test_falls_back_to_instantiation(self):
|
||||
text, n = afi.insert_setting_id(self.WITH_INSTANTIATION, "0123456789abcdef")
|
||||
self.assertEqual(n, 1)
|
||||
json.loads(text)
|
||||
self.assertIn('"setting_id": "0123456789abcdef",\n "instantiation"', text)
|
||||
|
||||
def test_falls_back_to_name(self):
|
||||
# Last-resort anchor: every preset has a name, so the insert does not
|
||||
# depend on filament_id having been written first — a dry run, which
|
||||
# writes none, must reach the same verdict as the real run.
|
||||
text, n = afi.insert_setting_id('{\n "name": "K",\n "x": 1\n}\n',
|
||||
"0123456789abcdef")
|
||||
self.assertEqual(n, 1)
|
||||
json.loads(text)
|
||||
self.assertIn('"name": "K",\n "setting_id": "0123456789abcdef",', text)
|
||||
|
||||
def test_no_anchor_returns_zero(self):
|
||||
src = '{\n "type": "filament"\n}\n'
|
||||
text, n = afi.insert_setting_id(src, "0123456789abcdef")
|
||||
self.assertEqual(n, 0)
|
||||
self.assertEqual(text, src)
|
||||
|
||||
|
||||
class TestKeyLineHelpers(unittest.TestCase):
|
||||
def test_delete_trailing_comma_form(self):
|
||||
text = ('{\n "name": "K",\n "setting_id": "OLD0000000000000",\n'
|
||||
' "instantiation": "false"\n}\n')
|
||||
out, n = afi.delete_key_line(text, "setting_id")
|
||||
self.assertEqual(n, 1)
|
||||
self.assertEqual(json.loads(out), {"name": "K", "instantiation": "false"})
|
||||
self.assertEqual(out, text.replace(
|
||||
' "setting_id": "OLD0000000000000",\n', "", 1))
|
||||
|
||||
def test_delete_last_property_form_consumes_the_preceding_comma(self):
|
||||
text = ('{\n "name": "K",\n "instantiation": "false",\n'
|
||||
' "setting_id": "OLD0000000000000"\n}\n')
|
||||
out, n = afi.delete_key_line(text, "setting_id")
|
||||
self.assertEqual(n, 1)
|
||||
self.assertEqual(json.loads(out), {"name": "K", "instantiation": "false"})
|
||||
self.assertEqual(out, '{\n "name": "K",\n "instantiation": "false"\n}\n')
|
||||
|
||||
def test_delete_requires_the_exact_old_value(self):
|
||||
text = ('{\n "name": "K",\n "setting_id": "OLD0000000000000",\n'
|
||||
' "instantiation": "false"\n}\n')
|
||||
out, n = afi.delete_key_line(text, "setting_id", old_value="OTHER")
|
||||
self.assertEqual((out, n), (text, 0))
|
||||
_out, n = afi.delete_key_line(text, "setting_id",
|
||||
old_value="OLD0000000000000")
|
||||
self.assertEqual(n, 1)
|
||||
|
||||
def test_delete_missing_key_returns_zero(self):
|
||||
text = '{\n "name": "K"\n}\n'
|
||||
self.assertEqual(afi.delete_key_line(text, "setting_id"), (text, 0))
|
||||
|
||||
def test_delete_does_not_confuse_the_two_spellings(self):
|
||||
text = ('{\n "name": "K",\n "settings_id": "TYPO000000000000",\n'
|
||||
' "setting_id": "REAL000000000000",\n'
|
||||
' "instantiation": "true"\n}\n')
|
||||
out, n = afi.delete_key_line(text, "settings_id")
|
||||
self.assertEqual(n, 1)
|
||||
self.assertEqual(json.loads(out)["setting_id"], "REAL000000000000")
|
||||
|
||||
def test_replace_refuses_a_stale_old_value(self):
|
||||
text = '{\n "setting_id": "OLD0000000000000"\n}\n'
|
||||
out, n = afi.replace_key_value(text, "setting_id", "NEW0000000000000",
|
||||
old_value="NOTTHIS000000000")
|
||||
self.assertEqual((out, n), (text, 0))
|
||||
out, n = afi.replace_key_value(text, "setting_id", "NEW0000000000000",
|
||||
old_value="OLD0000000000000")
|
||||
self.assertEqual(n, 1)
|
||||
self.assertEqual(json.loads(out)["setting_id"], "NEW0000000000000")
|
||||
|
||||
def test_insert_key_line_prefers_before_over_after(self):
|
||||
text = ('{\n "name": "K",\n "filament_id": "OFabc123",\n'
|
||||
' "instantiation": "true"\n}\n')
|
||||
out, n = afi.insert_key_line(text, "setting_id", "V",
|
||||
before=("filament_id", "instantiation"),
|
||||
after=("name",))
|
||||
self.assertEqual(n, 1)
|
||||
self.assertLess(out.index('"setting_id"'), out.index('"filament_id"'))
|
||||
self.assertGreater(out.index('"setting_id"'), out.index('"name"'))
|
||||
# The `before` tuple's own order decides, not the order in the file.
|
||||
out, _n = afi.insert_key_line(text, "setting_id", "V",
|
||||
before=("instantiation", "filament_id"))
|
||||
self.assertGreater(out.index('"setting_id"'), out.index('"filament_id"'))
|
||||
|
||||
def test_insert_key_line_falls_back_to_after(self):
|
||||
text = '{\n "name": "K",\n "from": "system"\n}\n'
|
||||
out, n = afi.insert_key_line(text, "setting_id", "V",
|
||||
before=("filament_id",), after=("name",))
|
||||
self.assertEqual(n, 1)
|
||||
self.assertEqual(out, '{\n "name": "K",\n "setting_id": "V",\n'
|
||||
' "from": "system"\n}\n')
|
||||
|
||||
def test_insert_key_line_without_any_anchor(self):
|
||||
text = '{\n "from": "system"\n}\n'
|
||||
self.assertEqual(
|
||||
afi.insert_key_line(text, "setting_id", "V", before=("filament_id",),
|
||||
after=("name",)),
|
||||
(text, 0))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# error paths: reported and counted, never raised
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestErrorPaths(SettingTreeCase):
|
||||
def test_unparsable_profile_is_reported_and_the_run_continues(self):
|
||||
broken = b'{\n "name": "Broken @P1",\n oops\n}\n'
|
||||
self.t.write_raw("VendorA", "filament", "Broken @P1", broken)
|
||||
self.t.write("VendorA", "filament", preset("Good @P1"))
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (1, 1), out)
|
||||
self.assertIn("Broken @P1", out)
|
||||
self.assertEqual(self.t.raw("VendorA", "filament", "Broken @P1"), broken)
|
||||
self.assertEqual(self.t.read("VendorA", "filament", "Good @P1")["setting_id"],
|
||||
afi.generate_preset_setting_id("VendorA", "filament", "Good @P1"))
|
||||
|
||||
def test_non_object_top_level_is_reported(self):
|
||||
raw = b'[\n {"name": "K"}\n]\n'
|
||||
self.t.write_raw("VendorA", "filament", "List @P1", raw)
|
||||
self.t.write("VendorA", "filament", preset("Good @P1"))
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (1, 1), out)
|
||||
self.assertIn("List @P1", out)
|
||||
self.assertEqual(self.t.raw("VendorA", "filament", "List @P1"), raw)
|
||||
|
||||
def test_nameless_instantiated_preset_is_reported(self):
|
||||
nameless = {"type": "filament", "from": "system", "instantiation": "true"}
|
||||
self.t.write("VendorA", "filament", nameless, name="Nameless")
|
||||
self.t.write("VendorA", "filament", preset("Good @P1"))
|
||||
before = self.t.raw("VendorA", "filament", "Nameless")
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (1, 1), out)
|
||||
self.assertIn("Nameless", out)
|
||||
self.assertEqual(self.t.raw("VendorA", "filament", "Nameless"), before)
|
||||
self.assertEqual(self.t.read("VendorA", "filament", "Good @P1")["setting_id"],
|
||||
afi.generate_preset_setting_id("VendorA", "filament", "Good @P1"))
|
||||
|
||||
def test_a_nameless_preset_still_gets_its_misspelled_key_dropped(self):
|
||||
# The nameless-preset error must not abandon an edit already queued for
|
||||
# the same file: leaving "settings_id" behind would keep the validator
|
||||
# red with no way for this tool to clear it.
|
||||
nameless = {"type": "filament", "from": "system", "settings_id": "JUNK123",
|
||||
"instantiation": "true"}
|
||||
self.t.write("VendorA", "filament", nameless, name="Nameless")
|
||||
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((changed, errors), (1, 1), out)
|
||||
self.assertNotIn("settings_id", self.t.read("VendorA", "filament", "Nameless"))
|
||||
self.assertNotIn("setting_id", self.t.read("VendorA", "filament", "Nameless"))
|
||||
self.assertIn('misspelled "settings_id" dropped : 1', out)
|
||||
|
||||
def test_an_unanchorable_file_is_reported_not_raised(self):
|
||||
# One oddly formatted profile must not abort the pass over all the
|
||||
# others, and it must fail the same way in a dry run as in a real one.
|
||||
raw = b'{ "type":"filament", "name":"Flat @P1", "instantiation":"true" }\n'
|
||||
self.t.write_raw("VendorA", "filament", "Flat @P1", raw)
|
||||
self.t.write("VendorA", "filament", preset("Good @P1"))
|
||||
|
||||
dry_changed, dry_errors, dry_out = self.t.run(dry_run=True)
|
||||
changed, errors, out = self.t.run()
|
||||
|
||||
self.assertEqual((dry_changed, dry_errors), (changed, errors), dry_out)
|
||||
self.assertEqual((changed, errors), (1, 1), out)
|
||||
self.assertIn("Flat @P1", out)
|
||||
self.assertEqual(self.t.raw("VendorA", "filament", "Flat @P1"), raw)
|
||||
self.assertEqual(self.t.read("VendorA", "filament", "Good @P1")["setting_id"],
|
||||
afi.generate_preset_setting_id("VendorA", "filament", "Good @P1"))
|
||||
|
||||
def test_nameless_base_preset_is_fine(self):
|
||||
# Only instantiated presets need an identity; a nameless base profile is
|
||||
# simply left alone.
|
||||
nameless = {"type": "filament", "from": "system", "instantiation": "false"}
|
||||
self.t.write("VendorA", "filament", nameless, name="Nameless base")
|
||||
before = self.t.bytes_map()
|
||||
changed, errors, out = self.t.run()
|
||||
self.assertEqual((changed, errors), (0, 0), out)
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the real tree
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@unittest.skipUnless(os.path.isdir(REAL_PROFILES), "resources/profiles not present")
|
||||
class TestRealTree(unittest.TestCase):
|
||||
def test_shipped_tree_needs_no_setting_id_change(self):
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
changed, errors = afi.generate_setting_ids(REAL_PROFILES, dry_run=True)
|
||||
self.assertEqual((changed, errors), (0, 0), buf.getvalue())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCli(SettingTreeCase):
|
||||
def main(self, argv):
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
rc = afi.main(argv)
|
||||
return rc, buf.getvalue()
|
||||
|
||||
def test_setting_id_only_run_touches_no_filament_id(self):
|
||||
# "OFZZZZZZ" is not the mint of the preset's own triple, so the filament
|
||||
# half of --generate has real work waiting on this tree (proven at the
|
||||
# end): leaving the id alone is the narrowing's doing, not an idle tree.
|
||||
self.t.write("VendorA", "filament",
|
||||
preset("A PLA @P1", filament_id="OFZZZZZZ",
|
||||
filament_vendor=["AV"], filament_type=["PLA"]))
|
||||
self.t.write("VendorA", "machine",
|
||||
preset("P1 0.4 nozzle", type_name="machine"))
|
||||
|
||||
rc, out = self.main(["generate-id", "--setting-id",
|
||||
"--profiles", self.t.profiles])
|
||||
|
||||
self.assertEqual(rc, 0, out)
|
||||
filament = self.t.read("VendorA", "filament", "A PLA @P1")
|
||||
self.assertEqual(filament["filament_id"], "OFZZZZZZ")
|
||||
self.assertEqual(filament["setting_id"],
|
||||
afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1"))
|
||||
machine = self.t.read("VendorA", "machine", "P1 0.4 nozzle")
|
||||
self.assertNotIn("filament_id", machine)
|
||||
self.assertEqual(machine["setting_id"],
|
||||
afi.generate_preset_setting_id("VendorA", "machine", "P1 0.4 nozzle"))
|
||||
# ... and the skipped half does re-mint that id when it is allowed to run.
|
||||
changed, errors, out = self.t.run_filament_ids()
|
||||
self.assertEqual((changed, errors), (1, 0), out)
|
||||
self.assertEqual(
|
||||
self.t.read("VendorA", "filament", "A PLA @P1")["filament_id"],
|
||||
afi.generate_filament_id("AV", "PLA", "A PLA"))
|
||||
|
||||
def test_dry_run_setting_id_writes_nothing(self):
|
||||
self.t.write("VendorA", "filament", preset("A PLA @P1"))
|
||||
before = self.t.bytes_map()
|
||||
rc, out = self.main(["generate-id", "--setting-id", "--dry-run",
|
||||
"--profiles", self.t.profiles])
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("1 file(s) would change", out) # there WAS one to write
|
||||
self.assertEqual(self.t.bytes_map(), before)
|
||||
# the real run then writes exactly it
|
||||
rc, out = self.main(["generate-id", "--setting-id",
|
||||
"--profiles", self.t.profiles])
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("1 file(s) changed", out)
|
||||
self.assertEqual(self.t.read("VendorA", "filament", "A PLA @P1")["setting_id"],
|
||||
afi.generate_preset_setting_id("VendorA", "filament",
|
||||
"A PLA @P1"))
|
||||
|
||||
def test_setting_id_without_a_command_is_a_usage_error(self):
|
||||
with contextlib.redirect_stderr(io.StringIO()), \
|
||||
self.assertRaises(SystemExit) as cm:
|
||||
afi.main(["--setting-id", "--profiles", self.t.profiles])
|
||||
self.assertEqual(cm.exception.code, 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate resources/printers/bambu_filament_ids.json: the map from Orca's
|
||||
content-addressed filament_id ("OF" + 6 base62 chars, see orca_profile_tool.py)
|
||||
to Bambu Lab's own AMS/RFID catalog id ("GF..." etc.) for the subset of filament
|
||||
products Bambu ships.
|
||||
|
||||
The map is generated from BambuStudio's OWN shipped BBL bundle, never from
|
||||
Orca's: Orca's BBL bundle is a fork of Bambu's, tuned and extended
|
||||
independently, so it is not the source of truth for Bambu's catalog ids.
|
||||
src/slic3r/Utils/BBLPrinterAgent.cpp loads it at runtime and translates an id
|
||||
only where it crosses to or from a Bambu printer, so the correspondence never
|
||||
has to be hand-maintained. See docs/HLSD/filament_id.md.
|
||||
|
||||
One row per BambuStudio filament PRODUCT: one named spool product = one
|
||||
"@base"-declared filament_id, shared by every per-printer/per-nozzle
|
||||
instantiation of it (BambuStudio follows the same one-product-one-id shape
|
||||
Orca's own filament_id policy does). A row's key is the OF id that product's
|
||||
(filament_vendor, filament_type, filament) triple mints — the id Orca carries
|
||||
for it wherever it ships it, since the id is a function of the triple alone.
|
||||
|
||||
Map format:
|
||||
{
|
||||
"source": "https://github.com/bambulab/BambuStudio",
|
||||
"bambustudio_commit": "66e405477",
|
||||
"generated": "2026-09-04",
|
||||
"filaments": {
|
||||
"OFhuaUQB": {"bambu_id": "GFB00", "vendor": "Bambu Lab", "type": "ABS", "name": "Bambu ABS"}
|
||||
}
|
||||
}
|
||||
|
||||
Run from anywhere: python3 scripts/update_bambu_filament_ids.py
|
||||
(default) shallow-clone BambuStudio's master branch (sparse: just
|
||||
the BBL filament bundle) and regenerate the map
|
||||
--bambustudio-dir DIR
|
||||
read DIR (a BambuStudio resources/profiles checkout)
|
||||
instead of cloning
|
||||
--ref REF clone this BambuStudio ref instead of master
|
||||
--output PATH write here instead of resources/printers/bambu_filament_ids.json
|
||||
|
||||
After writing, an informational drift report is printed: BambuStudio filaments
|
||||
Orca ships nothing with the same identity for, and a count of Orca's own BBL
|
||||
filaments that matched no BambuStudio row. Neither blocks the write; both are
|
||||
for a human to read.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import datetime
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from orca_profile_tool import ( # noqa: E402
|
||||
BAMBU_MAP_PATH,
|
||||
OFL,
|
||||
PROFILES_DIR,
|
||||
analyze_tree,
|
||||
base_name,
|
||||
generate_filament_id,
|
||||
load_vendor_filaments,
|
||||
print_error,
|
||||
print_info,
|
||||
print_success,
|
||||
resolve_filament_id,
|
||||
resolve_triple,
|
||||
)
|
||||
|
||||
BAMBUSTUDIO_REPO = "https://github.com/bambulab/BambuStudio"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Row derivation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def derive_rows(bs_filaments):
|
||||
"""One row per BambuStudio product, keyed by the OF id its triple mints."""
|
||||
by_filament = {} # filament_name -> (bambu_id, triple)
|
||||
for rec in bs_filaments.values():
|
||||
if not rec["instantiation"]:
|
||||
continue
|
||||
bambu_id, _src, _entry = resolve_filament_id(rec["name"], bs_filaments, {})
|
||||
if not bambu_id:
|
||||
continue
|
||||
filament_name = base_name(rec["name"])
|
||||
triple = resolve_triple(rec["name"], bs_filaments, {})
|
||||
prev = by_filament.setdefault(filament_name, (bambu_id, triple))
|
||||
if prev != (bambu_id, triple):
|
||||
raise SystemExit(f"BambuStudio filament {filament_name!r} is not one "
|
||||
f"product: {prev} vs {(bambu_id, triple)}")
|
||||
rows, seen = {}, {}
|
||||
for filament_name, (bambu_id, triple) in sorted(by_filament.items()):
|
||||
if bambu_id in seen:
|
||||
raise SystemExit(f"Bambu id {bambu_id} is shared by "
|
||||
f"{seen[bambu_id]!r} and {filament_name!r}")
|
||||
seen[bambu_id] = filament_name
|
||||
rows[generate_filament_id(*triple)] = {
|
||||
"bambu_id": bambu_id, "vendor": triple[0], "type": triple[1], "name": triple[2]}
|
||||
return rows
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Drift report (informational only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def drift_report(rows, orca_analysis):
|
||||
"""Two triple-identity comparisons between the map just derived and Orca's
|
||||
own BBL bundle (never id-based: the two bundles assign filament_id
|
||||
independently, so only the (vendor, type, name) identity is comparable).
|
||||
|
||||
Returns print-ready lines: one per BambuStudio row triple with no
|
||||
same-triple filament in Orca's BBL bundle (upstream ships it, we ship
|
||||
nothing with that identity there — sometimes a genuinely missing product,
|
||||
sometimes a renamed one), then a count of Orca's own BBL filaments that
|
||||
matched no BambuStudio row (Orca-only products, e.g. a name-drifted
|
||||
duplicate of one already counted in the first list).
|
||||
"""
|
||||
row_triples = {(r["vendor"], r["type"], r["name"]) for r in rows.values()}
|
||||
|
||||
bbl_filaments = orca_analysis["vendors"].get("BBL", {})
|
||||
ofl_filaments = orca_analysis["vendors"].get(OFL, {})
|
||||
orca_filaments = {}
|
||||
for rec in bbl_filaments.values():
|
||||
if not rec["instantiation"]:
|
||||
continue
|
||||
triple = resolve_triple(rec["name"], bbl_filaments, ofl_filaments)
|
||||
orca_filaments.setdefault(base_name(rec["name"]), triple)
|
||||
|
||||
lines = []
|
||||
for triple in sorted(row_triples - set(orca_filaments.values())):
|
||||
lines.append(f'upstream ships {triple[2]!r} ({triple[0]}/{triple[1]}), '
|
||||
"we ship nothing with that identity")
|
||||
orca_only = [name for name, triple in orca_filaments.items()
|
||||
if triple not in row_triples]
|
||||
lines.append(f"Orca BBL filaments with no row: {len(orca_only)} Orca-only product(s)")
|
||||
return lines
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Map IO
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def write_map(path, rows, commit, date):
|
||||
"""Write the map: sorted keys, indent 2, LF, trailing newline."""
|
||||
payload = {
|
||||
"source": BAMBUSTUDIO_REPO,
|
||||
"bambustudio_commit": commit,
|
||||
"generated": date,
|
||||
"filaments": rows,
|
||||
}
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8", newline="\n") as f:
|
||||
json.dump(payload, f, indent=2, ensure_ascii=False, sort_keys=True)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BambuStudio fetch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run(command, cwd=None):
|
||||
"""Run a command, streaming its output; raise SystemExit on failure."""
|
||||
print("+ " + " ".join(command))
|
||||
try:
|
||||
subprocess.run(command, cwd=cwd, check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise SystemExit(f"command failed ({e.returncode}): {' '.join(command)}") from e
|
||||
|
||||
|
||||
def run_out(command, cwd=None):
|
||||
"""Run a command and return its stripped stdout; raise SystemExit on failure."""
|
||||
try:
|
||||
result = subprocess.run(command, cwd=cwd, check=True, capture_output=True, text=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise SystemExit(
|
||||
f"command failed ({e.returncode}): {' '.join(command)}\n{e.stderr}") from e
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def fetch_bambustudio(ref, workdir):
|
||||
run(["git", "clone", "--depth=1", "--filter=blob:none", "--sparse", "--branch", ref,
|
||||
BAMBUSTUDIO_REPO + ".git", workdir])
|
||||
run(["git", "-C", workdir, "sparse-checkout", "set", "--no-cone",
|
||||
"resources/profiles/BBL.json", "resources/profiles/BBL/filament"])
|
||||
return os.path.join(workdir, "resources", "profiles"), run_out(["git", "-C", workdir, "rev-parse", "--short", "HEAD"])
|
||||
|
||||
|
||||
def local_dir_commit(dir_path):
|
||||
"""git rev-parse --short HEAD of dir_path, or "local" when it is not a repo."""
|
||||
result = subprocess.run(["git", "-C", dir_path, "rev-parse", "--short", "HEAD"],
|
||||
capture_output=True, text=True)
|
||||
return result.stdout.strip() if result.returncode == 0 else "local"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Regenerate resources/printers/bambu_filament_ids.json from "
|
||||
"BambuStudio's shipped BBL bundle.")
|
||||
source = parser.add_mutually_exclusive_group()
|
||||
source.add_argument("--bambustudio-dir", metavar="DIR",
|
||||
help="a BambuStudio resources/profiles directory to read "
|
||||
"instead of cloning")
|
||||
source.add_argument("--ref", default="master",
|
||||
help='BambuStudio git ref to shallow-clone when '
|
||||
'--bambustudio-dir is not given (default: "master")')
|
||||
parser.add_argument("--output", default=BAMBU_MAP_PATH,
|
||||
help="output path (default: resources/printers/bambu_filament_ids.json)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
workdir = None
|
||||
if args.bambustudio_dir:
|
||||
profiles_dir = args.bambustudio_dir
|
||||
commit = local_dir_commit(profiles_dir)
|
||||
else:
|
||||
workdir = tempfile.mkdtemp(prefix="bambustudio_")
|
||||
profiles_dir, commit = fetch_bambustudio(args.ref, workdir)
|
||||
|
||||
try:
|
||||
bs_filaments, errors = load_vendor_filaments(profiles_dir, "BBL")
|
||||
if errors:
|
||||
for e in errors:
|
||||
print_error(e)
|
||||
raise SystemExit("unreadable BambuStudio filament profile(s)")
|
||||
|
||||
rows = derive_rows(bs_filaments)
|
||||
write_map(args.output, rows, commit, datetime.date.today().isoformat())
|
||||
print_success(f"wrote {len(rows)} row(s) to {args.output} (BambuStudio @ {commit})")
|
||||
|
||||
for line in drift_report(rows, analyze_tree(PROFILES_DIR)):
|
||||
print_info(line)
|
||||
finally:
|
||||
if workdir:
|
||||
shutil.rmtree(workdir, ignore_errors=True)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user