diff --git a/tests/daily_verb_cron.sh b/tests/daily_verb_cron.sh index 1deab38..f735d1c 100644 --- a/tests/daily_verb_cron.sh +++ b/tests/daily_verb_cron.sh @@ -1,43 +1,38 @@ -#!/bin/bash -# Daily fusion-bridge verb test (see dev-skills/fusion-verb-tests). -# Cron-safe: runs the daily tier against AdomLapper, keeps a dated log, and on a -# red run files ONE issue on adom/fusion-bridge with the scorecard so a failure -# is a filed finding by the time anyone looks, not a log nobody reads. -# the wiki CLI that knows `issue create`: two lineages live on this box (/usr/local/bin is a -# June 1.12.x without `issue`; ~/.local/bin is the current one), so probe instead of guessing -WIKI="" -for w in "$HOME/.local/bin/adom-wiki" /usr/local/bin/adom-wiki "$(command -v adom-wiki 2>/dev/null)"; do - [ -n "$w" ] && [ -x "$w" ] && "$w" issue --help >/dev/null 2>&1 && { WIKI="$w"; break; } -done -[ -n "$WIKI" ] || WIKI="$(command -v adom-wiki)" -set -u -REPO=/home/adom/project/adom-desktop-fusion-bridge +#!/usr/bin/env bash +# Explicit-target, read-only daily discovery. Issue filing is opt-in. +set -euo pipefail +: "${FUSION_TEST_TARGET:?Set FUSION_TEST_TARGET to the intended Bridge machine}" +REPO=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) LOGDIR="$REPO/tests/reports" -STAMP=$(date +%Y-%m-%d) -LOG="$LOGDIR/daily-cron-$STAMP.log" -cd "$REPO" || exit 1 -mkdir -p "$LOGDIR" - -python3 tests/run_verb_tests.py --target "${FUSION_TEST_TARGET:-ConfRoomROG}" --tier daily >"$LOG" 2>&1 -RC=$? -if [ $RC -eq 0 ]; then - echo "GREEN $STAMP" >>"$LOGDIR/daily-cron-history.txt" - exit 0 +mkdir -p -- "$LOGDIR" +LOG=$(mktemp "$LOGDIR/daily-cron-$(date -u +%Y%m%dT%H%M%SZ)-XXXXXX.log") +cd -- "$REPO" +# Keep scheduled runs in this checkout from overlapping. No test or host work +# occurs when another run owns the lock. +exec 9>"$LOGDIR/daily-cron.lock" +if ! flock -n 9; then + echo 'SKIP: another daily run owns this checkout' >"$LOG" + exit 75 fi - -echo "RED $STAMP (exit $RC)" >>"$LOGDIR/daily-cron-history.txt" +RC=0 +python3 tests/run_verb_tests.py --target "$FUSION_TEST_TARGET" --tier daily >"$LOG" 2>&1 || RC=$? +printf '%s exit=%s target=%s log=%s\n' "$(date -u +%FT%TZ)" "$RC" "$FUSION_TEST_TARGET" "$LOG" >>"$LOGDIR/daily-cron-history.txt" +if [ "$RC" -eq 0 ] || [ "${FUSION_TEST_FILE_ISSUE:-0}" != 1 ]; then + exit "$RC" +fi +# Setting FUSION_TEST_FILE_ISSUE=1 explicitly enables external issue reporting. +# Default scheduled runs only write local evidence. +WIKI=$(command -v adom-wiki) BODY=$(mktemp) +trap 'rm -f -- "$BODY"' EXIT { - echo "Automated daily verb run went red ($STAMP, exit $RC). Scorecard tail:" - echo - echo '```' - tail -25 "$LOG" - echo '```' - echo - echo "Full log: tests/reports/daily-cron-$STAMP.log on the dev container. Versions at run time are in the readiness line of the log. Triage per dev-skills/fusion-verb-tests: re-run the failing verbs with --only first, a startup race or relay flake re-passes; a repeat failure is the real finding." + printf 'Daily read-only Fusion discovery failed (target %s, exit %s).\n\n' "$FUSION_TEST_TARGET" "$RC" + printf '```\n' + tail -25 -- "$LOG" + printf '```\n\nFull local log: %s\n' "$LOG" + printf 'Inspect raw responses and infrastructure errors before rerunning. A skipped case is not a passed workflow.\n' } >"$BODY" "$WIKI" issue create adom/fusion-bridge \ - --title "Daily verb run RED $STAMP: $(grep -c FAIL: "$LOG" 2>/dev/null || echo '?') failing verb(s)" \ - --body-file "$BODY" >>"$LOG" 2>&1 -rm -f "$BODY" -exit $RC + --title "Fusion daily discovery failed: $FUSION_TEST_TARGET" \ + --body-file "$BODY" >>"$LOG" 2>&1 || true +exit "$RC" diff --git a/tests/fusion_harness.py b/tests/fusion_harness.py new file mode 100644 index 0000000..8b898af --- /dev/null +++ b/tests/fusion_harness.py @@ -0,0 +1,472 @@ +"""Evidence, single-dispatch transport and owned-resource lifecycle for live tests. + +This module never starts Fusion, chooses a document by display name, or retries a +mutation. Campaigns supply their own exact-identity native cleanup adapters. +""" +from dataclasses import dataclass, field +import datetime +import hashlib +import json +from pathlib import Path, PureWindowsPath +import re +import subprocess +import time +import uuid + +ENVELOPES = ("output", "data", "result", "response", "structuredContent", "content") +# Transport metadata does not make an output summary into a native result. A +# native reply has its own data/result/content, or explicit success plus domain +# fields (for example bridges or addinResponding). Keep those replies intact. +TRANSPORT_FIELDS = set(ENVELOPES) | { + "success", "ok", "status", "isError", "error", "errorCode", "cause", "message", + "durationMs", "timeoutSeconds", "schemaVersion", "requestId", "command", "verb", + "target", "caller", "runId", "_hint", "_timeoutHint", "_skillsHint", +} + + +def native_payload(value): + return (any(key in value for key in ("data", "result", "content")) + or isinstance(value.get("success"), bool) and bool(set(value) - TRANSPORT_FIELDS)) + + +def transport_wrapper(value): + return any(key in value for key in ("output", "response", "structuredContent")) and not native_payload(value) +INFRASTRUCTURE = {"bridge_unavailable", "target_not_found", "bridge_restarting", + "main_thread_busy", "fusion_addin_busy", "addin_command_timeout", + "addin_main_thread_blocked", "fusion_not_running", "connection_error", "timeout", "runner_error"} + + +def infrastructure_failure(failure): + return failure.get("code") in INFRASTRUCTURE or any(word in str(failure.get("error", "")).lower() for word in + ("bridge disconnected", "connection refused", "connection reset", "bridge unavailable", "target_not_found", + "addin_command_timeout", "addin_main_thread_blocked", "main_thread_busy", "bridge_restarting", "timed out")) + + +def unresolved_operation(observation): + """Lifecycle evidence is not a terminal refusal, even with success:false.""" + return any(value.get("outcome") in ("pending", "unknown") + or value.get("reconciliationRequired") is True + or value.get("errorCode") in ("operation_in_progress", "operation_reconciliation_required") + for _, value in observation.envelopes) + + +def mechanical_request_digest(request): + """Independent digest of the public user payload, excluding relay metadata.""" + payload = {key:value for key,value in request.items() if key not in ("caller", "_callerBudgetMs")} + return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")).hexdigest() + + +def run_id(): + return datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + uuid.uuid4().hex + + +@dataclass +class Observation: + payload: dict + failures: list = field(default_factory=list) + positive: bool = False + envelopes: list = field(default_factory=list) + + +def observe(raw): + """Preserve the semantic envelope; errors anywhere outrank outer success. + + Only explicit status markers are errors (isValid:false and ordinary domain + booleans are data). JSON in documented envelope/text slots is decoded without + overwriting an enclosing status, error or code. + """ + failures, envelopes = [], [] + positive = False + visited = 0 + + def walk(value, path="$", depth=0, envelope=True): + nonlocal positive, visited + # Domain objects (catalog schemas, geometry, file metadata) are not + # response envelopes. Their keys may themselves document error fields; + # traversing them adds no semantic check and can exhaust the budget on + # a legitimate duplicated describe response before reaching its status. + if not envelope: + return + visited += 1 + if visited > 20000 or depth > 40: + raise ValueError("response inspection budget exceeded") + if isinstance(value, str) and envelope and value.lstrip().startswith(("{", "[")): + value = json.loads(value) + if isinstance(value, dict): + if envelope: + envelopes.append((path, value)) + error = value.get("error") + failed = value.get("success") is False or value.get("ok") is False or value.get("isError") is True + status = value.get("status") + failed = failed or isinstance(status, str) and status.lower() in {"error", "failed", "failure", "cancelled", "canceled", "timeout"} + failed = failed or error not in (None, "", False, {}, []) or bool(value.get("errorCode")) + if envelope and failed: + failures.append({"path": path, "error": error, "code": value.get("errorCode"), + "cause": value.get("cause"), "status": status}) + positive = positive or envelope and not transport_wrapper(value) and (value.get("success") is True or value.get("ok") is True or status == "ok") + for key, child in value.items(): + if isinstance(child, (dict, list)) or key in ENVELOPES or key == "text": + script_stdout = key == "output" and isinstance(child, str) and isinstance(value.get("success"), bool) and isinstance(value.get("data"), dict) and "result" in value["data"] + summary_text = key == "output" and isinstance(child, str) and native_payload(value) and not child.lstrip().startswith(("{", "[")) + if envelope and key == "output" and transport_wrapper(value): + decoded = json.loads(child) if isinstance(child, str) else child + if not isinstance(decoded, dict): raise ValueError("Transport output must contain a JSON object") + walk(child, path + "." + key, depth + 1, not (script_stdout or summary_text) and (envelope and key in ENVELOPES or key == "text" and ".content[" in path)) + elif isinstance(value, list): + for index, child in enumerate(value): + walk(child, path + "[%d]" % index, depth + 1, envelope) + + try: + walk(raw) + except (ValueError, TypeError, RecursionError) as exc: + failures.append({"path": "$", "code": "malformed_response", "error": str(exc)}) + payload = raw if isinstance(raw, dict) else {} + # Output/response are transport wrappers; data and native result stay nested so callers + # retain native success/error beside it. Never merge dictionaries together. + for _ in range(40): + if not isinstance(payload, dict): break + if native_payload(payload): break + child = next((payload[k] for k in ("output", "response", "structuredContent") + if isinstance(payload.get(k), dict) or isinstance(payload.get(k), str) and payload[k].lstrip().startswith("{")), None) + if isinstance(child, str): + try: child = json.loads(child) + except ValueError: break + if not isinstance(child, dict): + break + payload = child + if not isinstance(raw, dict): + failures.append({"path": "$", "code": "malformed_response", "error": "Expected an object response."}) + return Observation(payload, failures, positive, envelopes) + + +def data_payload(observation): + value = observation.payload + while isinstance(value.get("data"), dict): + value = value["data"] + return value + + +def evaluate(raw, spec): + observation = observe(raw) + data = data_payload(observation) + errors = [] + if spec.get("pred") not in (None, "count_gt_0", "must_refuse", "verbs_match_plan", "ok_or_budget"): + return ["unknown expectation predicate: " + str(spec["pred"])] + failures = observation.failures + infrastructure = [f for f in failures if infrastructure_failure(f)] + if infrastructure or isinstance(raw, dict) and raw.get("_testTimeout"): + return ["infrastructure failure; this does not prove an expected refusal"] + expected = spec.get("errorCode") + refusal = spec.get("refusal", {}) + expected = refusal.get("code", expected) + if spec.get("pred") == "must_refuse" and not expected: + return ["negative case lacks an explicit refusal code/cause contract"] + alternative = spec.get("okOrCode") + if expected or alternative and failures: + code = expected or alternative + matching = [f for f in failures if f.get("code") == code] + if not matching: + errors.append("expected refusal code %s; observed %s" % (code, [f.get("code") for f in failures])) + if refusal.get("cause") is not None and not any(f.get("cause") == refusal["cause"] for f in matching): + errors.append("expected refusal cause %s" % refusal["cause"]) + # Wrapping success:false without a code is normal. A competing coded + # failure means the intended guard was not the sole observed outcome. + if any(f.get("code") not in (None, code) for f in failures): + errors.append("unexpected additional failure code") + else: + if failures: + errors.append("semantic failure: " + json.dumps(failures, default=str)) + if not observation.positive: + errors.append("no explicit successful response") + for name in spec.get("fields", []): + if name not in data and name not in observation.payload: + errors.append("missing field %r" % name) + if spec.get("pred") == "count_gt_0" and not (isinstance(data.get("count"), (int, float)) and data["count"] > 0 or isinstance(data.get("results"), list) and data["results"]): + errors.append("expected >0 results") + if spec.get("pred") == "ok_or_budget": + errors.append("BOM success and budget refusal need separate explicit fixture cases") + if spec.get("pred") == "verbs_match_plan" and not (isinstance(data.get("verbs"), list) and data["verbs"]): + errors.append("runtime catalog must be a nonempty list") + return errors + + +@dataclass +class CallResult: + raw: dict + observation: Observation + attempts: list + timed_out: bool + elapsed: float + mutating: bool = False + + @property + def ambiguous(self): + first = self.attempts[0] if self.attempts else {} + try: request = json.loads(first.get("requestJson", "{}")) + except (ValueError, TypeError): request = {} + excluded = set() + for path, value in self.observation.envelopes: + durability = value.get("durability") + if (first.get("verb") == "fusion_mechanical" and isinstance(request, dict) + and isinstance(durability, dict) and durability.get("terminalRecorded") is True + and durability.get("phase") == "not_executed" + and value.get("success") is False and value.get("nativeExecutionStarted") is False + and request.get("sessionId") and request.get("operationId") + and value.get("sessionId") == request["sessionId"] + and value.get("operationId") == request["operationId"] + and durability.get("recordedSessionId") == request["sessionId"]): + # A persisted skip proves no native execution for this exact + # request. Its timeout wording is not an unknown outcome. + excluded.add(path) + uncertain = any((infrastructure_failure(f) and f["path"] not in excluded) + or f.get("code") == "malformed_response" for f in self.observation.failures) + absent = not self.observation.positive and not self.observation.failures + return self.mutating and (self.timed_out or uncertain or absent or unresolved_operation(self.observation)) + + +class BridgeClient: + def __init__(self, target, run_id, ai_thread="Fusion harness", evidence_dir=None, runner=subprocess.run): + self.target, self.run_id, self.ai_thread = target, run_id, ai_thread + self.evidence_dir = Path(evidence_dir) if evidence_dir else None + if self.evidence_dir: self.evidence_dir.mkdir(parents=True, exist_ok=True) + self.runner, self.calls = runner, [] + + def call(self, verb, args=None, timeout=90, mutating=False): + started = time.time() + request_json = json.dumps(args or {}, allow_nan=False) + attempt = {"target": self.target, "verb": verb, "args": json.loads(request_json), "requestJson": request_json, "runId": self.run_id, + "caller": self.ai_thread, "startedAt": started, "mutating": mutating} + timed_out = False + try: + process = self.runner(["adom-bridge", "--target", self.target, "--ai-thread", self.ai_thread, + verb, request_json], capture_output=True, text=True, timeout=timeout) + attempt.update(stdout=process.stdout, stderr=process.stderr, returncode=process.returncode) + output = process.stdout.strip() + try: raw = json.loads(output) + except ValueError: + raw = {"success": False, "errorCode": "runner_error", "error": "CLI did not return a JSON response"} + if not isinstance(raw, dict): + raw = {"success": False, "errorCode": "runner_error", "error": "CLI returned a non-object response", "response": raw} + if process.returncode: + raw = {"success": False, "errorCode": "runner_error", "error": "CLI exited with code %s" % process.returncode, "output": raw} + except subprocess.TimeoutExpired as exc: + timed_out = True + attempt.update(stdout=exc.stdout.decode(errors="replace") if isinstance(exc.stdout, bytes) else exc.stdout, + stderr=exc.stderr.decode(errors="replace") if isinstance(exc.stderr, bytes) else exc.stderr) + raw = {"success": False, "errorCode": "timeout", "error": "CLI timeout; operation outcome is unknown", "_testTimeout": True} + except Exception as exc: + raw = {"success": False, "errorCode": "runner_error", "error": str(exc)} + attempt.update(finishedAt=time.time(), raw=json.loads(json.dumps(raw))) + result = CallResult(raw, observe(raw), [attempt], timed_out, time.time()-started, mutating) + self.calls.append(result) + if self.evidence_dir: + path = self.evidence_dir / ("%04d-%s.json" % (len(self.calls), re.sub(r"[^A-Za-z0-9_-]", "_", verb))) + path.write_text(json.dumps(attempt, indent=2, default=str)) + return result + + def reconcile_mechanical(self, original, session_id, operation_id, polls=3, interval=1): + """Status-only reconciliation; an unknown/running entry never means retry.""" + evidence = [] + first = original.attempts[0] if original.attempts else {} + try: args = json.loads(first.get("requestJson", "{}")) + except ValueError: args = {} + if not isinstance(args, dict) or first.get("verb") != "fusion_mechanical" or args.get("sessionId") != session_id or args.get("operationId") != operation_id or not original.mutating: + return {"state": "ambiguous", "original": original.attempts, "reconciliation": [], + "reason": "Reconciliation IDs must match the original typed mutation."} + for index in range(polls): + result = self.call("fusion_mech_operation_status", {"sessionId": session_id, "operationId": operation_id}) + evidence.append(result.attempts[0]) + data = data_payload(result.observation) + unrelated_failure = any(".result" not in f["path"] for f in result.observation.failures) + if not unrelated_failure and data.get("success") is True and data.get("sessionId") == session_id and data.get("operationId") == operation_id and data.get("state") in ("succeeded", "failed") and isinstance(data.get("result"), dict): + stored = observe(data["result"]) + terminal = data["result"] + durability = terminal.get("durability", {}) + identity_matches = (terminal.get("sessionId") == session_id + and terminal.get("operationId") == operation_id + and isinstance(durability, dict) + and durability.get("recordedSessionId", session_id) == session_id) + history = data.get("history", {}) + digest_matches = (isinstance(history, dict) and + ("requestDigest" not in history or history["requestDigest"] == mechanical_request_digest(args))) + phases = ("succeeded",) if data["state"] == "succeeded" else ("failed", "not_executed") + durability_matches = (isinstance(durability, dict) + and ("terminalRecorded" not in durability or durability["terminalRecorded"] is True) + and ("phase" not in data or data["phase"] in phases) + and ("phase" not in durability or durability["phase"] in phases) + and ("phase" not in data or "phase" not in durability or data["phase"] == durability["phase"])) + phase = data.get("phase", durability.get("phase") if isinstance(durability, dict) else None) + execution = (data.get("nativeExecutionStarted"), terminal.get("nativeExecutionStarted")) + execution_matches = all(value is None or isinstance(value, bool) for value in execution) + if phase == "not_executed": + execution_matches = execution_matches and terminal.get("nativeExecutionStarted") is False and data.get("nativeExecutionStarted") is not True + elif phase in ("succeeded", "failed"): + execution_matches = execution_matches and all(value is not False for value in execution) + coherent = identity_matches and digest_matches and durability_matches and execution_matches and not unresolved_operation(result.observation) and ( + data["state"] == "succeeded" and data["result"].get("success") is True and not stored.failures + or data["state"] == "failed" and data["result"].get("success") is False and bool(stored.failures)) + if coherent: + return {"state": data["state"], "result": data["result"], "original": original.attempts, "reconciliation": evidence} + if index + 1 < polls: time.sleep(interval) + return {"state": "ambiguous", "original": original.attempts, "reconciliation": evidence, + "reason": "No terminal ledger result; mutation was not replayed."} + + +class OwnedRun: + """Persist ownership before effects; cleanup always requires exact adapters. + + Document IDs are live session identities, never names or creationId alone. + A native checkpoint must be verified before the document closer is invoked. + verify_owner returns the exact documentId and ownerRunId observed in Fusion. + Cleanup callbacks return {success:true}; errors stay in the manifest/report. + """ + def __init__(self, manifest_path, target, run_id=None, host_scratch=None): + self.path = Path(manifest_path) + if self.path.exists(): raise ValueError("Ownership manifest already exists; never overwrite a prior run") + self.run_id = run_id or globals()["run_id"]() + if not re.fullmatch(r"[A-Za-z0-9_-]{12,100}", self.run_id): raise ValueError("Invalid run ID") + self.manifest = {"runId": self.run_id, "target": target, "state": "running", "documents": {}, "files": {}, "preferences": {}, "customCleanup": [], "cleanup": []} + self.callbacks = [] + self.documents = {} + self.file_adapters = {} + self.preference_adapters = {} + self.set_host_scratch(host_scratch or "C:/tmp/fusion-verb-tests/" + self.run_id) + + def persist(self): + self.path.parent.mkdir(parents=True, exist_ok=True) + temporary = self.path.with_suffix(self.path.suffix + ".tmp") + temporary.write_text(json.dumps(self.manifest, indent=2, default=str)) + temporary.replace(self.path) + + def set_host_scratch(self, path): + root = PureWindowsPath(path) + if not root.is_absolute() or ".." in root.parts or root.name != self.run_id: raise ValueError("Scratch root must be an absolute directory ending in the unique run ID") + if self.manifest["files"]: raise ValueError("Scratch root cannot change after file registration") + self.manifest["hostScratch"] = root.as_posix() + self.persist() + + @classmethod + def resume(cls, manifest_path, target, run_id=None): + manifest = json.loads(Path(manifest_path).read_text()) + rid = manifest.get("runId", "");root = PureWindowsPath(manifest.get("hostScratch", "")) + if manifest.get("target") != target or not re.fullmatch(r"[A-Za-z0-9_-]{12,100}", rid) or run_id is not None and run_id != rid: raise ValueError("Manifest target/run identity mismatch") + if not root.is_absolute() or ".." in root.parts or root.name != rid: raise ValueError("Invalid saved scratch ownership") + for name in manifest.get("files", {}): + path = PureWindowsPath(name) + if ".." in path.parts or root not in path.parents: raise ValueError("Saved file escapes owned scratch") + result = cls.__new__(cls) + result.path, result.run_id, result.manifest = Path(manifest_path), rid, manifest + result.callbacks, result.documents, result.file_adapters, result.preference_adapters = [], {}, {}, {} + return result + + def register_document(self, document_id, role, metadata=None, cleanup=None, verify_owner=None): + if not isinstance(document_id, str) or not document_id or document_id in self.manifest["documents"]: raise ValueError("A unique exact runtime document ID is required") + self.manifest["documents"][document_id] = {"role": role, "metadata": metadata or {}, "checkpoint": None} + self.documents[document_id] = (cleanup, verify_owner) + self.persist() + + def bind_document_cleanup(self, document_id, cleanup, verify_owner): + if document_id not in self.manifest["documents"]: raise ValueError("Document is not registered") + self.documents[document_id] = (cleanup, verify_owner) + + def register_file(self, path, sha256=None, cleanup=None, verify_owner=None, retain=False): + file = PureWindowsPath(path);root = PureWindowsPath(self.manifest["hostScratch"]) + if not file.is_absolute() or ".." in file.parts or file == root or root not in file.parents: raise ValueError("File must be below this run's scratch root") + if sha256 is not None and not re.fullmatch(r"[0-9a-fA-F]{64}", sha256): raise ValueError("Invalid file hash") + self.manifest["files"][file.as_posix()] = {"sha256": sha256, "retain": retain} + self.file_adapters[file.as_posix()] = (cleanup, verify_owner) + self.persist() + + def mark_checkpoint(self, document_id, path, sha256, verified=False): + if document_id not in self.manifest["documents"]: raise ValueError("Document is not owned") + self.register_file(path, sha256, retain=True) + self.manifest["documents"][document_id]["checkpoint"] = {"path": path, "sha256": sha256, "verified": verified is True} + self.persist() + + def add_cleanup(self, label, callback): + if label not in self.manifest["customCleanup"]: self.manifest["customCleanup"].append(label) + self.callbacks = [(name, action) for name, action in self.callbacks if name != label] + [(label, callback)] + self.persist() + + def preserve_preference(self, name, read, write): + original = read() + if name in self.manifest["preferences"]: raise ValueError("Preference already tracked; rebind its adapter on resume") + self.manifest["preferences"][name] = {"original": original, "restoreRequired": False} + self.preference_adapters[name] = (read, write) + self.persist() + def set_value(value): + self.manifest["preferences"][name].update(requested=value, restoreRequired=True) + self.persist() + write(value) + return set_value + + def bind_preference_restore(self, name, read, write): + if name not in self.manifest["preferences"]: raise ValueError("Preference is not tracked") + self.preference_adapters[name] = (read, write) + + def cleanup(self): + entries = [] + for document_id, record in self.manifest["documents"].items(): + close, verify = self.documents.get(document_id, (None, None)) + def finish(document_id=document_id, record=record, close=close, verify=verify): + if not (record.get("checkpoint") or {}).get("verified"): + raise ValueError("No verified native checkpoint; owned document retained") + if not close or not verify: raise ValueError("Exact-identity cleanup adapter unavailable; document retained") + owner = verify(document_id) + if observe(owner).failures: raise ValueError("Document ownership verification failed; refusing close") + if owner.get("documentId") != document_id or owner.get("ownerRunId") != self.run_id: + raise ValueError("Document ownership changed; refusing close") + result = close(document_id) + data = data_payload(observe(result)) + if data.get("closedDocumentId") != document_id or data.get("closed") is not True: + raise ValueError("Closer did not verify the exact document was closed") + return result + entries.append(("close owned document " + document_id, finish)) + for path, record in self.manifest["files"].items(): + if record.get("retain"): continue + remove, verify = self.file_adapters.get(path, (None, None)) + def finish_file(path=path, record=record, remove=remove, verify=verify): + if not remove or not verify: raise ValueError("Owned file cleanup adapter unavailable; file retained") + owner = verify(path) + if observe(owner).failures: raise ValueError("File ownership verification failed; refusing deletion") + if owner.get("ownerRunId") != self.run_id or PureWindowsPath(owner.get("path", "")) != PureWindowsPath(path) or owner.get("isSymlink") is not False: + raise ValueError("File ownership changed; refusing deletion") + if record.get("sha256") and owner.get("sha256") != record["sha256"]: raise ValueError("Owned file changed; refusing deletion") + return remove(path) + entries.append(("remove owned file " + path, finish_file)) + for name, record in self.manifest["preferences"].items(): + if not record.get("restoreRequired"): continue + def restore(name=name, record=record): + if name not in self.preference_adapters: raise ValueError("Preference restoration adapter unavailable; recovery remains unresolved") + read, write = self.preference_adapters[name] + current = read() + if current != record["original"]: + if current != record.get("requested"): raise ValueError("Preference changed concurrently; refusing overwrite") + write(record["original"]) + restored = read() == record["original"] + if restored: + record["restoreRequired"] = False + self.persist() + return {"success": restored, "preference": name} + entries.append(("restore preference " + name, restore)) + custom = dict(self.callbacks) + for label in reversed(self.manifest["customCleanup"]): + entries.append((label, custom.get(label, lambda: {"success": False, "error": "Cleanup adapter unavailable after interruption"}))) + for label, callback in entries: + if any(row["label"] == label and row["status"] == "PASS" for row in self.manifest["cleanup"]): continue + try: + raw = callback() + errors = evaluate(raw, {"ok": True}) + row = {"label": label, "status": "FAIL" if errors else "PASS", "errors": errors, "raw": raw} + except BaseException as exc: + row = {"label": label, "status": "FAIL", "error": str(exc)} + self.manifest["cleanup"].append(row) + self.persist() + latest = {row["label"]: row for row in self.manifest["cleanup"]} + self.manifest["cleanupLatest"] = latest + self.manifest["state"] = "cleanup_failed" if any(row["status"] != "PASS" for row in latest.values()) else "complete" + self.persist() + return self.manifest["cleanup"] + + def __enter__(self): return self + def __exit__(self, *_): self.cleanup() diff --git a/tests/job_acceptance/busy/CONTRACT.md b/tests/job_acceptance/busy/CONTRACT.md new file mode 100644 index 0000000..da3630b --- /dev/null +++ b/tests/job_acceptance/busy/CONTRACT.md @@ -0,0 +1,13 @@ +# CP8 bounded native busy-status observation + +Run only after ordinary durable acceptance passes and all owned CAD scratch documents are closed. Use the exact current session/native original object from that run, and one already terminal depth-edit record. Root owns the host throughout. This contract creates no CAD change. + +The native adapter extends the existing exact-run `inventory` program. Before starting, require all run documents closed, no provisional imports or retained failures, one exact original pristine active document, unchanged baseline flags, expected current process/session, and idle selection command. Write a unique entered JSON marker under the run scratch with exclusive creation and fsync. Include nonce, native PID/thread, wall and monotonic time, original creation ID and session. Then `time.sleep(8)` once, with no event pumping, compute, transaction, activation or CAD setter. After waking, verify original state and write an exclusive exited marker with the same identities and timestamps. Normal completion is required. + +The root starts the one public modeling-script call asynchronously and does not cancel or retry it. A separate host read waits at most five seconds for the entered marker, verifies nonce/session/PID, and records a host-clock timestamp. Only then does root query the terminal operation through the actual public bridge CLI. After that response completes, a second host read records current UTC time; that is an upper bound on the earlier response completion. The hold then finishes normally, and root reads both marker bytes/hashes and the public script response. + +Acceptance requires entered <= observedEntered <= postStatusUpperBound < exited on the same host clock; monotonically increasing native entry/exit spanning approximately eight seconds; matching identities/nonce; unmodified original document; same immutable terminal history/result before/during/after; stable native process and session. Container timestamps are supplementary and cannot establish the interval. Status elapsed below eight seconds or busy:true alone is insufficient. + +The status proves ledger readability during occupied main-thread dispatch while Python's GIL is released. It does not demonstrate live CAD progress, cancellation, interrupted-operation recovery, or survival of a native kernel/GIL wedge. Any marker/identity/timing failure preserves the attempted observation as FAIL/INCONCLUSIVE without repeating the hold automatically. + +The implementation also requires a common Windows performance-counter interval: native QueryPerformanceCounter/QueryPerformanceFrequency and host PowerShell Stopwatch.GetTimestamp/Frequency with IsHighResolution=true. Frequencies must agree, and ordered observations must be separated by more than one tick. UTC alone cannot prove the interval because a post-exit clock adjustment can move a later status response backward. Original wall-clock and native duration checks remain supplemental requirements. Exact latest successful close receipts govern local cleanup prerequisites; historical registration metadata.closed is not current closure evidence. diff --git a/tests/job_acceptance/busy/README.md b/tests/job_acceptance/busy/README.md new file mode 100644 index 0000000..93fdb91 --- /dev/null +++ b/tests/job_acceptance/busy/README.md @@ -0,0 +1,31 @@ +# Finite busy-status runner + +`busy_status.py` defaults to `plan`, which makes no host calls and imports no bridge harness. `run` is reserved for the root operator after an ordinary durable campaign has both native PASS and cleanup PASS, with exact successful close receipts for all owned documents. Historical `metadata.closed` snapshots are not used as close proof; the native registry is rechecked before the hold. The operator supplies the current Fusion PID; the runner verifies that PID, process creation time, executable, session, exact original object and pristine model before the hold. A failed or retained ordinary run is rejected. + +```bash +python3 busy_status.py plan +python3 -m unittest discover -s . -p 'test_busy_status.py' -v +``` + +The explicit native invocation, only after the gate, is: + +```bash +python3 busy_status.py run \ + --ordinary-evidence /absolute/path/to/passing-ordinary-run \ + --target TARGET --host-name EXACT_WINDOWS_HOST --fusion-pid PID \ + --evidence /absolute/path/to/new-busy-evidence +``` + +The runner verifies and imports the ordinary run's four hash-bound harness copies. It validates the existing depth-edit request and acknowledged result against public durable status. The inherited inventory adapter requires the original native run registry; a main-thread prefix precedes its execution. No ownership registration, activation, compute, transaction, CAD edit, restart, preference change, image capture, Shotlog upload or rendering is performed. + +One public modeling-script call writes a complete entered marker, sleeps for eight seconds without event pumping, rechecks the original, and writes an exited marker. Each marker is written to an exclusively created writable staging file, flushed and fsynced, then exclusively published with a hard link. Only the owned staging link is removed. Final markers and any failure residue remain under a unique `busy-` child of that run's exact scratch root. No partial marker is published. Unsupported publication or flush behavior fails the observation. + +A separate PowerShell read waits at most five seconds for entered. It reads the bytes and host clock. The public status call follows; another separate PowerShell read supplies an upper bound on its completion. Final host reads preserve marker bytes, hashes and parsed values. Native markers read Windows `QueryPerformanceCounter` and `QueryPerformanceFrequency`; PowerShell records `Stopwatch.GetTimestamp`, its frequency and `IsHighResolution`. Passing requires matching counter frequencies and exact integer ticks ordered across the same host, with more than one tick between adjacent observations to reject cross-thread ordering ambiguity. This shared counter interval is the primary proof; UTC ordering remains an additional consistency check. Passing also requires exact before/during/after semantic status equality, matching native and host marker bytes, and stable native identity. [Microsoft documents the shared Windows performance counter and its independence from UTC.](https://learn.microsoft.com/en-us/windows/win32/sysinfo/acquiring-high-resolution-time-stamps) Native elapsed monotonic time must be 8–12 seconds, with wall elapsed within 0.25 seconds; scheduling delays or clock jumps outside these bounds remain unqualified. Container clocks and `busy:true` cannot satisfy the proof. + +The asynchronous hold uses a separate evidence client. Native script calls retain the documented 180-second bridge transport budget and use a 210-second client deadline; the other reads have a 60-second client deadline. An argument named `timeout` inside the modeling-script payload would not control its HTTP waiter and is not sent. No cancellation or retry is issued. A client deadline cannot cancel native work and never establishes completion. If any concurrent observation fails, the runner still waits for the one issued call, retains its raw response/error, and attempts a read-only final marker retrieval. The report remains FAIL with an INCONCLUSIVE interval; it does not replay the hold, close documents, clean up the host, or reinterpret an earlier failed attempt as success. + +This qualifies ledger access while the main thread sleeps and releases Python's GIL. It does not qualify a kernel/GIL wedge, native progress, cancellation, interrupted-write recovery, or restart history. Source tests are offline; no native execution was performed by the builder. + +The final checker derives marker measurements from the original base64 bytes after verifying exact length and SHA-256. Native and host copies must contain identical bytes. Their separately parsed JSON shadows remain checked: every identity, integer QPC value, Boolean and non-clock field must match exactly. Only `wall` and `monotonic` floating-point shadows may differ by one adjacent IEEE754 value, which is recorded explicitly and is never used for the interval proof. This narrowly accounts for the native reply's observed JSON roundtrip of one wall timestamp; larger differences or numeric-type changes fail. + +`adjudicate_saved.py EVIDENCE NEW_OUTPUT.json` performs offline qualification only. It revalidates the eleven original public envelopes, exact generated programs, hash-bound ordinary harness/request, three full terminal histories, and original marker bytes/QPC interval. It hashes and rereads all source evidence without modifying it; output creation is exclusive. The original run's FAIL remains visible beside a separately qualified result. It makes no bridge calls and never repeats a native hold. diff --git a/tests/job_acceptance/busy/adjudicate_saved.py b/tests/job_acceptance/busy/adjudicate_saved.py new file mode 100644 index 0000000..e696f63 --- /dev/null +++ b/tests/job_acceptance/busy/adjudicate_saved.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Offline qualification of immutable busy evidence; never dispatches any call.""" +import argparse +import base64 +import json +import math +from pathlib import Path + +import busy_status as b + + +def bind_attempt(row): + b.need(type(row.get('returncode')) is int and row['returncode']==0,'Saved call return code is not exact integer zero') + b.need(b.canonical(row.get('args'))==b.canonical(json.loads(row['requestJson'])), 'Saved args/requestJson differ') + b.need(b.canonical(row.get('raw'))==b.canonical(json.loads(row['stdout'])), 'Saved raw/stdout differ') + b.need(all(type(row.get(key)) in (int,float) and math.isfinite(row[key]) and row[key]>0 + for key in ('startedAt','finishedAt')) and row['startedAt']<=row['finishedAt'], 'Saved call timestamps are invalid') + + +def bind_sequence(calls, hold): + b.need(all(left['finishedAt']<=right['startedAt'] for left,right in zip(calls,calls[1:])), 'Sequential observation calls overlap or are reordered') + b.need(calls[4]['finishedAt']<=hold['startedAt']<=calls[5]['finishedAt'] + and hold['finishedAt']<=calls[8]['startedAt'], 'Hold call chronology differs from the one asynchronous dispatch') + + +def adjudicate(directory): + root=Path(directory).resolve() + paths=[root/'report.json',*sorted((root/'calls').glob('*.json')),*sorted((root/'hold-calls').glob('*.json')), + *sorted(root.glob('*.py')),*sorted(root.glob('*.ps1'))] + hashes={str(path):b.digest(path.read_bytes()) for path in paths} + report=json.loads((root/'report.json').read_text());expected=report['expected'];observations=report['observations'] + ordinary,owner,folder=b.prerequisites(report['ordinaryEvidence'], + json.loads((Path(report['ordinaryEvidence'])/'report.json').read_text())['target'],expected['host']) + b.need(ordinary['sessionId']==expected['sessionId'] and ordinary['runId']==expected['runId'] + and ordinary['baseline']['native']['documents']==[expected['original']] + and owner['hostScratch']==expected['scratch'], 'Busy/ordinary identity differs') + modules=b.load_harness(folder);harness=modules['fusion_harness'];jobs=modules['ralph_jobs'];support=modules['ralph_support'] + request,original=b.depth_receipt(report['ordinaryEvidence'],ordinary,owner) + calls=[json.loads(path.read_text()) for path in sorted((root/'calls').glob('*.json'))] + hold_calls=[json.loads(path.read_text()) for path in sorted((root/'hold-calls').glob('*.json'))] + sequence=['run_script','fusion_state','fusion_document_info','fusion_run_modeling_script','fusion_mech_operation_status', + 'run_script','fusion_mech_operation_status','run_script','run_script','fusion_mech_operation_status'] + b.need([row['verb'] for row in calls]==sequence and len(hold_calls)==1 + and hold_calls[0]['verb']=='fusion_run_modeling_script', 'Saved call sequence differs') + def payload(row): + bind_attempt(row) + b.need(row.get('target')==ordinary['target'] and row.get('runId')==expected['runId'] + and row.get('returncode')==0 and not harness.evaluate(row['raw'],{'ok':True}), 'Saved public envelope failed') + return harness.observe(row['raw']).payload + values=[payload(row) for row in calls] + held=payload(hold_calls[0])['data']['result'] + bind_sequence(calls,hold_calls[0]) + state=values[1] + b.need(state.get('running') is True and state.get('addinResponding') is True + and not state.get('mainThreadBusy') and not state.get('blockingDialogs'), 'Saved initial runtime readiness differs') + documents=values[2].get('data',values[2]) + b.need(len(documents.get('openDocuments',documents.get('documents',[])))==1,'Saved initial document inventory differs') + b.need(b.canonical(held)==b.canonical(observations['hold']) + and b.canonical(held)==b.canonical(report['holdResponse']['data']['result']), 'Saved hold result differs') + for index,mode in ((0,'initial'),(5,'entered'),(7,'clock'),(8,'final')): + value=values[index] + b.need(value.get('exitCode')==0 and value.get('timedOut') is not True,'Saved host read failed') + b.need(b.canonical(json.loads(value['stdout']))==b.canonical(observations[mode]),'Saved host result differs') + args=json.loads(calls[index]['requestJson']) + b.need(base64.b64decode(args['scriptB64'],validate=True).decode()==b.host_script(expected,mode),'Saved host program differs') + for row,hold in ((calls[3],False),(hold_calls[0],True)): + args=json.loads(row['requestJson']) + b.need(set(args)=={'script'} and args['script']==b.native_program(support.native_script,expected,hold), 'Saved native program differs') + b.need(b.canonical(values[3]['data']['result'])==b.canonical(observations['preflight']), 'Saved native preflight differs') + preflight=observations['preflight'] + b.need(preflight.get('success') is True and all(b.canonical(preflight.get(key))==b.canonical(expected[key]) + for key in ('sessionId','runId','pid','original')), 'Saved exact native preflight identity differs') + signatures=[] + for index,label in ((4,'before'),(6,'during'),(9,'after')): + status=values[index] + b.need(json.loads(calls[index]['requestJson'])=={'sessionId':expected['sessionId'],'operationId':request['operationId']}, 'Status request identity differs') + b.need(b.canonical(status)==b.canonical(observations[label]),'Saved status observation differs') + errors=jobs.terminal_contract(request,jobs.native_envelope(original),status, + ordinary['baseline']['addin']['version'],request['target']['documentId']) + b.need(not errors,'Saved terminal contract differs: '+str(errors)) + signatures.append(jobs.native_signature(status)) + b.same_status(*signatures) + interval=b.interval_proof(expected,observations['initial'],observations['entered'],observations['clock'],observations['final'],held) + b.need(all(b.digest(Path(path).read_bytes())==sha for path,sha in hashes.items()), 'Input evidence changed during qualification') + return {'qualification':'PASS','nativeRerun':False,'originalOverall':report['overall'],'originalFailure':report.get('failure'), + 'originalEvidence':str(root),'inputHashes':hashes,'checkerSha256':b.digest(Path(b.__file__).read_bytes()), + 'adjudicatorSha256':b.digest(Path(__file__).read_bytes()),'publicCallsValidated':len(calls)+len(hold_calls), + 'immutableStatusComparisons':3,'interval':interval,'ordinaryHarnessSources':ordinary['harnessSources'], + 'limitations':report['limitations']} + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('evidence');parser.add_argument('output') + args=parser.parse_args() + output=Path(args.output) + b.need(not output.exists(),'Qualification output already exists; original evidence will never be overwritten') + result=adjudicate(args.evidence) + with output.open('x') as stream:stream.write(json.dumps(result,indent=2,allow_nan=False)+'\n') + print(json.dumps({'qualification':result['qualification'],'output':str(output),'nativeRerun':False})) + + +if __name__=='__main__':main() diff --git a/tests/job_acceptance/busy/busy_status.py b/tests/job_acceptance/busy/busy_status.py new file mode 100644 index 0000000..4b2e5cc --- /dev/null +++ b/tests/job_acceptance/busy/busy_status.py @@ -0,0 +1,427 @@ +#!/usr/bin/env python3 +"""Finite CP8 busy-status observation; plan is the default and performs no host calls.""" +import argparse +import base64 +import concurrent.futures +import hashlib +import importlib +import json +import math +from pathlib import Path, PureWindowsPath +import re +import sys +import uuid + + +def need(condition, message): + if not condition: + raise ValueError(message) + + +def canonical(value): + return json.dumps(value, sort_keys=True, separators=(',', ':'), allow_nan=False) + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def marker_paths(scratch, run_id, nonce): + root = PureWindowsPath(scratch) + need(root.is_absolute() and not root.drive.startswith('\\\\') and '..' not in root.parts + and root.name == run_id and re.fullmatch(r'[A-Za-z0-9_-]{12,100}', run_id), 'Invalid owned scratch root') + need(re.fullmatch(r'[0-9a-f]{32}', nonce) is not None, 'Invalid probe nonce') + folder = root / ('busy-' + nonce) + return {kind: str(folder / (kind + '.json')) for kind in ('entered', 'exited')} + + +def closed_documents(owner): + need(owner.get('documents'), 'Ordinary ownership has no document records') + for document_id in owner['documents']: + label = 'close owned document ' + document_id + receipt = owner.get('cleanupLatest', {}).get(label, {}) + raw = receipt.get('raw', {}) + need(receipt.get('label') == label and receipt.get('status') == 'PASS' and receipt.get('errors') == [] + and raw.get('success') is True and raw.get('closed') is True and raw.get('closedDocumentId') == document_id, + 'Latest exact owned-document close receipt is absent or failed') + + +def prerequisites(directory, target, host): + directory = Path(directory).resolve() + report = json.loads((directory / 'report.json').read_text()) + owner = json.loads((directory / 'ownership.json').read_text()) + need(report.get('native') == 'PASS' and report.get('cleanup') == 'PASS' + and report.get('acceptanceScope', {}).get('ordinary') == 'PASS', 'Ordinary native acceptance and cleanup must pass') + need(report.get('fixtures') and all(x.get('status') == 'PASS' for x in report['fixtures']) + and all(x.get('status') == 'PASS' for x in report.get('checks', [])), 'Ordinary evidence contains a failure') + need(report.get('target') == owner.get('target') == target and report.get('host', '').lower() == host.lower() + and owner.get('host', '').lower() == host.lower(), 'Ordinary host/target differs') + need(report.get('runId') == owner.get('runId') and report.get('sessionId') == owner.get('sessionId') + and isinstance(report.get('sessionId'), str) and report['sessionId'], 'Ordinary run/session differs') + need(owner.get('state') == 'complete' and not owner.get('pendingRegistrations') + and not owner.get('retainRequired'), 'Ownership recovery remains unresolved') + # metadata.closed is the earlier registration/inventory snapshot; the + # closer's exact latest receipt is the maintained cleanup authority. + closed_documents(owner) + need(all(row.get('status') == 'PASS' for row in owner.get('cleanupLatest', {}).values()), 'Latest cleanup contains a failure') + baseline = report['baseline']['native'] + need(len(baseline['documents']) == 1 and baseline['activeCreationId'] == baseline['documents'][0]['creationId'] + and baseline['documents'][0]['modified'] is False and baseline['documents'][0]['saved'] is False, + 'Expected one pristine unsaved original document') + names = {'ralph_jobs.py', 'ralph_mechanical.py', 'ralph_support.py', 'fusion_harness.py'} + sources = report.get('harnessSources', []) + need(len(sources) == len(names) and {row['name'] for row in sources} == names, 'Immutable ordinary harness inventory differs') + folders = set() + for row in sources: + path = Path(row['path']).resolve() + need(path.is_relative_to(directory) and path.name == row['name'] and not Path(row['path']).is_symlink(), 'Harness source is outside ordinary evidence') + need(digest(path.read_bytes()) == row['sha256'], 'Ordinary harness hash differs') + folders.add(path.parent) + need(len(folders) == 1, 'Ordinary harness source folder differs') + marker_paths(owner['hostScratch'], report['runId'], '0' * 32) + return report, owner, folders.pop() + + +def load_harness(folder): + names = ('fusion_harness', 'ralph_support', 'ralph_mechanical', 'ralph_jobs') + for name in names: + need(name not in sys.modules, 'Refusing a preloaded harness module: ' + name) + sys.path.insert(0, str(folder)) + modules = {name: importlib.import_module(name) for name in names} + for name, module in modules.items(): + need(Path(module.__file__).resolve() == (folder / (name + '.py')).resolve(), 'Harness import escaped frozen sources') + return modules + + +def depth_receipt(directory, report, owner): + oid = report['runId'] + '-depth' + matches = [] + for path in sorted((Path(directory) / 'calls').glob('*-fusion_mechanical.json')): + row = json.loads(path.read_text()) + args = json.loads(row['requestJson']) + if args.get('operationId') == oid: + matches.append(row) + need(bool(matches), 'Ordinary terminal depth receipt is absent') + first = matches[0] + request = json.loads(first['requestJson']) + need(first.get('target') == report['target'] and first.get('returncode') == 0 + and request.get('sessionId') == report['sessionId'] and request.get('op') == 'parameter_set', 'Depth receipt identity differs') + need(request.get('target', {}).get('documentId') in owner['documents'], 'Depth edit was not in an owned document') + return request, first['raw'] + + +PREFIX = '''import threading, builtins +if threading.current_thread() is not threading.main_thread(): + raise RuntimeError('Busy probe requires the native main thread') +_registry = getattr(builtins, '_adom_mechanical_ralph_runs', None) +if not isinstance(_registry, dict) or __RUN_ID__ not in _registry: + raise RuntimeError('Existing exact run registry is required') +''' + + +def native_program(factory, expected, hold=False): + paths = marker_paths(expected['scratch'], expected['runId'], expected['nonce']) + prefix = PREFIX.replace('__RUN_ID__', repr(expected['runId'])) + # Reuse exact ordinary session/object inventory. The prefix prevents the + # inherited adapter from creating a registry when none exists. + source = prefix + factory('inventory', run_id=expected['runId'], host=expected['host'], session_id=expected['sessionId']) + source += '\nEXPECTED = json.loads(' + repr(json.dumps(expected)) + ')\n' + source += r''' +import time, threading +require(threading.current_thread() is threading.main_thread(), 'Main thread changed') +require(os.getpid() == EXPECTED['pid'], 'Native Fusion PID changed') +require(not run.get('provisionalImports') and not run.get('retainRequired'), 'Native recovery remains unresolved') +require(len(run['documents']) <= 1024 and all(row.get('closed') is True and not row.get('retainRequired') for row in run['documents'].values()), 'Owned native document remains open or retained') +require(len(run['baselineObjects']) == 1 and app.documents.count == 1, 'Native original inventory differs') +original = run['activeObject'] +require(app.activeDocument == original and app.documents.item(0) == original and run['baselineObjects'][0] == original, 'Exact original object differs') +require(run['activeFingerprint'] == EXPECTED['original'] and fingerprint(original) == EXPECTED['original'], 'Original fingerprint changed') +require(not original.isModified and not original.isSaved, 'Original is not pristine') +require(documents() == run['baseline'] == [EXPECTED['original']], 'Original baseline changed') +idle() +design = adsk.fusion.Design.cast(original.products.itemByProductType('DesignProductType')) +require(design is not None and design.rootComponent.bRepBodies.count == 0 and design.rootComponent.sketches.count == 0 and design.rootComponent.allOccurrences.count == 0 and design.timeline.count == 0, 'Original geometry is not pristine') +require(os.path.normcase(os.path.abspath(run['scratch'])) == os.path.normcase(os.path.abspath(EXPECTED['scratch'])), 'Scratch differs from native registry') +require(os.path.isdir(run['scratch']) and os.path.realpath(run['scratch']) == os.path.abspath(run['scratch']), 'Scratch root is linked or absent') +def state(): + require(app.activeDocument == original and app.documents.count == 1 and app.documents.item(0) == original, 'Original object changed during hold') + require(fingerprint(original) == EXPECTED['original'], 'Original fingerprint changed during hold') + require(design.rootComponent.bRepBodies.count == 0 and design.rootComponent.sketches.count == 0 and design.rootComponent.allOccurrences.count == 0 and design.timeline.count == 0, 'Original geometry changed during hold') + idle() + return fingerprint(original) +result = {'success': True, 'sessionId': EXPECTED['sessionId'], 'runId': EXPECTED['runId'], 'pid': os.getpid(), 'original': state()} +''' + if hold: + source += '\nPATHS = json.loads(' + repr(json.dumps(paths)) + ')\n' + source += r''' +folder = os.path.dirname(PATHS['entered']) +require(os.path.dirname(folder) == os.path.abspath(run['scratch']), 'Marker directory escaped exact scratch') +require(platform.system() == 'Windows', 'Shared QPC proof requires Windows') +import ctypes +kernel = ctypes.WinDLL('kernel32', use_last_error=True) +for function in (kernel.QueryPerformanceCounter, kernel.QueryPerformanceFrequency): + function.argtypes = [ctypes.POINTER(ctypes.c_longlong)] + function.restype = ctypes.c_int +def counter(): + ticks, frequency = ctypes.c_longlong(), ctypes.c_longlong() + require(kernel.QueryPerformanceFrequency(ctypes.byref(frequency)) and frequency.value > 0, 'QPC frequency unavailable') + require(kernel.QueryPerformanceCounter(ctypes.byref(ticks)) and ticks.value > 0, 'QPC timestamp unavailable') + return {'counterKind': 'windows_qpc', 'counterTicks': ticks.value, 'counterFrequency': frequency.value} +os.mkdir(folder) # Exclusive probe directory; never reuse or remove evidence. +def marker(kind): + row = {key: EXPECTED[key] for key in ('nonce', 'sessionId', 'runId', 'pid', 'host')} + row.update(kind=kind, wall=time.time(), monotonic=time.monotonic(), thread=threading.get_ident(), original=state()) + row.update(counter()) + raw = json.dumps(row, sort_keys=True, allow_nan=False).encode('utf-8') + require(len(raw) <= 4096, 'Marker exceeds byte budget') + pending = PATHS[kind] + '.pending' + with open(pending, 'xb') as stream: + stream.write(raw); stream.flush(); os.fsync(stream.fileno()) + os.link(pending, PATHS[kind]) # Exclusive publication of complete bytes. + os.unlink(pending) + return {'value': row, 'sha256': hashlib.sha256(raw).hexdigest(), 'bytes': len(raw), 'rawBase64': base64.b64encode(raw).decode('ascii')} +entered = marker('entered') +time.sleep(8) +exited = marker('exited') +result.update(entered=entered, exited=exited, holdSeconds=8) +''' + return source + + +def host_script(expected, mode): + need(mode in ('initial', 'entered', 'clock', 'final'), 'Invalid host observation mode') + request = {**expected, 'paths': marker_paths(expected['scratch'], expected['runId'], expected['nonce']), 'mode': mode} + return r''' +$ErrorActionPreference = 'Stop' +try { + $q = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__REQUEST__')) | ConvertFrom-Json + if ([Environment]::MachineName -ine $q.host) { throw 'Host identity differs' } + $p = Get-CimInstance Win32_Process -Filter ('ProcessId=' + [int]$q.pid) + if (-not $p -or $p.Name -ine 'Fusion360.exe') { throw 'Expected Fusion process is absent' } + $scratch = [IO.Path]::GetFullPath([string]$q.scratch) + if ([IO.Path]::GetFileName($scratch) -cne $q.runId) { throw 'Scratch run identity differs' } + $walk = Get-Item -LiteralPath $scratch + while ($null -ne $walk) { + if (($walk.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Scratch traverses a reparse point' } + $walk = $walk.Parent + } + function Read-Marker([string]$path) { + $full = [IO.Path]::GetFullPath($path) + $folder = [IO.Path]::GetDirectoryName($full) + if ([IO.Path]::GetDirectoryName($folder) -ine $scratch -or [IO.Path]::GetFileName($folder) -cne ('busy-' + $q.nonce)) { throw 'Marker escaped owned scratch' } + foreach ($item in @((Get-Item -LiteralPath $folder), (Get-Item -LiteralPath $full))) { + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Marker is linked' } + } + $file = Get-Item -LiteralPath $full + if ($file.Length -gt 4096 -or $file.Length -le 0) { throw 'Marker size invalid' } + $bytes = [IO.File]::ReadAllBytes($full) + if ($bytes.Length -gt 4096) { throw 'Marker exceeded read budget' } + $sha = [Security.Cryptography.SHA256]::Create() + try { $hash = [BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-','').ToLowerInvariant() } finally { $sha.Dispose() } + return @{ value=([Text.Encoding]::UTF8.GetString($bytes) | ConvertFrom-Json); sha256=$hash; bytes=$bytes.Length; rawBase64=[Convert]::ToBase64String($bytes) } + } + $values = @{} + if ($q.mode -eq 'initial') { + if (Test-Path -LiteralPath ([IO.Path]::GetDirectoryName($q.paths.entered))) { throw 'Probe directory already exists' } + } + if ($q.mode -eq 'entered') { + $wait = [Diagnostics.Stopwatch]::StartNew() + while (-not (Test-Path -LiteralPath $q.paths.entered)) { + if ($wait.Elapsed.TotalSeconds -ge 5) { throw 'Entered marker was not observed within five seconds' } + Start-Sleep -Milliseconds 25 + } + $values.entered = Read-Marker $q.paths.entered + } + if ($q.mode -eq 'final') { + $values.entered = Read-Marker $q.paths.entered + $values.exited = Read-Marker $q.paths.exited + } + $wall = ([DateTimeOffset]::UtcNow - [DateTimeOffset]'1970-01-01T00:00:00Z').TotalSeconds + if (-not [Diagnostics.Stopwatch]::IsHighResolution) { throw 'Stopwatch is not using the high-resolution performance counter' } + $ticks = [Diagnostics.Stopwatch]::GetTimestamp() + $again = Get-CimInstance Win32_Process -Filter ('ProcessId=' + [int]$q.pid) + if (-not $again -or $again.CreationDate -ne $p.CreationDate -or $again.ExecutablePath -ine $p.ExecutablePath) { throw 'Native process changed during host read' } + @{ success=$true; host=[Environment]::MachineName; pid=[int]$q.pid; processCreatedUtc=$p.CreationDate.ToUniversalTime().ToString('o'); + executablePath=$p.ExecutablePath; wall=$wall; markers=$values; counterKind='windows_qpc'; counterTicks=$ticks; + counterFrequency=[Diagnostics.Stopwatch]::Frequency; highResolution=$true } | ConvertTo-Json -Depth 12 -Compress +} catch { [Console]::Error.WriteLine($_.Exception.Message); exit 1 } +'''.replace('__REQUEST__', base64.b64encode(json.dumps(request).encode()).decode()) + + +def validate_host(value, expected, initial=None): + need(value.get('success') is True and value.get('pid') == expected['pid'] and type(value.get('pid')) is int + and value.get('host', '').lower() == expected['host'].lower(), 'Host observation identity differs') + need(value.get('processCreatedUtc') and value.get('executablePath'), 'Host process provenance missing') + need(type(value.get('wall')) in (int, float) and math.isfinite(value['wall']) and value['wall'] > 0, 'Invalid host wall clock') + validate_counter(value) + need(value.get('highResolution') is True, 'Host high-resolution counter is unqualified') + if initial: + need(all(value[key] == initial[key] for key in ('pid', 'processCreatedUtc', 'executablePath')), 'Native process changed') + + +def validate_counter(value): + need(value.get('counterKind') == 'windows_qpc' and all(type(value.get(key)) is int and 0 < value[key] < 2**63 + for key in ('counterTicks','counterFrequency')), 'Invalid shared Windows performance counter') + + +def marker_bytes(receipt, label): + """Hash-bound bytes are authoritative; decoded transport shadows are checked.""" + need(re.fullmatch(r'[0-9a-f]{64}', receipt.get('sha256','')) and type(receipt.get('bytes')) is int + and 0 < receipt['bytes'] <= 4096, 'Marker byte attestation invalid') + raw = base64.b64decode(receipt['rawBase64'],validate=True) + need(len(raw) == receipt['bytes'] and digest(raw) == receipt['sha256'], 'Marker bytes or hash differ') + value, shadow = json.loads(raw), receipt.get('value') + need(isinstance(value,dict) and isinstance(shadow,dict) and value.keys() == shadow.keys(), 'Parsed marker shape differs') + differences = [] + for key, authoritative in value.items(): + shown = shadow[key] + if canonical(authoritative) == canonical(shown): + continue + # A native reply's JSON parse/serialize path was measured changing the + # entered wall double by one representable float. QPC integers, identity + # fields, booleans and every other field still require exact equality. + need(key in ('wall','monotonic') and type(authoritative) is float and type(shown) is float + and math.isfinite(authoritative) and math.isfinite(shown) + and shown in (math.nextafter(authoritative,math.inf),math.nextafter(authoritative,-math.inf)), + 'Parsed marker differs beyond one adjacent clock float: ' + key) + differences.append({'receipt':label,'field':key,'byteValue':authoritative,'transportValue':shown, + 'qualification':'one adjacent IEEE754 float; proof uses original bytes'}) + return raw, value, differences + + +def interval_proof(expected, initial, observed, upper, final, native): + for value in (initial, observed, upper, final): validate_host(value, expected, initial) + entered, exited = final['markers']['entered'], final['markers']['exited'] + records = [('hostObservedEntered',observed['markers']['entered']),('hostFinalEntered',entered), + ('nativeEntered',native['entered']),('hostFinalExited',exited),('nativeExited',native['exited'])] + decoded = {label:marker_bytes(receipt,label) for label,receipt in records} + need(decoded['hostObservedEntered'][0] == decoded['hostFinalEntered'][0] == decoded['nativeEntered'][0], 'Entered marker bytes changed') + need(decoded['hostFinalExited'][0] == decoded['nativeExited'][0], 'Native/host exited marker bytes differ') + a,b = decoded['hostFinalEntered'][1],decoded['hostFinalExited'][1] + for kind, value in (('entered', a), ('exited', b)): + need(value.get('kind') == kind and all(canonical(value.get(key)) == canonical(expected[key]) for key in ('nonce','sessionId','runId','pid','host','original')), 'Marker identity differs') + need(type(value.get('pid')) is int and type(value.get('thread')) is int and value['thread'] > 0, 'Marker native identity type differs') + need(all(type(value.get(key)) in (int,float) and math.isfinite(value[key]) for key in ('wall','monotonic')), 'Marker clock invalid') + validate_counter(value) + need(a['thread'] == b['thread'], 'Native thread changed') + elapsed = b['monotonic'] - a['monotonic'] + need(8 <= elapsed <= 12 and abs((b['wall'] - a['wall']) - elapsed) <= .25, 'Native duration or wall-clock continuity unproven') + points = (initial,a,observed,upper,b,final) + frequency = a['counterFrequency'] + need(all(point['counterFrequency'] == frequency for point in points), 'Performance-counter frequencies differ') + ticks = [point['counterTicks'] for point in points] + # Microsoft documents a +/- one-tick cross-thread ordering ambiguity. + need(all(left + 1 < right for left,right in zip(ticks,ticks[1:])), 'Status response is not proven inside the shared QPC hold interval') + counter_elapsed = (b['counterTicks'] - a['counterTicks']) / frequency + need(8 <= counter_elapsed <= 12 and abs(counter_elapsed - elapsed) <= .25, 'Native clocks disagree about hold duration') + need(initial['wall'] <= a['wall'] <= observed['wall'] <= upper['wall'] < b['wall'] <= final['wall'], 'Status response is not proven inside the native hold') + need(native.get('success') is True and type(native.get('holdSeconds')) is int and native['holdSeconds'] == 8 + and all(canonical(native.get(key)) == canonical(expected[key]) for key in ('sessionId','runId','pid','original')), 'Native hold did not finish normally') + return {'hostClockIntervalProven': True, 'sharedCounter': 'windows_qpc', 'counterFrequency': frequency, + 'nativeElapsedSeconds': elapsed, 'qpcElapsedSeconds': counter_elapsed, + 'statusUpperBoundTicks': upper['counterTicks'], 'statusUpperBound': upper['wall'], + 'transportClockRoundTrips':[difference for _,_,rows in decoded.values() for difference in rows]} + + +def same_status(before, during, after): + need(all(isinstance(value, str) and value for value in (before,during,after)) + and before == during == after, 'Immutable terminal status changed') + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('mode', choices=('plan','run'), nargs='?', default='plan') + for name in ('ordinary-evidence','target','host-name','evidence'): parser.add_argument('--'+name) + parser.add_argument('--fusion-pid', type=int) + args = parser.parse_args(argv) + if args.mode == 'plan': + print(json.dumps({'hostCalls':False,'holdSeconds':8,'holds':1,'requires':'Ordinary native PASS + cleanup PASS; exact existing session/pristine original', + 'checks':['immutable terminal status before/during/after','separate host-clock interval proof','exact process/session/original preservation'], + 'notRun':['CAD mutation','restart','render','screenshots','Shotlog','automatic retries']})) + return 0 + need(all((args.ordinary_evidence,args.target,args.host_name,args.evidence,args.fusion_pid)) and args.fusion_pid > 0, 'Explicit ordinary evidence, target, host, output and Fusion PID required') + report, owner, folder = prerequisites(args.ordinary_evidence,args.target,args.host_name) + request, original_receipt = depth_receipt(args.ordinary_evidence,report,owner) + modules = load_harness(folder) + harness, jobs, support = (modules[name] for name in ('fusion_harness','ralph_jobs','ralph_support')) + out = Path(args.evidence); out.mkdir(parents=True, exist_ok=False) + expected = {'runId':report['runId'],'sessionId':report['sessionId'],'host':args.host_name,'pid':args.fusion_pid, + 'scratch':owner['hostScratch'],'nonce':uuid.uuid4().hex,'original':report['baseline']['native']['documents'][0]} + result = {'overall':'RUNNING','expected':expected,'ordinaryEvidence':str(Path(args.ordinary_evidence).resolve()),'observations':{},'limitations':['Releases Python GIL during sleep; no kernel/GIL-wedge, cancellation or crash proof.']} + client = harness.BridgeClient(args.target, report['runId'], ai_thread='Fusion finite busy-status probe',evidence_dir=out/'calls') + hold_client = harness.BridgeClient(args.target,report['runId'],ai_thread='Fusion finite busy-status hold',evidence_dir=out/'hold-calls') + def save(): (out/'report.json').write_text(json.dumps(result,indent=2,allow_nan=False)+'\n') + def call(verb, arguments, selected=client): + # Keep the documented 180-second native script transport budget; a + # timeout value inside its args would not control the HTTP waiter. + reply = selected.call(verb,arguments,timeout=210 if verb == 'fusion_run_modeling_script' else 60) + need(not reply.timed_out and not harness.evaluate(reply.raw,{'ok':True}), 'Public call failed: '+verb) + return reply.observation.payload + def host(mode): + program = host_script(expected,mode) + (out/('host-'+mode+'.ps1')).write_text(program) + reply = call('run_script',{'interpreter':'powershell','scriptB64':base64.b64encode(program.encode()).decode(),'timeoutSeconds':12,'reason':'Read exact owned busy-probe markers and native host clock without changing Fusion'}) + need(reply.get('exitCode') == 0 and reply.get('timedOut') is not True, 'Host marker read failed: '+str(reply.get('stderr'))) + value = json.loads(reply['stdout']); validate_host(value,expected,result['observations'].get('initial')) + result['observations'][mode] = value; save(); return value + def status(label): + value = call('fusion_mech_operation_status',{'sessionId':request['sessionId'],'operationId':request['operationId']}) + errors = jobs.terminal_contract(request,jobs.native_envelope(original_receipt),value, + report['baseline']['addin']['version'],request['target']['documentId']) + need(not errors,'Terminal status contract differs: '+str(errors)) + signature = jobs.native_signature(value) + result['observations'][label] = value; save(); return signature + future = None + pool = concurrent.futures.ThreadPoolExecutor(max_workers=1) + save() + try: + initial = host('initial') + state = call('fusion_state',{'screenshot':False,'nativeViewport':False}) + need(state.get('running') is True and state.get('addinResponding') is True and not state.get('mainThreadBusy') and not state.get('blockingDialogs'), 'Fusion is not already idle and responsive') + info = call('fusion_document_info',{}) + info = info.get('data',info) + docs = info.get('openDocuments',info.get('documents')) + need(isinstance(docs,list) and len(docs)==1,'Exactly one open original document required before native script') + program = native_program(support.native_script,expected) + (out/'native-preflight.py').write_text(program) + preflight = call('fusion_run_modeling_script',{'script':program})['data']['result'] + need(preflight.get('success') is True and preflight.get('sessionId') == expected['sessionId'] and preflight.get('pid') == expected['pid'], 'Native preflight refused') + result['observations']['preflight'] = preflight + before = status('before') + program = native_program(support.native_script,expected,True) + (out/'native-hold.py').write_text(program) + result['holdDispatched'] = True; save() + future = pool.submit(call,'fusion_run_modeling_script',{'script':program},hold_client) + observed = host('entered') + # Identity/timestamp failures prohibit treating a foreign marker as + # permission to continue, but the one issued hold still runs normally. + entry = observed['markers']['entered']['value'] + need(all(canonical(entry.get(k))==canonical(expected[k]) for k in ('nonce','sessionId','runId','pid','host','original')), 'Entered marker identity differs') + during = status('during') + upper = host('clock') + completed = future.result()['data']['result'] + result['observations']['hold'] = completed + final = host('final') + after = status('after') + same_status(before,during,after) + result['interval'] = interval_proof(expected,initial,observed,upper,final,completed) + result['overall'] = 'PASS' + except BaseException as error: + result.update(overall='FAIL',failure={'type':type(error).__name__,'message':str(error)},intervalQualification='INCONCLUSIVE') + finally: + # No cancellation or replay. Preserve the hold result even when a + # concurrent status or timing observation failed first. + if future is not None: + try: result['holdResponse'] = future.result() + except BaseException as error: result['holdFailure'] = {'type':type(error).__name__,'message':str(error)} + if 'final' not in result['observations']: + try: host('final') + except BaseException as error: result['finalMarkerFailure'] = {'type':type(error).__name__,'message':str(error)} + pool.shutdown(wait=True) + save() + print(json.dumps({'overall':result['overall'],'report':str(out/'report.json')})) + return 0 if result['overall']=='PASS' else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/job_acceptance/busy/manifest.json b/tests/job_acceptance/busy/manifest.json new file mode 100644 index 0000000..e3b1631 --- /dev/null +++ b/tests/job_acceptance/busy/manifest.json @@ -0,0 +1,37 @@ +{ + "name": "CP8 finite busy-status orchestrator + byte-bound offline qualification", + "files": [ + { + "path": "CONTRACT.md", + "sha256": "9e80e786ef63b88782188c0fde43cec476849204b279ad7a5613155c078eecc9", + "bytes": 3126 + }, + { + "path": "README.md", + "sha256": "84ab99f27c756e6e609548c246dd0dbcba03670ffdb5bc40a029b0b7f0c68666", + "bytes": 5531 + }, + { + "path": "busy_status.py", + "sha256": "3cdb4ccf2b326bdd940e209f8254fe773d084cc4ba77933a5651701d03b0f236", + "bytes": 28125 + }, + { + "path": "test_busy_status.py", + "sha256": "28241f5ae8733dd4e3d24a6a4edcd80ec6f0ed894348ac387508355bc6a2c514", + "bytes": 18496 + }, + { + "path": "adjudicate_saved.py", + "sha256": "d97f1e44e99b14bacecfbb52cc4bc5a1d747d9b9c1f7a306f2f29258f8608221", + "bytes": 7197 + } + ], + "offlineTests": 21, + "nativeExecutedByBuilder": false, + "defaultMode": "plan", + "frozenSource": "/home/adom/project/research/fusion-roadmap-build-2026-10-02/cp8-busy-preparation/source-r5", + "testCommand": "python3 -m unittest discover -s . -p 'test_busy_status.py' -v", + "offlineQualification": "qualified-native-r4.json", + "originalNativeFailurePreserved": true +} diff --git a/tests/job_acceptance/busy/test_busy_status.py b/tests/job_acceptance/busy/test_busy_status.py new file mode 100644 index 0000000..577aa06 --- /dev/null +++ b/tests/job_acceptance/busy/test_busy_status.py @@ -0,0 +1,288 @@ +"""Offline fault checks only; no bridge commands or native Fusion imports.""" +import ast +import base64 +import contextlib +import copy +import hashlib +import io +import json +import math +from pathlib import Path +import tempfile +import threading +from types import SimpleNamespace as NS +import unittest +from unittest.mock import patch + +import busy_status as b +import adjudicate_saved as adjudicator + + +class BusyStatus(unittest.TestCase): + def setUp(self): + self.expected = {'nonce':'a'*32,'sessionId':'session-A','runId':'owned-run-12345', + 'pid':123,'host':'HOST','scratch':'C:/Temp/adom-fusion-ralph/owned-run-12345', + 'original':{'creationId':'original','name':'Untitled','modified':False,'saved':False}} + self.initial = self.host(99) + self.entry = self.marker('entered',100,500) + self.exit = self.marker('exited',108.1,508.1) + self.observed = self.host(101,{'entered':self.entry}) + self.upper = self.host(103) + self.final = self.host(109,{'entered':self.entry,'exited':self.exit}) + self.native = {**{key:self.expected[key] for key in ('sessionId','runId','pid','original')}, + 'success':True,'holdSeconds':8,'entered':self.entry,'exited':self.exit} + + def host(self,wall,markers=None): + return {'success':True,'pid':123,'host':'HOST','processCreatedUtc':'2026-10-05T10:00:00Z', + 'executablePath':'C:/Fusion/Fusion360.exe','wall':wall,'markers':markers or {}, + 'counterKind':'windows_qpc','counterTicks':int((wall+400)*1000),'counterFrequency':1000,'highResolution':True} + + def marker(self,kind,wall,monotonic): + value = {key:self.expected[key] for key in ('nonce','sessionId','runId','pid','host','original')} + value.update(kind=kind,wall=wall,monotonic=monotonic,thread=17) + value.update(counterKind='windows_qpc',counterTicks=int(monotonic*1000),counterFrequency=1000) + raw = json.dumps(value,sort_keys=True).encode() + return {'value':value,'sha256':hashlib.sha256(raw).hexdigest(),'bytes':len(raw),'rawBase64':base64.b64encode(raw).decode()} + + def proof(self): + return b.interval_proof(self.expected,self.initial,self.observed,self.upper,self.final,self.native) + + def rehash(self, marker): + raw=json.dumps(marker['value'],sort_keys=True).encode() + marker.update(sha256=b.digest(raw),bytes=len(raw),rawBase64=base64.b64encode(raw).decode()) + + def test_real_interval_requires_host_bounds_and_native_monotonic_duration(self): + self.assertTrue(self.proof()['hostClockIntervalProven']) + self.upper['wall']=108.1 + with self.assertRaisesRegex(ValueError,'inside'):self.proof() + + def test_container_elapsed_or_busy_flag_cannot_replace_interval(self): + self.upper.update(wall=109,elapsed=.01,busy=True) + with self.assertRaises(ValueError):self.proof() + + def test_post_exit_clock_step_cannot_make_a_late_status_pass(self): + # Old UTC-only proof accepted this: status ended after native exit, + # then host UTC stepped backwards, while native entry/exit stayed sane. + self.upper.update(wall=105.3,counterTicks=508300) + self.final.update(wall=109,counterTicks=512000) + with self.assertRaisesRegex(ValueError,'QPC'):self.proof() + + def test_missing_mismatched_and_one_tick_counter_boundaries_refuse(self): + self.upper['counterFrequency']=1001 + with self.assertRaisesRegex(ValueError,'frequencies'):self.proof() + self.upper['counterFrequency']=1000 + self.upper['counterTicks']=self.exit['value']['counterTicks']-1 + with self.assertRaisesRegex(ValueError,'QPC'):self.proof() + self.upper['counterTicks']=503000;self.upper['highResolution']=False + with self.assertRaisesRegex(ValueError,'high-resolution'):self.proof() + def test_clock_jump_and_backward_host_observation_are_refused(self): + self.exit['value']['wall']=110 + self.rehash(self.exit) + self.final['wall']=111 + with self.assertRaisesRegex(ValueError,'continuity'):self.proof() + self.exit['value']['wall']=108.1 + self.rehash(self.exit) + self.upper['wall']=100.5 + with self.assertRaisesRegex(ValueError,'inside'):self.proof() + + def test_nonfinite_boolean_and_short_duration_clocks_are_refused(self): + for bad in (float('nan'),float('inf'),True): + with self.subTest(bad=bad): + old=self.upper['wall'];self.upper['wall']=bad + with self.assertRaises(ValueError):self.proof() + self.upper['wall']=old + self.exit['value']['monotonic']=507 + self.rehash(self.exit) + with self.assertRaises(ValueError):self.proof() + + def test_process_restart_session_nonce_and_native_identity_are_refused(self): + self.upper['processCreatedUtc']='different' + with self.assertRaisesRegex(ValueError,'process'):self.proof() + self.upper['processCreatedUtc']=self.initial['processCreatedUtc'] + for key,value in (('sessionId','other'),('nonce','b'*32),('pid',True),('original',{**self.expected['original'],'saved':0})): + old=self.entry['value'][key];self.entry['value'][key]=value + with self.subTest(key=key), self.assertRaises(ValueError):self.proof() + self.entry['value'][key]=old + self.native['sessionId']='wrong' + with self.assertRaisesRegex(ValueError,'normally'):self.proof() + + def test_marker_bytes_must_match_earlier_read_and_native_completion(self): + self.observed=copy.deepcopy(self.observed) + self.observed['markers']['entered']['sha256']='b'*64 + with self.assertRaises(ValueError):self.proof() + self.observed['markers']['entered']=self.entry + self.native=copy.deepcopy(self.native);self.native['exited']['bytes']+=1 + with self.assertRaises(ValueError):self.proof() + + def test_one_adjacent_transport_clock_float_uses_exact_marker_bytes(self): + self.entry['value']['wall']=100.0;self.rehash(self.entry) + self.native=copy.deepcopy(self.native) + self.native['entered']['value']['wall']=math.nextafter(100.0,math.inf) + proof=self.proof() + self.assertEqual(proof['transportClockRoundTrips'][0]['field'],'wall') + self.assertEqual(proof['transportClockRoundTrips'][0]['byteValue'],100.0) + self.assertTrue(proof['hostClockIntervalProven']) + + def test_transport_clock_allowance_cannot_change_qpc_identity_types_or_bytes(self): + for field,changed in (('wall',100.01),('counterTicks',500001),('pid',124),('monotonic','500')): + candidate=copy.deepcopy(self.entry);candidate['value'][field]=changed + with self.subTest(field=field),self.assertRaises(ValueError):b.marker_bytes(candidate,'fault') + candidate=copy.deepcopy(self.entry);candidate['value']['wall']=100.0 + with self.assertRaises(ValueError):b.marker_bytes(candidate,'type changed from integer') + candidate=copy.deepcopy(self.entry);candidate['rawBase64']=base64.b64encode(b'{}').decode() + with self.assertRaises(ValueError):b.marker_bytes(candidate,'wrong bytes') + + def test_status_comparison_preserves_json_types_and_terminal_error_fields(self): + first=b.canonical({'success':True,'result':{'value':1,'error':''}}) + b.same_status(first,first,first) + for change in ({'success':True,'result':{'value':1.0,'error':''}}, + {'success':True,'result':{'value':True,'error':''}}, + {'success':False,'result':{'value':1,'error':'failure'}}): + with self.assertRaises(ValueError):b.same_status(first,b.canonical(change),first) + + def test_owned_path_guard_rejects_unc_traversal_wrong_run_and_injected_nonce(self): + self.assertTrue(b.marker_paths(self.expected['scratch'],self.expected['runId'],self.expected['nonce'])['entered'].endswith('entered.json')) + for path in ('relative','C:/Temp/../owned-run-12345','//server/share/owned-run-12345','C:/Temp/other'): + with self.subTest(path=path),self.assertRaises(ValueError):b.marker_paths(path,self.expected['runId'],self.expected['nonce']) + with self.assertRaises(ValueError):b.marker_paths(self.expected['scratch'],self.expected['runId'],'../foreign') + + def test_generated_hold_has_one_sleep_no_events_and_exclusive_writable_markers(self): + source=b.native_program(lambda *args,**kwargs:'\nINVENTORY_SENTINEL=True\n',self.expected,True) + tree=ast.parse(source) + calls=[node for node in ast.walk(tree) if isinstance(node,ast.Call)] + sleeps=[node for node in calls if isinstance(node.func,ast.Attribute) and node.func.attr=='sleep'] + self.assertEqual(len(sleeps),1);self.assertEqual(sleeps[0].args[0].value,8) + self.assertIn("open(pending, 'xb')",source) + self.assertIn('stream.flush(); os.fsync',source) + self.assertLess(source.index('os.fsync(stream.fileno())'),source.index('os.link(pending, PATHS[kind])')) + self.assertLess(source.index('current_thread()'),source.index('INVENTORY_SENTINEL')) + for denied in ('doEvents','computeAll','activate','deleteMe','close','startLocalRender','fireCustomEvent'): + self.assertFalse(any(isinstance(node.func,ast.Attribute) and node.func.attr==denied for node in calls)) + + def test_worker_guard_precedes_even_inherited_inventory_execution(self): + errors=[] + source=b.native_program(lambda *args,**kwargs:"\nraise AssertionError('inventory executed')\n",self.expected) + def worker(): + try:exec(source,{}) + except Exception as error:errors.append(error) + thread=threading.Thread(target=worker);thread.start();thread.join(1) + self.assertFalse(thread.is_alive());self.assertEqual(len(errors),1) + self.assertIsInstance(errors[0],RuntimeError);self.assertIn('main thread',str(errors[0])) + + def test_host_read_is_bounded_and_contains_no_writes_or_process_lifecycle(self): + script=b.host_script(self.expected,'entered') + self.assertIn('TotalSeconds -ge 5',script) + self.assertIn('Length -gt 4096',script) + self.assertIn('ReparsePoint',script) + self.assertIn('Stopwatch]::GetTimestamp()',script) + self.assertIn('Stopwatch]::IsHighResolution',script) + for bad in ('WriteAll','Set-Content','Remove-Item','Stop-Process','Start-Process','Invoke-RestMethod'): + self.assertNotIn(bad,script) + + def test_plan_default_cannot_load_harness_or_make_host_calls(self): + with patch.object(b,'load_harness',side_effect=AssertionError('loaded')),contextlib.redirect_stdout(io.StringIO()) as output: + self.assertEqual(b.main([]),0) + self.assertFalse(json.loads(output.getvalue())['hostCalls']) + + def test_offline_attempt_binds_exact_wire_args_stdout_and_clock_types(self): + row={'returncode':0,'args':{'value':1},'requestJson':'{"value":1}', + 'raw':{'success':True},'stdout':'{"success":true}','startedAt':1.0,'finishedAt':2.0} + adjudicator.bind_attempt(row) + for key,value in (('args',{'value':1.0}),('raw',{'success':False}),('returncode',False), + ('startedAt',float('nan')),('finishedAt',.5)): + with self.subTest(key=key),self.assertRaises(ValueError):adjudicator.bind_attempt({**row,key:value}) + + def test_offline_attempt_order_brackets_the_status_with_host_reads(self): + calls=[{'startedAt':1+i*3,'finishedAt':2+i*3} for i in range(10)] + hold={'startedAt':14.5,'finishedAt':24.5} + adjudicator.bind_sequence(calls,hold) + wrong=copy.deepcopy(calls);wrong[6]['startedAt']=16 + with self.assertRaises(ValueError):adjudicator.bind_sequence(wrong,hold) + for changed in ({**hold,'startedAt':13},{**hold,'finishedAt':26}): + with self.assertRaises(ValueError):adjudicator.bind_sequence(calls,changed) + + def test_cleanup_receipt_binds_exact_document_despite_stale_metadata(self): + label='close owned document id' + owner={'documents':{'id':{'metadata':{'closed':False}}},'cleanupLatest':{label:{'label':label, + 'status':'PASS','errors':[],'raw':{'success':True,'closed':True,'closedDocumentId':'id'}}}} + b.closed_documents(owner) + for changes in ({'closed':False},{'closedDocumentId':'foreign'},{'success':False}): + bad=copy.deepcopy(owner);bad['cleanupLatest'][label]['raw'].update(changes) + with self.subTest(changes=changes),self.assertRaises(ValueError):b.closed_documents(bad) + bad=copy.deepcopy(owner);bad['cleanupLatest'][label]['status']='FAIL' + with self.assertRaises(ValueError):b.closed_documents(bad) + owner['cleanupLatest']={} + with self.assertRaises(ValueError):b.closed_documents(owner) + + def test_prerequisites_bind_cleanup_run_and_harness_hashes(self): + with tempfile.TemporaryDirectory() as folder: + root=Path(folder);snap=root/'harness';snap.mkdir() + rows=[] + for name in ('ralph_jobs.py','ralph_mechanical.py','ralph_support.py','fusion_harness.py'): + path=snap/name;path.write_text('# frozen\n');rows.append({'name':name,'path':str(path),'sha256':b.digest(path.read_bytes())}) + report={'native':'PASS','cleanup':'PASS','acceptanceScope':{'ordinary':'PASS'},'fixtures':[{'status':'PASS'}], + 'checks':[{'status':'PASS'}],'target':'target','host':'HOST','runId':self.expected['runId'],'sessionId':'session-A', + 'baseline':{'native':{'documents':[self.expected['original']],'activeCreationId':'original'}},'harnessSources':rows} + owner={'target':'target','host':'HOST','runId':self.expected['runId'],'sessionId':'session-A','state':'complete', + 'documents':{'id':{'metadata':{'closed':False}}},'hostScratch':self.expected['scratch'], + 'cleanupLatest':{'close owned document id':{'label':'close owned document id','status':'PASS','errors':[], + 'raw':{'success':True,'closed':True,'closedDocumentId':'id'}}}} + def write(): + (root/'report.json').write_text(json.dumps(report));(root/'ownership.json').write_text(json.dumps(owner)) + write();b.prerequisites(root,'target','HOST') + for key,value in (('state','cleanup_failed'),('sessionId','wrong'),('pendingRegistrations',{'pending':1})): + old=owner.get(key);owner[key]=value;write() + with self.subTest(key=key),self.assertRaises(ValueError):b.prerequisites(root,'target','HOST') + owner[key]=old + write();(snap/'fusion_harness.py').write_text('# changed\n') + with self.assertRaisesRegex(ValueError,'hash'):b.prerequisites(root,'target','HOST') + + def test_orchestrator_runs_one_hold_and_retains_it_when_status_fails(self): + for fail_during in (False,True): + with self.subTest(fail=fail_during),tempfile.TemporaryDirectory() as folder: + root=Path(folder);calls=[];statuses=[] + expected=self.expected + report={'runId':expected['runId'],'sessionId':expected['sessionId'], + 'baseline':{'native':{'documents':[expected['original']]},'addin':{'version':'dev.8'}}} + owner={'hostScratch':expected['scratch']} + request={'sessionId':expected['sessionId'],'operationId':'operation','target':{'documentId':'closed'}} + host_rows={'initial':self.initial,'entered':self.observed,'clock':self.upper,'final':self.final} + native=self.native + class Client: + def __init__(self,*args,**kwargs):pass + def call(self,verb,args,**kwargs): + calls.append((verb,args)) + value={'success':True} + if verb=='run_script': + script=base64.b64decode(args['scriptB64']).decode() + encoded=script.split("FromBase64String('")[1].split("')")[0] + mode=json.loads(base64.b64decode(encoded))['mode'] + value.update(exitCode=0,timedOut=False,stdout=json.dumps(host_rows[mode])) + elif verb=='fusion_state':value.update(running=True,addinResponding=True) + elif verb=='fusion_document_info':value['data']={'openDocuments':[{}]} + elif verb=='fusion_run_modeling_script':value['data']={'result':native} + elif verb=='fusion_mech_operation_status': + statuses.append(1) + if fail_during and len(statuses)==2:value={'success':False,'error':'injected status failure'} + return NS(raw=value,observation=NS(payload=value),timed_out=False) + modules={'fusion_harness':NS(BridgeClient=Client,evaluate=lambda raw,*_:[] if raw.get('success') else ['failed']), + 'ralph_jobs':NS(terminal_contract=lambda *args:[],native_envelope=lambda x:x,native_signature=b.canonical), + 'ralph_support':NS(native_script=lambda *args,**kwargs:'\nINVENTORY=True\n')} + with (patch.object(b,'prerequisites',return_value=(report,owner,root)), + patch.object(b,'depth_receipt',return_value=(request,{'success':True})), + patch.object(b,'load_harness',return_value=modules), + patch.object(b.uuid,'uuid4',return_value=NS(hex=expected['nonce'])), + contextlib.redirect_stdout(io.StringIO())): + code=b.main(['run','--ordinary-evidence',str(root),'--target','target','--host-name','HOST', + '--evidence',str(root/'out'),'--fusion-pid','123']) + self.assertEqual(code,1 if fail_during else 0) + result=json.loads((root/'out/report.json').read_text()) + self.assertTrue(result['holdResponse']['success']) + self.assertIn('final',result['observations']) + holds=[args for verb,args in calls if verb=='fusion_run_modeling_script' and 'time.sleep(8)' in args['script']] + self.assertEqual(len(holds),1) + self.assertEqual(set(holds[0]),{'script'}) + self.assertEqual(len(statuses),2 if fail_during else 3) + + +if __name__=='__main__':unittest.main() diff --git a/tests/job_acceptance/history/CONTRACT.md b/tests/job_acceptance/history/CONTRACT.md new file mode 100644 index 0000000..d7d367b --- /dev/null +++ b/tests/job_acceptance/history/CONTRACT.md @@ -0,0 +1,43 @@ +# CP8 native history / busy-status acceptance — independent offline contract + +Scope: root owns host calls. This package is offline, imports no host client or product implementation, and creates no CAD document or mutation. It is deliberately separate from frozen product/ordinary harness files. This r2 checker corrects only the successful CLI duplicate envelope observed on CP8 r5. The ordinary runner correction is 4322d735104fb82f722fe03a1a806f6cc64fb264541ef22968111df14150a56f. Original checker source remains in the sibling cp8-history-contract directory. + +## Historical terminal records after restart + +1. Finish ordinary acceptance and exact owned cleanup. Preserve raw requests/replies and the final ordinary report. Visual judgment remains separate. The snapshot command requires native PASS, cleanup PASS, acceptanceScope.ordinary PASS, and no failed checks; it does not claim visual PASS. +2. Run `python3 history_acceptance.py snapshot --ordinary ORDINARY_DIR --output NEW_SNAPSHOT_JSON`. +3. Snapshot selects exactly the original create/depth/abort operations by the original run ID. It reads immutable requestJson, binds args and target, checks the original current-session terminal records, phase order/timestamps, request digest, original target and observed native provenance, initial/stored result and durability equality, then hashes its source evidence. Repeated original status replies must be identical. Original creation, depth edit and deliberately failed atomic batch are separate required records. +4. Root performs the authorized controlled restart only after ordinary cleanup and known original pristine inventory. Separately retain installed package hashes, process PID+creation identity, bridge health versions, add-in version/session, and before/after document evidence. A new add-in session alone proves reload, not a Fusion process restart; the offline checker reports that limitation. +5. Once the new session is ready, make one read-only `fusion_mech_capabilities` call and save the complete BridgeClient attempt JSON. Then make only the three `fusion_mech_operation_status` calls from snapshot.queries. Never substitute the new session ID in those queries; never attach an old target or replay the old mutation. The old requested/observed document IDs are historical provenance, not usable new-session selectors. +6. `python3 history_acceptance.py verify --snapshot SNAPSHOT --capabilities CAPABILITIES_CALL --status CREATE_STATUS_CALL --status DEPTH_STATUS_CALL --status ABORT_STATUS_CALL --output NEW_ACCEPTANCE_JSON`. +7. Required historical response equals the original whole semantic status except currentSessionId=newly attested session and historical=true. Original sessionId, operationId, result/durability.recordedSessionId, history sourceVersion/storageVersion/requestDigest/requestedTarget/observedDocument/events/timestamps/phase, outcome=recorded and reconciliationRequired=false remain exact. The failed batch must remain a failed result with verified recovery; outer status.success=true is only successful retrieval. No phase/event append is allowed. Original evidence hashes must still match. +8. Root separately checks original inventory remains pristine and no lifecycle action or unintended process replacement occurred during these status reads. Do not turn missing records, unknown, interrupted or endpoint unavailability into PASS, and do not retry mutations to reconstruct records. + +The check establishes durable *terminal* history across the verified session/process boundary. It says nothing about interrupted native execution, cold-launch behavior, disk failures, process crash recovery, postrestart mutation replay, or usability of old document references. + +## Real native busy hold + +Precondition: ordinary records exist and task cleanup is complete. Keep the exact current session and pristine original native object. Root must own exclusive native validation during this finite observation. Use an already terminal create/depth/abort status, not an intentionally destructive or long mutation. + +- Dispatch one modeling script whose exact source has been captured. On the native callback, read/verify session, expected original creation identity, existing active document and pristine state. Confirm no active command/transaction, no retained fixture or outstanding operation. +- Allocate unique task evidence marker paths before dispatch; no overwrite. Inside the actual callback, persist an entered marker containing nonce, thread identity, native session, exact document creation ID, wall time and monotonic time. That marker proves the callback entered. A /status busy flag by itself only proves HTTP dispatched/queued a CustomEvent. +- Call `time.sleep(8)` exactly once. It blocks main-thread event dispatch while releasing the Python GIL. Do not call adsk.doEvents, compute, activate, parameters, transaction APIs, or CAD setters. Do not simulate a native kernel/GIL wedge. +- After waking, reread exact original identity/pristine flags and persist an exited marker with matching nonce and wall/monotonic timestamps. The script ends normally, with no close or retry. Use an outer timeout comfortably above 8 seconds so the test does not manufacture transport ambiguity. +- After entered is observed, issue public `fusion_mech_operation_status` for an original terminal record. A successful response must match the previous semantic status completely (historical stays false if session unchanged), including result/history and event timestamps. +- Prove response completion precedes the native hold exit, using timestamps captured on the **same host clock** or a rigorously bounded/common-clock interval. Do not compare unsynchronized container time directly against host time. Prefer host-side monotonic timestamps for observation/request completion, and the native entered/exited monotonic clock. Expose the raw timings and margin. Requiring only "elapsed under 8 seconds" without proving the call began after native entry can falsely pass a pre-hold lookup. +- Record the add-in /status request ID/command as supporting evidence before and after the public read; main-thread lock/command should still identify the hold. Those flags alone do not replace the native entry/exit interval proof. +- Require a normal hold completion and unchanged session/process/original document before/after. No restart, repair, dialog dismissal, launch, retries, or active document changes. If the status completes after the hold, mark the busy-route gate FAIL or NOT_PROVED; do not lengthen the hold indefinitely. + +This establishes ledger readability while a real native callback occupies the main thread but releases the GIL. It does not establish progress reporting for a currently executing mechanical mutation or HTTP survival during a GIL-holding native kernel call. + +## Independent offline verification + +`python3 -m unittest discover -s . -p 'test_history_acceptance.py' -v` from this directory. + +The original 16-case suite is retained: valid three-record historical read; identity/digest/phase/timestamp/JSON-type drift; same session; missing historical flag; duplicate operation; mutation verb; status preceding attestation; unknown outcome; contradictory duplicate CLI output; modified source evidence; failure relabeled success. No full product suite was run. The r2 suite passes 38 cases. The utility intentionally fails closed on unfamiliar transport shapes; inspect an actual reply before adding a narrowly justified envelope accommodation. + +## r2 envelope correction + +Only an exact outer `error:""` is removed when an explicit parsed `output` object and its enclosing object both have `success:true`, and that native duplicate has no `error` key. Native empty errors are preserved. No output means no exemption. Nonempty errors, null/Boolean/object/number error values, native error/code/result fields, and every JSON type still compare exactly. Failed receipts gain no exemption; a failed result inside a successfully retrieved status remains part of the full historical comparison. No path, timestamp, result, or numeric normalization is added. + +Source-evidence hashing, request digest binding, immutable historical comparison, failure semantics and CLI dispatch-free behavior are unchanged. Tests repeat the full 16-case historical fault matrix under the newly observed successful-envelope form, plus narrow empty-error boundaries and changed-source hashing. Native acceptance remains root-owned. diff --git a/tests/job_acceptance/history/history_acceptance.py b/tests/job_acceptance/history/history_acceptance.py new file mode 100644 index 0000000..55c9f6a --- /dev/null +++ b/tests/job_acceptance/history/history_acceptance.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Offline-only CP8 terminal history acceptance; imports no host/client/product code.""" +import argparse,copy,hashlib,json,math +from pathlib import Path + +ANNOTATIONS={'durationMs','status','schemaVersion','shell','_contribute','_hint','_hintDocumentHygiene','_timeoutHint','documentHygiene','statusVerb','timeoutSeconds','windowEtiquette','_timing'} + +def need(value, message): + if not value: raise ValueError(message) + +def canon(value):return json.dumps(value,sort_keys=True,separators=(',',':'),allow_nan=False) +def digest(value):return hashlib.sha256(canon({k:v for k,v in value.items() if k not in ('caller','_callerBudgetMs')}).encode()).hexdigest() +def filehash(path):return hashlib.sha256(Path(path).read_bytes()).hexdigest() +def load(path): + raw=Path(path).read_bytes();need(len(raw)<=16*1024*1024,'Evidence file exceeds budget') + def pairs(rows): + out={} + for key,value in rows: + need(key not in out,'Duplicate JSON key: '+key);out[key]=value + return out + return json.loads(raw,object_pairs_hook=pairs,parse_constant=lambda x:(_ for _ in ()).throw(ValueError('Nonfinite JSON'))) + +def native(value,depth=0): + need(depth<12 and isinstance(value,dict),'Malformed/deep transport envelope') + need(value.get('status') not in ('error','failed') or value.get('success') is False,'Contradictory transport failure') + need(value.get('reconciliationRequired') is not True and value.get('outcome') not in ('pending','unknown'),'Unresolved observation') + output=value.get('output') + if not isinstance(value.get('success'),bool): + need(value.get('status')=='ok' and output not in (None,''),'Missing native outcome') + return native(json.loads(output) if isinstance(output,str) else output,depth+1) + stripped={k:v for k,v in value.items() if k not in ANNOTATIONS and k not in ('output','replayed')} + if output not in (None,''): + raw_nested=json.loads(output) if isinstance(output,str) else output + nested=native(raw_nested,depth+1) + # A successful CLI envelope adds error:'' while its explicit native + # duplicate lacks that key. Preserve all native errors and JSON types. + if (isinstance(raw_nested,dict) and value.get('success') is True + and raw_nested.get('success') is True and stripped.get('error')=='' + and 'error' not in raw_nested): + stripped.pop('error') + need(canon(nested)==canon(stripped),'Conflicting duplicate CLI output') + return stripped + +def attempt(path): + record=load(path) + need(record.get('returncode')==0 and not isinstance(record.get('returncode'),bool),'CLI attempt did not finish successfully') + need(isinstance(record.get('requestJson'),str),'Immutable requestJson missing') + args=json.loads(record['requestJson']);need(canon(args)==canon(record.get('args')),'Request args conflict with immutable requestJson') + for field in ('startedAt','finishedAt'): + need(type(record.get(field)) in (int,float) and math.isfinite(record[field]),'Call timestamp missing') + need(record['startedAt']<=record['finishedAt'],'Call timestamps reversed') + raw=record.get('raw');need(isinstance(raw,dict),'Raw response absent') + if record.get('stdout'): + need(canon(json.loads(record['stdout']))==canon(raw),'Raw response differs from stdout') + return record,args,native(raw) + +def validate_terminal(request,response,status,source,old_session): + sid,oid=request['sessionId'],request['operationId'];need(sid==old_session,'Wrong original session') + phase='succeeded' if response['success'] is True else 'failed' + if phase=='failed':need(response.get('errorCode')=='mutation_failed' and response.get('causeCode')=='invalid_expression' and response.get('recovery',{}).get('verified') is True,'Unexpected original failure') + else:need(not response.get('errorCode') and not response.get('error'),'Success contains native error') + need(status.get('success') is True and status.get('sessionId')==sid and status.get('operationId')==oid,'Status identity differs') + need(status.get('currentSessionId')==sid and status.get('historical') is False,'Original status is not current-session') + need(status.get('state')==phase and status.get('phase')==phase and status.get('outcome')=='recorded' and status.get('reconciliationRequired') is False,'Original status not durably terminal') + history=status.get('history',{}) + need(history.get('sessionId')==sid and history.get('operationId')==oid and history.get('operation')==request['op'],'History identity differs') + need(history.get('phase')==phase and history.get('sourceVersion')==source and type(history.get('storageVersion')) is int and history['storageVersion']==1,'History provenance differs') + need(history.get('requestDigest')==digest(request),'Digest differs') + need(canon(history.get('requestedTarget'))==canon(request.get('target')),'Original target changed') + observed=history.get('observedDocument',{}) + need(isinstance(observed.get('documentId'),str) and observed['documentId'].startswith(sid+':') and 'creationId' in observed and 'dataFileId' in observed,'Observed document identity missing') + if request['op']!='new_design':need(observed['documentId']==request['target']['documentId'],'Observed document is not original target') + events=history.get('events',[]) + need([e.get('phase') for e in events]==['admitted','dispatched','executing',phase],'Phase sequence differs') + times=[history.get('createdAt')]+[e.get('at') for e in events]+[history.get('updatedAt')] + need(all(type(t) in (int,float) and math.isfinite(t) and t>0 for t in times) and times==sorted(times),'Phase timestamps invalid') + for item in (response,status.get('result',{})): + need(item.get('sessionId')==sid and item.get('operationId')==oid,'Terminal result identity differs') + d=item.get('durability',{}) + need(d.get('terminalRecorded') is True and d.get('recordedSessionId')==sid and type(d.get('storageVersion')) is int and d['storageVersion']==1 and d.get('phase')==phase,'Terminal durability differs') + need(item.get('nativeExecutionStarted') is None or item['nativeExecutionStarted'] is True,'Executed terminal contradicts execution') + need(status.get('nativeExecutionStarted') is None or status['nativeExecutionStarted'] is True,'Status contradicts execution') + need(canon(native(response))==canon(native(status['result'])),'Initial and stored results differ') + need(status.get('observedResult') is None and status.get('persistenceFailure') is None,'Terminal status carries unresolved persistence') + +def snapshot(directory): + directory=Path(directory);report=load(directory/'report.json') + need(report.get('native')=='PASS' and report.get('cleanup')=='PASS' and report.get('acceptanceScope',{}).get('ordinary')=='PASS','Ordinary native/cleanup gate has not passed') + need(report.get('checks') and all(r.get('status')=='PASS' for r in report['checks']),'Ordinary report has failing/empty checks') + sid=report['sessionId'];rid=report['runId'];source=report['baseline']['addin']['version'];target=report['target'] + entries=[];hashes={'report.json':filehash(directory/'report.json')} + allcalls=[] + for path in sorted((directory/'calls').glob('*.json')): + raw=load(path) + if raw.get('verb') not in ('fusion_mechanical','fusion_mech_operation_status'):continue + args=json.loads(raw.get('requestJson','{}')) + if args.get('operationId') not in (rid+'-create',rid+'-depth',rid+'-abort'):continue + record,args,response=attempt(path);need(record.get('target')==target,'Ordinary call targets another host') + allcalls.append((path,record,args,response)) + for label,op in [('create','new_design'),('depth','parameter_set'),('abort','parameter_batch')]: + oid=rid+'-'+label + mutations=[row for row in allcalls if row[1]['verb']=='fusion_mechanical' and row[2].get('operationId')==oid] + statuses=[row for row in allcalls if row[1]['verb']=='fusion_mech_operation_status' and row[2].get('operationId')==oid] + need(mutations and statuses,'Original operation/status evidence missing: '+oid) + first=mutations[0];request,response=first[2],first[3];need(request.get('op')==op,'Unexpected original operation') + prior=statuses[0][3];validate_terminal(request,response,prior,source,sid) + for path,record,args,status in statuses: + need(canon(args)==canon({'sessionId':sid,'operationId':oid}),'Original status query has extra/rebound selectors') + validate_terminal(request,response,status,source,sid);need(canon(status)==canon(prior),'Original immutable status changed') + hashes[str(path.relative_to(directory))]=filehash(path) + hashes[str(first[0].relative_to(directory))]=filehash(first[0]) + entries.append({'label':label,'request':request,'response':response,'status':prior,'query':{'sessionId':sid,'operationId':oid}}) + return {'scope':'terminal history across a newly attested add-in session; no host calls','ordinaryDirectory':str(directory.resolve()),'target':target,'oldSessionId':sid,'sourceVersion':source,'evidenceSha256':hashes,'operations':entries,'queries':[e['query'] for e in entries],'notProved':['process crash recovery','in-flight interruption','native replay after restart','current model/target usability']} + +def verify(plan,capability_path,status_paths): + base=Path(plan['ordinaryDirectory']) + for rel,expected in plan['evidenceSha256'].items():need(filehash(base/rel)==expected,'Original evidence changed: '+rel) + cap,args,current=attempt(capability_path) + need(cap['verb']=='fusion_mech_capabilities' and cap['target']==plan['target'] and current.get('success') is True,'Current session attestation invalid') + new=current.get('sessionId');need(isinstance(new,str) and new and new!=plan['oldSessionId'],'Add-in session did not change') + need(len(status_paths)==len(plan['operations'])==3,'Require exactly three historical status calls') + expected={e['query']['operationId']:e for e in plan['operations']};seen=set();evidence=[] + for path in status_paths: + record,args,status=attempt(path) + need(record['verb']=='fusion_mech_operation_status' and record['target']==plan['target'],'Postrestart input is not an observational status call') + oid=args.get('operationId');need(oid in expected and oid not in seen,'Unexpected/duplicate historical operation') + need(canon(args)==canon(expected[oid]['query']),'Historical query was rebound or gained selectors') + want=copy.deepcopy(expected[oid]['status']);want.update(currentSessionId=new,historical=True) + need(canon(status)==canon(want),'Historical result/history changed beyond session presentation: '+oid) + need(record['startedAt']>=cap['finishedAt'],'Status predates new-session attestation') + seen.add(oid);evidence.append({'path':str(Path(path).resolve()),'sha256':filehash(path),'operationId':oid}) + return {'status':'PASS','scope':'three exact terminal records survived an add-in session change','oldSessionId':plan['oldSessionId'],'currentSessionId':new,'records':evidence,'capabilitiesSha256':filehash(capability_path),'nativeActionsByChecker':0,'limitations':plan['notProved']+['Application restart/process identity must be separately attested by root.']} + +def main(): + p=argparse.ArgumentParser(description=__doc__);sub=p.add_subparsers(dest='mode',required=True) + s=sub.add_parser('snapshot');s.add_argument('--ordinary',required=True);s.add_argument('--output',required=True) + v=sub.add_parser('verify');v.add_argument('--snapshot',required=True);v.add_argument('--capabilities',required=True);v.add_argument('--status',action='append',required=True);v.add_argument('--output',required=True) + a=p.parse_args();out=snapshot(a.ordinary) if a.mode=='snapshot' else verify(load(a.snapshot),a.capabilities,a.status) + with Path(a.output).open('x') as stream:json.dump(out,stream,indent=2,allow_nan=False);stream.write('\n') + print(json.dumps({'status':'PREPARED' if a.mode=='snapshot' else out['status'],'output':a.output,'hostCalls':False})) +if __name__=='__main__':main() diff --git a/tests/job_acceptance/history/manifest.json b/tests/job_acceptance/history/manifest.json new file mode 100644 index 0000000..75e67c7 --- /dev/null +++ b/tests/job_acceptance/history/manifest.json @@ -0,0 +1,42 @@ +{ + "scope": "Offline historical terminal checker only; source review and offline replay, no host actions", + "status": "SOURCE_FROZEN", + "createdAt": "2026-10-05T20:57:16.753978+00:00", + "parentDirectory": "/home/adom/project/research/fusion-roadmap-build-2026-10-02/cp8-history-contract", + "r1Hashes": { + "source": "/home/adom/project/research/fusion-roadmap-build-2026-10-02/cp8-history-contract", + "files": { + "history_acceptance.py": "b6b86e8b658841a6c11493ba2dd3fc3b5771b63da0b5d83bd2438b200ca9c4c9", + "test_history_acceptance.py": "c6ded6f09b24add8c7e31c52106957a96550c17e32888351c91f4f8611acfb91", + "CONTRACT.md": "7c1cb3299ce5dca03a71a1b1cbd0b07d6af181be7cee02b4a0b9e5be24ca372c" + } + }, + "files": { + "history_acceptance.py": { + "sha256": "3be02c0f7ee3986e1ade0cb4e920822ca3315983afcf0f6019c9098c54221b9d", + "bytes": 12226 + }, + "test_history_acceptance.py": { + "sha256": "108d4f43d5bccea7fbaafdd9364b1ff87489f4c8bbf658495cde3185dcab16db", + "bytes": 8725 + }, + "CONTRACT.md": { + "sha256": "304babc725f467d5fb11364f0bfa21d3468617e9ddb4f26ab0ff15f920e11e22", + "bytes": 8455 + }, + "offline-review.json": { + "sha256": "ee9dc04d767fdf04ac2f990640c9c7245b2aab1c08780e05cbc018f12935ddb3", + "bytes": 337 + } + }, + "validation": { + "focusedTests": 38, + "focusedResult": "PASS", + "actualArchivedCreateAndTerminalStatus": "PASS", + "originalFailedCampaignSnapshotRefused": "Ordinary native/cleanup gate has not passed", + "originalR1FilesUnchanged": true, + "hostCalls": 0, + "sourceDelta": "Only successful explicit duplicate output blank-error annotation accommodation in native(); other functions unchanged." + }, + "nativeAcceptance": "NOT_RUN; root owns host calls and must supply a fully passing ordinary report before snapshot" +} diff --git a/tests/job_acceptance/history/test_history_acceptance.py b/tests/job_acceptance/history/test_history_acceptance.py new file mode 100644 index 0000000..a30229b --- /dev/null +++ b/tests/job_acceptance/history/test_history_acceptance.py @@ -0,0 +1,108 @@ +import copy,json,tempfile,unittest +from pathlib import Path +import history_acceptance as h + +class Acceptance(unittest.TestCase): + def setUp(self): + self.t=tempfile.TemporaryDirectory();self.addCleanup(self.t.cleanup);self.p=Path(self.t.name);(self.p/'calls').mkdir();self.sid='old-session';self.rid='run-abcdefghijkl';self.paths=[] + report={'native':'PASS','cleanup':'PASS','acceptanceScope':{'ordinary':'PASS'},'checks':[{'status':'PASS'}],'sessionId':self.sid,'runId':self.rid,'baseline':{'addin':{'version':'dev8'}},'target':'host'} + (self.p/'report.json').write_text(json.dumps(report)) + for index,(label,op) in enumerate([('create','new_design'),('depth','parameter_set'),('abort','parameter_batch')]): + oid=self.rid+'-'+label;phase='failed' if label=='abort' else 'succeeded';req={'sessionId':self.sid,'operationId':oid,'op':op} + if label!='create':req['target']={'documentId':self.sid+':owned','revision':'before'} + response={'success':phase=='succeeded','sessionId':self.sid,'operationId':oid,'durability':{'terminalRecorded':True,'recordedSessionId':self.sid,'storageVersion':1,'phase':phase},'data':{'value':1}} + if phase=='failed':response.update(errorCode='mutation_failed',causeCode='invalid_expression',recovery={'verified':True}) + status={'success':True,'sessionId':self.sid,'operationId':oid,'currentSessionId':self.sid,'historical':False,'state':phase,'phase':phase,'outcome':'recorded','reconciliationRequired':False,'result':copy.deepcopy(response),'nativeExecutionStarted':None,'persistenceFailure':None,'observedResult':None,'history':{'sessionId':self.sid,'operationId':oid,'operation':op,'sourceVersion':'dev8','storageVersion':1,'phase':phase,'requestDigest':h.digest(req),'requestedTarget':req.get('target'),'observedDocument':{'documentId':self.sid+(':original' if label=='create' else ':owned'),'creationId':'creation','dataFileId':None},'events':[{'phase':p,'at':float(i+1)} for i,p in enumerate(['admitted','dispatched','executing',phase])],'createdAt':1.,'updatedAt':4.}} + self.write(self.p/'calls'/f'{index*2:04d}-fusion_mechanical.json','fusion_mechanical',req,response,1.) + self.write(self.p/'calls'/f'{index*2+1:04d}-fusion_mech_operation_status.json','fusion_mech_operation_status',{'sessionId':self.sid,'operationId':oid},status,2.) + status.update(currentSessionId='new-session',historical=True) + path=self.p/f'post-{index}.json';self.paths.append(path);self.write(path,'fusion_mech_operation_status',{'sessionId':self.sid,'operationId':oid},status,20.) + self.plan=h.snapshot(self.p);self.cap=self.p/'cap.json';self.write(self.cap,'fusion_mech_capabilities',{}, {'success':True,'sessionId':'new-session'},10.) + def write(self,path,verb,args,raw,t):path.write_text(json.dumps({'returncode':0,'requestJson':json.dumps(args),'args':args,'raw':raw,'target':'host','verb':verb,'startedAt':t,'finishedAt':t+1})) + def change(self,fn,path=None): + path=path or self.paths[0];x=json.loads(path.read_text());fn(x);path.write_text(json.dumps(x)) + def check(self):return h.verify(self.plan,self.cap,self.paths) + def test_valid_three_records(self):self.assertEqual(self.check()['status'],'PASS') + def test_terminal_failure_remains_failure(self): + self.change(lambda x:x['raw']['result'].update(success=True),self.paths[2]);self.assertRaises(ValueError,self.check) + def test_changed_result_identity(self): + self.change(lambda x:x['raw']['result'].update(operationId='other'));self.assertRaises(ValueError,self.check) + def test_rebound_query(self): + self.change(lambda x:x.update(args={'sessionId':'new-session','operationId':x['args']['operationId']},requestJson=json.dumps({'sessionId':'new-session','operationId':x['args']['operationId']})));self.assertRaises(ValueError,self.check) + def test_digest_drift(self): + self.change(lambda x:x['raw']['history'].update(requestDigest='0'*64));self.assertRaises(ValueError,self.check) + def test_phase_drift(self): + self.change(lambda x:x['raw']['history']['events'].append({'phase':'executing','at':9}));self.assertRaises(ValueError,self.check) + def test_timestamps_drift(self): + self.change(lambda x:x['raw']['history'].update(updatedAt=9));self.assertRaises(ValueError,self.check) + def test_numeric_types_preserved(self): + self.change(lambda x:x['raw']['result']['data'].update(value=True));self.assertRaises(ValueError,self.check) + def test_same_session_refuses(self): + self.change(lambda x:x['raw'].update(sessionId='old-session'),self.cap);self.assertRaises(ValueError,self.check) + def test_not_historical_refuses(self): + self.change(lambda x:x['raw'].update(historical=False));self.assertRaises(ValueError,self.check) + def test_duplicate_id_refuses(self): + self.paths[1]=self.paths[0];self.assertRaises(ValueError,self.check) + def test_mutation_verb_refuses(self): + self.change(lambda x:x.update(verb='fusion_mechanical'));self.assertRaises(ValueError,self.check) + def test_before_attestation_refuses(self): + self.change(lambda x:x.update(startedAt=1,finishedAt=2));self.assertRaises(ValueError,self.check) + def test_unknown_refuses(self): + self.change(lambda x:x['raw'].update(outcome='unknown'));self.assertRaises(ValueError,self.check) + def test_conflicting_output_refuses(self): + self.change(lambda x:x['raw'].update(output=json.dumps({'success':False,'errorCode':'timeout'})));self.assertRaises(ValueError,self.check) + def test_original_evidence_change_refuses(self): + self.change(lambda x:x['raw']['history'].update(updatedAt=8),self.p/'calls'/'0001-fusion_mech_operation_status.json');self.assertRaises(ValueError,self.check) + +class SuccessfulBlankEnvelope(unittest.TestCase): + def setUp(self):self.inner={'success':True,'sessionId':'s','operationId':'o','data':{'value':1}} + def envelope(self,inner=None): + value=copy.deepcopy(self.inner if inner is None else inner) + return {**value,'output':json.dumps(value),'error':''} + def test_exact_successful_duplicate_allows_only_outer_empty_error(self): + self.assertEqual(h.canon(h.native(self.envelope())),h.canon(self.inner)) + def test_native_empty_error_and_no_output_remain_part_of_identity(self): + native={**self.inner,'error':''} + self.assertEqual(h.native(self.envelope(native)),native) + self.assertNotEqual(h.canon(h.native(native)),h.canon(h.native(self.inner))) + def test_other_outer_error_types_and_meaningful_text_are_not_removed(self): + for error in (None,False,0,0.0,{},[],'failure'): + with self.subTest(error=error): + value=self.envelope();value['error']=error;self.assertRaises(ValueError,h.native,value) + def test_failed_receipt_and_conflicting_native_error_do_not_earn_exemption(self): + failed={'success':False,'errorCode':'mutation_failed','sessionId':'s','operationId':'o'} + self.assertRaises(ValueError,h.native,self.envelope(failed)) + value=self.envelope();nested={**self.inner,'error':'native failure'};value['output']=json.dumps(nested) + self.assertRaises(ValueError,h.native,value) + def test_payload_changes_and_numeric_types_are_still_exact(self): + for patch in ({'value':True},{'value':1.0},{'value':2},{'value':1,'error':'native diagnostic'}): + with self.subTest(patch=patch): + value=self.envelope();nested=copy.deepcopy(self.inner);nested['data']=patch;value['output']=json.dumps(nested) + self.assertRaises(ValueError,h.native,value) + +class AnnotatedHistoricalFlow(Acceptance): + # Exercise the same full source hashing and historical comparison with real CLI + # successful-envelope shape, including a failed result inside successful status. + def setUp(self): + super().setUp() + for path in list((self.p/'calls').glob('*.json'))+self.paths+[self.cap]: + row=json.loads(path.read_text());raw=row['raw'] + if raw.get('success') is True: + row['raw']={**raw,'output':json.dumps(raw),'error':''};row['stdout']=json.dumps(row['raw']) + path.write_text(json.dumps(row)) + self.plan=h.snapshot(self.p) + def change(self,fn,path=None): + path=path or self.paths[0];row=json.loads(path.read_text());old_output=row['raw'].get('output');fn(row) + # Keep deliberate semantic faults coherent across both CLI copies so the full + # history checks reject them; preserve explicitly injected duplicate conflicts. + raw=row['raw'] + if old_output is not None and raw.get('output')==old_output: + raw['output']=json.dumps({k:v for k,v in raw.items() if k not in ('output','error')}) + if 'stdout' in row:row['stdout']=json.dumps(raw) + path.write_text(json.dumps(row)) + def test_annotation_change_still_invalidates_original_source_hash(self): + path=self.p/'calls'/'0001-fusion_mech_operation_status.json' + row=json.loads(path.read_text());row['raw']['durationMs']=123;row['stdout']=json.dumps(row['raw']);path.write_text(json.dumps(row)) + self.assertRaises(ValueError,self.check) + +if __name__=='__main__':unittest.main() diff --git a/tests/ralph_assemblies.py b/tests/ralph_assemblies.py new file mode 100644 index 0000000..7727d20 --- /dev/null +++ b/tests/ralph_assemblies.py @@ -0,0 +1,857 @@ +#!/usr/bin/env python3 +"""Finite CP6 actual-app campaign. Plan/review are offline; run is explicit. + +Uses public mechanical operations for every fixture mutation. Independent native +reads extend the maintained exact-object ownership and F3D cleanup witness. +No lifecycle commands, foreground input, production readback imports or guessed +ownership. A missing native capability fails its group rather than passing it. +""" +import argparse +import hashlib +import json +import math +from pathlib import Path +import signal +import sys + +from ralph_everyday import Everyday, EXTRA_WITNESS as EVERYDAY_WITNESS +from ralph_mechanical import recover as shared_recover +from ralph_support import equivalent, native_script, visual_review, write_json + + +MOTION_TYPES = {"rigid": "RigidJointMotion", "revolute": "RevoluteJointMotion", + "slider": "SliderJointMotion", "cylindrical": "CylindricalJointMotion", + "pin_slot": "PinSlotJointMotion", "planar": "PlanarJointMotion", "ball": "BallJointMotion"} +GROUPS = tuple("regular_" + name for name in MOTION_TYPES) + ( + "placement_frames", "root_attachment", "nested_context", "origins", "redefinition", "links_two", "link_single", "asbuilt") + + +def multiply(a, b): + return [sum(a[r * 4 + k] * b[k * 4 + c] for k in range(4)) for r in range(4) for c in range(4)] + + +def inverse(m): + out = [m[c * 4 + r] if r < 3 and c < 3 else int(r == c == 3) for r in range(4) for c in range(4)] + for r in range(3): out[r * 4 + 3] = -sum(out[r * 4 + k] * m[k * 4 + 3] for k in range(3)) + return out + + +def point(m, p): + return [sum(m[r * 4 + c] * p[c] for c in range(3)) + m[r * 4 + 3] for r in range(3)] + + +def rotation_z(angle): + c, s = math.cos(math.radians(angle)), math.sin(math.radians(angle)) + return [c, -s, 0, 0, s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] + + +def rotation_x(angle): + c, s = math.cos(math.radians(angle)), math.sin(math.radians(angle)) + return [1,0,0,0,0,c,-s,0,0,s,c,0,0,0,0,1] + + +def rotation_delta(before, after): + relative = multiply(inverse(before), after) + return math.degrees(math.acos(max(-1, min(1, (relative[0] + relative[5] + relative[10] - 1) / 2)))) + + +def same_rotation(a, b): + return equivalent([a[r * 4 + c] for r in range(3) for c in range(3)], + [b[r * 4 + c] for r in range(3) for c in range(3)], tolerance=1e-6) + + +def mating_face_normals(moved, grounded): + """Known block bottom face must oppose the grounded block's top face.""" + if any(len(m) != 16 or not all(math.isfinite(x) for x in m) for m in (moved, grounded)): + return False + bottom = [-moved[i] for i in (2, 6, 10)] + top = [grounded[i] for i in (2, 6, 10)] + return (equivalent(sum(x*x for x in bottom), 1, tolerance=1e-8) and + equivalent(sum(x*x for x in top), 1, tolerance=1e-8) and + equivalent(sum(a*b for a,b in zip(bottom, top)), -1, tolerance=1e-8)) + + +def axis_parallel(a, b): + if not all(math.isfinite(x) for x in a + b): return False + na, nb = math.sqrt(sum(x*x for x in a)), math.sqrt(sum(x*x for x in b)) + return na > 1e-9 and nb > 1e-9 and abs(abs(sum(x*y for x, y in zip(a, b)) / na / nb) - 1) < 1e-6 + + +def signed_z_rotation(before, after): + """Signed world-Z angle for the known upright coupling fixtures only.""" + for matrix in (before, after): + if len(matrix) != 16 or not all(math.isfinite(v) for v in matrix): + raise RuntimeError("Invalid physical coupling matrix") + if not equivalent([matrix[i] for i in (2,6,8,9,10)], [0,0,0,0,1], tolerance=1e-6): + raise RuntimeError("Physical coupling oracle requires an upright world-Z fixture") + start, finish = [math.degrees(math.atan2(m[4], m[0])) for m in (before, after)] + return (finish - start + 180) % 360 - 180 + + +def coupled_step_matches(before_one, after_one, before_two, after_two, target, reversed): + """Known zero-origin 1:1 fixture, using the solver's actual current baseline.""" + direction = -1 if reversed else 1 + delta = after_one - before_one + return (abs(delta) > 1e-6 and equivalent(after_one, target) and + equivalent(after_two, direction * after_one) and + equivalent(after_two - before_two, direction * delta)) + + +def timeline_edit_preserved(before, after): + """An end-position edit may append one verified native position snapshot.""" + added = after.get("added", []) + delta = after["count"] - before["count"] + return (before["marker"] == before["count"] and before["pending"] is False and + after["marker"] == after["count"] and after["pending"] is False and + after.get("originalEntitiesPreserved") is True and delta in (0, 1) and len(added) == delta and + all(row.get("type") == "adsk::fusion::Snapshot" and row.get("nativeSnapshot") is True for row in added)) + + +def empty_position_refusal(result, before, after): + """Exact observed native-history limitation; unrelated health failures fail.""" + health = result.get("causeDetails", {}).get("health", {}) + faults = health.get("faults", []) + return (result.get("causeCode") == "unhealthy_model" and + result.get("recovery", {}).get("verified") is True and + health.get("complete") is True and health.get("computeSucceeded") is True and + health.get("healthy") is False and not health.get("unknown") and len(faults) == 1 and + faults[0].get("name") == "Position1" and faults[0].get("state") == 1 and + str(faults[0].get("index")) == str(before["count"] - 1) and + "Capture Position has no positions for current Components and Joints" in faults[0].get("message", "") and + before["types"][-1:] == ["adsk::fusion::Snapshot"] and + timeline_edit_preserved(before, after) and after["count"] == before["count"] and not after["added"]) + + +# This graph uses native API equality to assign stable inventory addresses, not +# session identity helpers, display-name links or token-string comparisons. +# Topology ordinals are evidence inside these small owned fixtures only. +ASSEMBLY_WITNESS = r''' +def assembly_cast(entity): + if entity is None: return None + cls = getattr(adsk.fusion, entity.objectType.split('::')[-1], None) + return cls.cast(entity) if cls else entity + +def assembly_matrix(matrix): + values = list(matrix.asArray()) + require(len(values) == 16 and all(math.isfinite(v) for v in values), 'Invalid independent native matrix') + for i in (3, 7, 11): values[i] *= 10 + return values + +def assembly_parameter(parameter, angular=False): + require(parameter is not None, 'Independent joint parameter is absent') + return {'expression': parameter.expression, 'value': parameter.value * (180 / math.pi if angular else 10)} + +def assembly_resolve(reference): + design = adsk.fusion.Design.cast(app.activeDocument.products.itemByProductType('DesignProductType')) + matches = [] + for value in design.findEntityByToken(reference['token']): + value = assembly_cast(value) + require(value is not None and value.isValid, 'Independent assembly reference invalid') + value = value.nativeObject if getattr(value, 'nativeObject', None) is not None else value + if not any(value == old for old in matches): matches.append(value) + require(len(matches) == 1, 'Independent assembly reference absent or ambiguous') + entity = matches[0] + if reference.get('occurrencePath'): + occurrences = [o for o in design.rootComponent.allOccurrences if o.fullPathName == reference['occurrencePath']] + require(len(occurrences) == 1, 'Independent assembly context absent or ambiguous') + require(entity.parentComponent == occurrences[0].component, 'Independent origin context has wrong component') + entity = entity.createForAssemblyContext(occurrences[0]) + return entity + +def assembly_timeline_entities(design): + snapshots = [design.snapshots.item(i) for i in range(design.snapshots.count)] + values = [] + for i in range(design.timeline.count): + timeline = design.timeline.item(i) + matched = [s for s in snapshots if s.timelineObject == timeline] + require(len(matched) <= 1, 'Independent native snapshot timeline identity is ambiguous') + entity = matched[0] if matched else assembly_cast(timeline.entity) + require(entity is not None and entity.isValid, 'Original timeline entity identity is unavailable') + values.append(entity) + return values, snapshots + +def assembly_timeline_registry(run, document_id): + # Opaque API objects belong outside public document rows. + return run.setdefault('assemblyTimelineProbes', {}).setdefault(document_id, {}) + +def assembly_timeline_before(design, registry): + entities, _ = assembly_timeline_entities(design) + saved = [] + for entity in entities: + saved.append({'object':entity,'type':entity.objectType,'token':getattr(entity,'entityToken',None)}) + registry['assemblyTimelineProbe'] = saved + return {'count':design.timeline.count,'marker':design.timeline.markerPosition, + 'pending':design.snapshots.hasPendingSnapshot,'types':[e.objectType for e in entities]} + +def assembly_timeline_after(design, registry): + require('assemblyTimelineProbe' in registry, 'No independent timeline baseline was captured') + saved = registry['assemblyTimelineProbe'] + entities, snapshots = assembly_timeline_entities(design) + preserved = len(entities) >= len(saved) + for old, current in zip(saved, entities): + candidates = [] + values = design.findEntityByToken(old['token']) if old['token'] else [old['object']] + for raw in values: + entity = assembly_cast(raw) + if entity is not None and entity.isValid and not any(entity == c for c in candidates):candidates.append(entity) + preserved = preserved and len(candidates) == 1 and candidates[0] == current and current.objectType == old['type'] + return {'count':design.timeline.count,'marker':design.timeline.markerPosition, + 'pending':design.snapshots.hasPendingSnapshot,'originalEntitiesPreserved':preserved, + 'added':[{'type':e.objectType,'nativeSnapshot':any(e == snapshot for snapshot in snapshots)} for e in entities[len(saved):]]} + +def assembly_graph(design): + root = design.rootComponent + comps = sorted(list(design.allComponents), key=lambda c: '' if c == root else c.name) + require(len(set('' if c == root else c.name for c in comps)) == len(comps), 'Fixture component names must be unique') + occs = sorted(list(root.allOccurrences), key=lambda o: o.fullPathName) + require(len(set(o.fullPathName for o in occs)) == len(occs), 'Fixture occurrence paths must be unique') + inventory = [] + def add(address, entity): inventory.append((address, assembly_cast(entity))) + for ci, comp in enumerate(comps): + add(['component', ci], comp) + for bi, body in enumerate(comp.bRepBodies): + add(['body', ci, bi], body) + for kind, values in (('face', body.faces), ('edge', body.edges), ('vertex', body.vertices)): + for i, entity in enumerate(values): add([kind, ci, bi, i], entity) + for si, sketch in enumerate(comp.sketches): + add(['sketch', ci, si], sketch) + for kind, values in (('curve', sketch.sketchCurves), ('point', sketch.sketchPoints)): + for i, entity in enumerate(values): add([kind, ci, si, i], entity) + for kind, values in (('axis', comp.constructionAxes), ('plane', comp.constructionPlanes), + ('origin', comp.jointOrigins), ('joint', comp.joints), ('asbuilt', comp.asBuiltJoints)): + for i, entity in enumerate(values): add([kind, ci, i], entity) + for key in ('xConstructionAxis', 'yConstructionAxis', 'zConstructionAxis', + 'xYConstructionPlane', 'xZConstructionPlane', 'yZConstructionPlane', 'originConstructionPoint'): + add(['base', ci, key], getattr(comp, key)) + if hasattr(comp, 'motionLinks'): + for i, entity in enumerate(comp.motionLinks): add(['link', ci, i], entity) + def link(entity): + if entity is None: return None + entity = assembly_cast(entity) + require(entity.isValid, 'Independent graph contains invalid entity') + if entity.objectType.endswith('::Occurrence'): + matches = [i for i, o in enumerate(occs) if o == entity] + require(len(matches) == 1, 'Independent occurrence relationship absent or ambiguous') + return {'node': ['occurrence', matches[0]], 'context': None} + context = getattr(entity, 'assemblyContext', None) + native = getattr(entity, 'nativeObject', None) or entity + matches = [address for address, candidate in inventory if candidate == native] + require(len(matches) == 1, 'Independent graph relationship absent or ambiguous: ' + entity.objectType) + contexts = [i for i, o in enumerate(occs) if o == context] if context is not None else [] + require(context is None or len(contexts) == 1, 'Independent graph occurrence context is ambiguous') + return {'node': matches[0], 'context': contexts[0] if contexts else None} + def geometry(raw, definition=False): + g = assembly_cast(raw) + require(g is not None and g.isValid, 'Independent attachment geometry is invalid') + if g.objectType.endswith('::JointOrigin'): return {'origin': link(g)} + require(g.objectType.endswith('::JointGeometry'), 'Unknown independent joint attachment') + def source(value): + value = assembly_cast(value) + return (getattr(value, 'nativeObject', None) or value) if definition and value is not None else value + result = {'kind': int(g.geometryType), 'keyPoint': int(g.keyPointType), + 'one': link(source(g.entityOne)), 'two': link(source(g.entityTwo)), + 'planeOne': link(source(g.planeOne)), 'planeTwo': link(source(g.planeTwo))} + if definition: + # Native JointOrigin.geometry can expose an incidental instance + # proxy. Its transient world frame is not the definition's frame. + # Capture concrete native source geometry plus origin.transform. + result['nativeSources'] = [everyday_geometry(source(e).geometry) if e is not None and hasattr(source(e),'geometry') else None + for e in (g.entityOne,g.entityTwo)] + else: + result.update(origin=vector(g.origin), axes=[[v.x,v.y,v.z] for v in + (g.secondaryAxisVector,g.thirdAxisVector,g.primaryAxisVector)]) + if g.geometryType in (adsk.fusion.JointGeometryTypes.JointTangentBRepFaceGeometry, + adsk.fusion.JointGeometryTypes.JointTangentBRepFaceEdgeGeometry): + result['tangent'] = [int(g.tangentFaceType), g.tangentFaceParamOne, g.tangentFaceParamTwo] + return result + def motion(raw): + m = assembly_cast(raw) + out = {'type': m.objectType.split('::')[-1]} + for name in ('rotation', 'slide', 'primarySlide', 'secondarySlide', 'pitch', 'roll', 'yaw'): + scale = 10 if 'lide' in name else 180 / math.pi + if hasattr(m, name + 'Value'): out[name + 'Value'] = getattr(m, name + 'Value') * scale + if hasattr(m, name + 'Limits'): + limits = getattr(m, name + 'Limits') + out[name + 'Limits'] = {k: getattr(limits, k + 'Value') * scale for k in ('minimum', 'maximum', 'rest')} + out[name + 'Limits'].update({k + 'Enabled': getattr(limits, 'is' + k.title() + 'ValueEnabled') for k in ('minimum', 'maximum', 'rest')}) + for axis in ('rotationAxis', 'slideDirection', 'normalDirection', 'primarySlideDirection', 'pitchDirection', 'yawDirection'): + if hasattr(m, axis): + out[axis] = int(getattr(m, axis)) + custom = 'custom' + axis[0].upper() + axis[1:] + 'Entity' + out[custom] = link(getattr(m, custom)) if getattr(m, axis) == adsk.fusion.JointDirections.CustomJointDirection else None + for key in ('rotationAxisVector', 'slideDirectionVector', 'normalDirectionVector', 'primarySlideDirectionVector', + 'secondarySlideDirectionVector', 'pitchDirectionVector', 'yawDirectionVector', 'rollDirectionVector'): + if hasattr(m, key): + v = getattr(m, key); out[key] = [v.x, v.y, v.z] if v else None + return out + def joint(j): + # AsBuiltJoint exposes health through its TimelineObject, unlike Joint. + # Missing health is an oracle error, never implicit healthy state. + regular = j.objectType.endswith('::Joint') + health = j if regular else j.timelineObject + require(health is not None, 'Independent joint timeline health unavailable') + out = {'name': j.name, 'node': link(j), 'motion': motion(j.jointMotion), + 'one': link(j.occurrenceOne), 'two': link(j.occurrenceTwo), + 'suppressed': j.isSuppressed, 'health': int(health.healthState), 'message': health.errorOrWarningMessage} + if regular: + out.update(geometryOne=geometry(j.geometryOrOriginOne), geometryTwo=geometry(j.geometryOrOriginTwo), + frameOne=assembly_matrix(j.geometryOneTransform), frameTwo=assembly_matrix(j.geometryTwoTransform), + flipped=j.isFlipped, locked=j.isLocked) + for key in ('angle', 'offset', 'offsetX', 'offsetY'): out[key] = assembly_parameter(getattr(j, key), key == 'angle') + else: + out['geometry'] = geometry(j.geometry) if j.geometry is not None else None + out['transform'] = assembly_matrix(j.transform) if j.transform is not None else None + return out + def origin(o): + out = {'name': o.name, 'node': link(o), 'geometry': geometry(o.geometry, definition=True), 'flipped': o.isFlipped, + 'xAxis': link(o.xAxisEntity), 'zAxis': link(o.zAxisEntity), 'transform': assembly_matrix(o.transform), + 'axes': [[v.x, v.y, v.z] for v in (o.secondaryAxisVector, o.thirdAxisVector, o.primaryAxisVector)]} + for key in ('angle', 'offsetX', 'offsetY', 'offsetZ'): out[key] = assembly_parameter(getattr(o, key), key == 'angle') + return out + def motion_link(item): + return {'name': item.name, 'node': link(item), 'one': link(item.jointOne), 'two': link(item.jointTwo), + 'motionOne': int(item.motionOne), 'motionTwo': int(item.motionTwo), + 'valueOne': {'expression': item.valueOne.expression, 'internalValue': item.valueOne.value}, + 'valueTwo': {'expression': item.valueTwo.expression, 'internalValue': item.valueTwo.value}, + 'reversed': item.isReversed, 'suppressed': item.isSuppressed, + 'health': int(item.healthState), 'message': item.errorOrWarningMessage} + return {'timelineMarker': design.timeline.markerPosition, 'pendingSnapshot': design.snapshots.hasPendingSnapshot, + 'components': [{'name': '' if c == root else c.name, + 'origins': [origin(o) for o in c.jointOrigins], 'joints': [joint(j) for j in c.joints], + 'asbuilt': [joint(j) for j in c.asBuiltJoints], + 'links': [motion_link(l) for l in c.motionLinks] if hasattr(c, 'motionLinks') else []} for c in comps], + 'occurrences': [{'path': o.fullPathName, 'node': link(o), 'component': link(o.component), + 'transform': assembly_matrix(o.transform2), 'grounded': o.isGrounded, + 'groundedToParent': o.isGroundToParent, + 'bodyCenters': [vector(b.getPhysicalProperties(adsk.fusion.CalculationAccuracy.HighCalculationAccuracy).centerOfMass) for b in o.bRepBodies]} for o in occs], + 'enums': {name: int(getattr(adsk.fusion.JointDirections, name.upper() + 'AxisJointDirection')) for name in ('x', 'y', 'z')}} + +_assembly_previous_witness = witness +def witness(document): + result = _assembly_previous_witness(document) + design = adsk.fusion.Design.cast(document.products.itemByProductType('DesignProductType')) + result['assemblies'] = assembly_graph(design) + return result +''' + + +class Assemblies(Everyday): + def __init__(self, args, client=None): + super().__init__(args, client) + source = Path(__file__) + data = source.read_bytes() + copied = self.directory / ("harness-" + self.id) / source.name + copied.write_bytes(data) + self.report["harnessSources"].append({"name": source.name, "path": str(copied), "sha256": hashlib.sha256(data).hexdigest()}) + self.report["limitations"] = "Finite representative native regular joints, origins and links; plane-center placement oracles are fixture-specific. No inference from capability availability. Appearance review is separate." + + def native(self, action, **values): + if self.ambiguous: raise RuntimeError("Unresolved operation prevents native lifecycle actions") + custom = action in ("probe_refs", "same_entity", "origin_probe", "timeline_before", "timeline_after") + actual = "witness" if custom else action + if custom: values.setdefault("documentId", self.document_id) + script = native_script(actual, run_id=self.id, host=self.args.host_name, session_id=self.session, **values) + marker = "\nrequire(platform.node()" + if script.count(marker) != 1: raise RuntimeError("Maintained native adapter injection point changed") + script = script.replace(marker, "\n" + EVERYDAY_WITNESS + ASSEMBLY_WITNESS + marker) + if action == "probe_refs": + script += "\nresult['probes'] = [everyday_probe(r) for r in REQUEST['refs']]\nentities = [everyday_resolve(r) for r in REQUEST['refs']]\nresult['distinct'] = all(a != b for i,a in enumerate(entities) for b in entities[i+1:])\n" + elif action == "same_entity": + script += "\nresult['equal'] = everyday_resolve(REQUEST['refs'][0]) == everyday_resolve(REQUEST['refs'][1])\n" + elif action == "origin_probe": + script += "\nentity=assembly_resolve(REQUEST['ref'])\nrequire(entity.objectType.endswith('::JointOrigin'),'Origin probe resolved wrong type')\nresult['origin']={'transform':assembly_matrix(entity.transform),'context':entity.assemblyContext.fullPathName if entity.assemblyContext else None}\n" + elif action in ("timeline_before", "timeline_after"): + script += "\nresult['timeline']=assembly_" + action + "(adsk.fusion.Design.cast(app.activeProduct),assembly_timeline_registry(run,row['documentId']))\n" + result = self.call("fusion_run_modeling_script", {"script": script}, + mutating=actual in {"begin", "register", "activate", "close", "checkpoint", "capture", "restore"})["data"]["result"] + if result.get("success") is not True: raise RuntimeError("Independent assembly adapter did not succeed") + return result + + def graph(self): return self.witness()["assemblies"] + + def graph_joint(self, name="Joint", graph=None): + matches = [j for c in (graph or self.graph())["components"] for collection in ("joints", "asbuilt") for j in c[collection] if j["name"] == name] + if len(matches) != 1: raise RuntimeError("Independent native joint name is absent or ambiguous") + return matches[0] + + def graph_origin(self, name, graph=None): + matches = [o for c in (graph or self.graph())["components"] for o in c["origins"] if o["name"] == name] + if len(matches) != 1: raise RuntimeError("Independent native origin name is absent or ambiguous") + return matches[0] + + def graph_link(self, name="Link", graph=None): + matches = [l for c in (graph or self.graph())["components"] for l in c["links"] if l["name"] == name] + if len(matches) != 1: raise RuntimeError("Independent native link name is absent or ambiguous") + return matches[0] + + def public_named(self, collection, name): + rows = self.inspect()[collection] + matches = [r for r in rows if r["name"] == name] + if len(matches) != 1: raise RuntimeError("Public " + collection + " selection absent or ambiguous") + return matches[0]["ref"] + + def occurrence(self, path): + matches = [o for o in self.inspect()["occurrences"] if o["path"] == path] + if len(matches) != 1: raise RuntimeError("Occurrence path absent or ambiguous") + return matches[0] + + def pose(self, path, graph=None): + return next(o["transform"] for o in (graph or self.graph())["occurrences"] if o["path"] == path) + + def face(self, path, local=(5, 5, 10), normal=(0, 0, 1)): + pose = self.pose(path) + vector = [sum(pose[r*4+c]*normal[c] for c in range(3)) for r in range(3)] + result = self.query(self.instance_body(path), type="Plane", direction={"vector": vector}, + near={"point": point(pose, local), "tolerance": 0.001}) + return result["matches"][0]["ref"] + + def native_face(self, name, *, local=(5, 5, 10), normal=(0, 0, 1)): + data = self.inspect() + owner = next(c["id"] for c in data["components"] if c["name"] == name) + body = next(b["ref"] for b in data["bodies"] if b["componentId"] == owner) + return self.query(body, space="component", type="Plane", direction={"vector": list(normal)}, + near={"point": list(local), "tolerance": 0.001})["matches"][0]["ref"] + + def pair(self): + self.start() + self.box_component("Movable", position=(80, -20, 40), angle=30) + self.box_component("Grounded", position=(10, 20, 30), angle=90) + self.mechanical("occurrence_update", write=True, occurrence=self.occurrence("Grounded:1")["ref"], grounded=True) + return self.graph() + + def create_joint(self, kind, name="Joint", one="Movable:1", two="Grounded:1", **options): + first, second = self.face(one), self.face(two) + return self.mechanical("regular_joint_create", write=True, type=kind, name=name, + geometryOne={"entity": first, "keyPoint": "center"}, geometryTwo={"entity": second, "keyPoint": "center"}, **options) + + def failed(self, label, op, *, cause=None, code="mutation_failed", **values): + before = self.witness() + result = self.mechanical(op, write=True, expected=code, **values) + if cause is not None: self.check(label + " exact refusal", result.get("causeCode") == cause, result) + if code == "mutation_failed": self.check(label + " verified rollback", result.get("recovery", {}).get("verified") is True, result) + self.check(label + " full native attachment graph restored", equivalent(before, self.witness()), result) + self.inspect() + return result + + def placement(self, label, before, after, one="Movable:1", two="Grounded:1", angle=0, offset=0, flipped=False): + anchor_before, anchor = self.pose(two, before), self.pose(two, after) + moved = self.pose(one, after) + expected_rotation = multiply(multiply(anchor, rotation_z(angle)), rotation_x(180 if flipped else 0)) + wanted = point(anchor, [5, 5, 10 + offset]) + self.check(label + " keeps grounded occurrence pose", equivalent(anchor_before, anchor)) + self.check(label + " moves selected occurrence", not equivalent(self.pose(one, before), moved)) + self.check(label + " physical top-face center", equivalent(point(moved, [5, 5, 10]), wanted, tolerance=1e-6), {"wanted": wanted, "actual": point(moved, [5, 5, 10])}) + self.check(label + " physical component orientation", same_rotation(moved, expected_rotation), {"wanted": expected_rotation, "actual": moved}) + + def healthy(self, label, graph=None): + graph = graph or self.graph() + rows = [j for c in graph["components"] for key in ("joints", "asbuilt", "links") for j in c[key]] + self.check(label, bool(rows) and all(x["health"] == 0 and not x["message"] for x in rows) and graph["pendingSnapshot"] is False, graph) + + def motion_case(self, kind): + before = self.pair() + axes = {} if kind in ("rigid", "ball") else {"axis": "x" if kind == "slider" else "z"} + if kind == "pin_slot": axes["slideAxis"] = "x" + prefixes = {"rigid": (), "revolute": ("rotation",), "slider": ("slide",), + "cylindrical": ("rotation", "slide"), "pin_slot": ("rotation", "slide"), + "planar": ("rotation", "primarySlide", "secondarySlide"), "ball": ("pitch", "yaw", "roll")}[kind] + limits = {p + "Limits": {"minimum": -45 if "lide" not in p else -10, + "maximum": 45 if "lide" not in p else 10, "rest": 0} for p in prefixes} + self.create_joint(kind, **axes, **limits, **({"locked":False} if kind == "rigid" else {})) + initial = self.graph(); native = self.graph_joint(graph=initial) + self.check(kind + " native motion family", native["motion"]["type"] == MOTION_TYPES[kind], native) + self.placement(kind + " solved placement", before, initial) + if kind == "slider": self.check("Nondefault slider X enum retained", native["motion"]["slideDirection"] == initial["enums"]["x"]) + for prefix in prefixes: + wanted = limits[prefix + "Limits"] + got = native["motion"][prefix + "Limits"] + self.check(prefix + " native limits and implicit flags", all(equivalent(got[k], v) for k, v in wanted.items()) and all(got[k + "Enabled"] for k in ("minimum", "maximum", "rest")), got) + prior = self.graph(); value = 3 if "lide" in prefix else 15 + self.mechanical("joint_edit", write=True, joint=self.public_named("joints", "Joint"), **{prefix + "Value": value}) + after = self.graph(); current = self.graph_joint(graph=after) + self.check(prefix + " native driven value", equivalent(current["motion"][prefix + "Value"], value), current) + p0, p1 = self.pose("Movable:1", prior), self.pose("Movable:1", after) + self.check(prefix + " grounded pose preserved", equivalent(self.pose("Grounded:1", prior), self.pose("Grounded:1", after))) + if "lide" in prefix: + displacement = [a-b for a,b in zip(point(p1,[5,5,10]),point(p0,[5,5,10]))] + local_axis = [0,0,1] if kind == "cylindrical" else ([0,1,0] if prefix == "secondarySlide" else [1,0,0]) + anchor=self.pose("Grounded:1",initial) + expected_axis=[sum(anchor[r*4+c]*local_axis[c] for c in range(3)) for r in range(3)] + self.check(prefix + " actual physical translation", equivalent(math.sqrt(sum(x*x for x in displacement)), value, tolerance=1e-6) and same_rotation(p0,p1) and axis_parallel(displacement,expected_axis), displacement) + else: + self.check(prefix + " actual physical rotation", equivalent(rotation_delta(p0,p1), value, tolerance=1e-6) and equivalent(point(p0,[5,5,10]),point(p1,[5,5,10]), tolerance=1e-6), {"before":p0,"after":p1}) + self.failed(prefix + " out of range drive", "joint_edit", cause="joint_limit_exceeded", + joint=self.public_named("joints", "Joint"), **{prefix + "Value": 99}) + self.mechanical("joint_edit", write=True, joint=self.public_named("joints", "Joint"), **{prefix + "Value": 0}) + self.check(prefix + " reset restores physical pose", equivalent(self.pose("Movable:1"), self.pose("Movable:1", initial), tolerance=1e-6)) + self.mechanical("joint_edit", write=True, joint=self.public_named("joints", "Joint"), suppressed=True) + self.check("Joint suppression native readback", self.graph_joint()["suppressed"] is True) + self.mechanical("joint_edit", write=True, joint=self.public_named("joints", "Joint"), suppressed=False) + self.check("Joint unsuppression native readback", self.graph_joint()["suppressed"] is False) + self.healthy(kind + " final healthy graph") + + def regular_rigid(self): + self.motion_case("rigid") + self.check("Rigid create retains explicit unlocked state",self.graph_joint()["locked"] is False) + self.mechanical("regular_joint_edit",write=True,joint=self.public_named("joints","Joint"),locked=False) + self.check("Existing rigid accepts explicit unlocked state",self.graph_joint()["locked"] is False) + self.failed("Rigid edit refuses locking","regular_joint_edit",cause="unsupported_joint_lock", + joint=self.public_named("joints","Joint"),locked=True) + self.failed("Rigid create refuses locking","regular_joint_create",cause="unsupported_joint_lock", + type="rigid",locked=True,geometryOne={"entity":self.face("Movable:1"),"keyPoint":"center"}, + geometryTwo={"entity":self.face("Grounded:1"),"keyPoint":"center"}) + def regular_revolute(self): self.motion_case("revolute") + def regular_slider(self): self.motion_case("slider") + def regular_cylindrical(self): self.motion_case("cylindrical") + def regular_pin_slot(self): self.motion_case("pin_slot") + def regular_planar(self): self.motion_case("planar") + def regular_ball(self): self.motion_case("ball") + + def placement_frames(self): + self.pair() + # Exact known face/pose cases established independently in the native + # probe, not inferred from the public joint angle parameter itself. + cases = ((0, 0, False), (0, 0, True), (30, 2, False), (30, -2, True)) + for family in ("rigid", "revolute"): + for angle, offset, flipped in cases: + before = self.graph() + self.create_joint(family, angle=angle, offset=offset, flipped=flipped) + after = self.graph(); native = self.graph_joint(graph=after) + label = "%s angle%s offset%s flipped%s" % (family, angle, offset, flipped) + self.placement(label, before, after, angle=angle, offset=offset, flipped=flipped) + self.check(label + " native parameters", equivalent(native["angle"]["value"], angle) and + equivalent(native["offset"]["value"], offset) and native["flipped"] is flipped, native) + self.mechanical("joint_delete", write=True, joint=self.public_named("joints", "Joint")) + self.check(label + " deleted exact joint", all(not c["joints"] for c in self.graph()["components"])) + self.mechanical("occurrence_update", write=True, occurrence=self.occurrence("Movable:1")["ref"], + transform={"position": [80, -20, 40], "axis": [0, 0, 1], "angle": 30}) + self.create_joint("rigid", angle=30, offset=2, flipped=False) + self.healthy("Archived frame example healthy") + + def nested_context(self): + self.pair() + self.mechanical("occurrence_insert", write=True, component=self.component("Movable")["ref"], + transform={"position": [130, 20, 0], "axis": [0, 0, 1], "angle": 90}) + self.mechanical("component_create", write=True, name="Container", + transform={"position": [120, 80, 0], "axis": [0, 0, 1], "angle": 90}) + self.solid("Container body",component=self.component("Container")["ref"]) + self.mechanical("occurrence_insert", write=True, component=self.component("Movable")["ref"], + parent=self.component("Container")["ref"], transform={"position": [20, 0, 0]}) + data = self.inspect(); owner = next(c["id"] for c in data["components"] if c["name"] == "Movable") + child = next(o["path"] for o in data["occurrences"] if o["componentId"] == owner and "+" in o["path"]) + self.mechanical("occurrence_update", write=True, occurrence=self.occurrence(child)["ref"], groundedToParent=False) + before = self.graph() + self.create_joint("rigid", one=child) + after = self.graph() + self.placement("Nested exact instance", before, after, one=child) + selected = next(o["node"] for o in after["occurrences"] if o["path"] == child) + self.check("Joint attached to selected nested occurrence", self.graph_joint(graph=after)["one"] == selected) + for path in ("Movable:1", "Movable:2", "Container:1"): + self.check("Unselected instance stays fixed: " + path, equivalent(self.pose(path,before),self.pose(path,after))) + face = self.face("Movable:1") + self.failed("Same occurrence refused", "regular_joint_create", cause="invalid_joint", type="rigid", + geometryOne={"entity":face,"keyPoint":"center"},geometryTwo={"entity":face,"keyPoint":"center"}) + native = self.native_face("Movable"); grounded = self.face("Grounded:1") + self.failed("Definition-only child attachment refused", "regular_joint_create", cause="occurrence_required", type="rigid", + geometryOne={"entity":native,"keyPoint":"center"},geometryTwo={"entity":grounded,"keyPoint":"center"}) + self.failed("Ancestor and descendant refused", "regular_joint_create", cause="invalid_joint",type="rigid", + geometryOne={"entity":self.face("Container:1"),"keyPoint":"center"},geometryTwo={"entity":self.face(child),"keyPoint":"center"}) + self.mechanical("occurrence_update", write=True, occurrence=self.occurrence("Movable:1")["ref"], grounded=True) + self.failed("Two grounded occurrences refused", "regular_joint_create", cause="grounded_conflict", type="rigid", + geometryOne={"entity":self.face("Movable:1"),"keyPoint":"center"},geometryTwo={"entity":self.face("Grounded:1"),"keyPoint":"center"}) + self.healthy("Nested assembly retains healthy joint") + + def root_attachment(self): + self.start();self.solid("Root fixed block") + self.box_component("Movable",position=(80,-20,40),angle=30) + before=self.pose("Movable:1") + data=self.inspect();root_id=next(c["id"] for c in data["components"] if c["ref"].get("rootComponentId")) + bodies=[b["ref"] for b in data["bodies"] if b["componentId"]==root_id] + if len(bodies)!=1:raise RuntimeError("Root fixture body absent or ambiguous") + fixed=self.query(bodies[0],type="Plane",direction={"vector":[0,0,1]},near={"point":[5,5,10],"tolerance":0.001})["matches"][0]["ref"] + self.mechanical("regular_joint_create",write=True,type="rigid",name="Joint", + geometryOne={"entity":self.face("Movable:1"),"keyPoint":"center"},geometryTwo={"entity":fixed,"keyPoint":"center"}) + after=self.pose("Movable:1");joint=self.graph_joint() + self.check("Root attachment solves physical instance pose",not equivalent(before,after) and same_rotation(after,rotation_z(0)) and equivalent(point(after,[5,5,10]),[5,5,10],tolerance=1e-6),after) + self.check("Root side stays explicit null occurrence context",joint["two"] is None and joint["geometryTwo"]["one"]["context"] is None,joint) + self.healthy("Root-owned geometry attachment healthy") + + def origin_ref(self, path, name): + refs = self.entity(self.occurrence(path)["ref"])["jointOriginRefs"] + matches = [r for r in refs if self.entity(r)["name"] == name] + if len(matches) != 1: raise RuntimeError("Occurrence origin absent or ambiguous") + return matches[0] + + def native_edge(self, component, local, direction): + data = self.inspect(); cid = next(c["id"] for c in data["components"] if c["name"] == component) + body = next(b["ref"] for b in data["bodies"] if b["componentId"] == cid) + return self.query(body,"edges",space="component",type="Line3D",direction={"vector":list(direction)}, + near={"point":list(local),"tolerance":0.001})["matches"][0]["ref"] + + def origins(self): + before = self.pair() + first = self.native_face("Movable") + created = self.mechanical("joint_origin_create", write=True, component=self.component("Movable")["ref"], + name="Shaft origin", geometry={"entity":first,"keyPoint":"center"}) + native = self.graph_origin("Shaft origin") + self.check("Origin source is native component face", native["geometry"]["one"]["context"] is None) + # The frame convention oracle is supplied by the separately recorded + # rotated-component native probe, and must never assume JointGeometry's + # convention also applies to JointOrigin.transform. + self.origin_frame_check("Shaft origin", "Movable:1", [5,5,10]) + self.mechanical("joint_origin_edit", write=True, origin=self.public_named("jointOrigins","Shaft origin"), + offsetX=1,offsetY=2,offsetZ=3,angle=15,flipped=True) + changed = self.graph_origin("Shaft origin") + self.check("Origin parameters and flip persist", all(equivalent(changed[k]["value"],v) for k,v in + (("offsetX",1),("offsetY",2),("offsetZ",3),("angle",15))) and changed["flipped"] is True, changed) + self.check("Origin physical frame changes", not equivalent(native["transform"],changed["transform"])) + self.check("Origin edit preserves source graph", native["geometry"]["one"] == changed["geometry"]["one"]) + bad = self.native_edge("Grounded", (5,0,10), (1,0,0)) + self.failed("Foreign orientation reference refused", "joint_origin_edit", cause="wrong_component", + origin=self.public_named("jointOrigins","Shaft origin"),xAxisEntity=bad) + xaxis = self.native_edge("Movable", (5,0,10), (1,0,0)) + zaxis = self.native_edge("Movable", (0,0,5), (0,0,1)) + self.mechanical("joint_origin_edit", write=True, origin=self.public_named("jointOrigins","Shaft origin"), + xAxisEntity=xaxis,zAxisEntity=zaxis,offsetX=0,offsetY=0,offsetZ=0,angle=0,flipped=False) + self.check("Origin retains both explicit orientation links", self.graph_origin("Shaft origin")["xAxis"] is not None and self.graph_origin("Shaft origin")["zAxis"] is not None) + one = self.origin_ref("Movable:1","Shaft origin"); two = self.face("Grounded:1") + self.mechanical("regular_joint_create", write=True, type="rigid", name="Joint", + geometryOne={"origin":one},geometryTwo={"entity":two,"keyPoint":"center"}) + attached = self.graph_joint() + self.check("Actual regular joint uses explicit origin node", attached["geometryOne"]["origin"]["node"] == self.graph_origin("Shaft origin")["node"]["node"]) + graph_before = self.graph() + self.mechanical("joint_origin_edit", write=True, origin=self.public_named("jointOrigins","Shaft origin"),offsetZ=2) + graph_after = self.graph() + self.check("Editing used origin changes actual attached occurrence pose", not equivalent(self.pose("Movable:1",graph_before),self.pose("Movable:1",graph_after))) + self.check("Used origin edit preserves grounded body", equivalent(self.pose("Grounded:1",before),self.pose("Grounded:1",graph_after))) + self.check("Used origin retains attachment identity", attached["geometryOne"] == self.graph_joint(graph=graph_after)["geometryOne"]) + self.healthy("Used origin edit keeps healthy constraint graph") + self.mechanical("joint_origin_create", write=True, component=self.component("Movable")["ref"], + name="Unused",geometry={"entity":self.native_face("Movable"),"keyPoint":"center"}) + original_unused=self.graph_origin("Unused") + self.mechanical("joint_origin_edit",write=True,origin=self.public_named("jointOrigins","Unused"), + geometry={"entity":self.native_face("Movable",local=(5,5,0),normal=(0,0,-1)),"keyPoint":"center"}) + self.check("Origin geometry redefinition changes exact native source",original_unused["geometry"]["one"] != self.graph_origin("Unused")["geometry"]["one"]) + self.mechanical("joint_origin_delete", write=True, origin=self.public_named("jointOrigins","Unused")) + self.check("Unused origin deleted while used attachment remains", [o["name"] for c in self.graph()["components"] for o in c["origins"]] == ["Shaft origin"]) + self.checkpoint() + self.used_origin_delete() + + def used_origin_delete(self): + # The bounded probe on this installed build returned deleteMe=true and + # computeAll=true while the attached joint became unhealthy. The public + # operation must refuse and restore; success is never accepted here. + self.failed("Referenced origin deletion", "joint_origin_delete", + origin=self.public_named("jointOrigins","Shaft origin")) + self.healthy("Referenced-origin refusal retains healthy original joint") + + def origin_frame_check(self, name, path, expected_local): + native = self.graph_origin(name) + row = self.entity(self.origin_ref(path,name)) + local = [native["transform"][i] for i in (3,7,11)] + world = [row["transform"][i] for i in (3,7,11)] + independent=self.native("origin_probe",ref=self.origin_ref(path,name))["origin"] + self.check("Native origin transform has known component position", equivalent(local,expected_local,tolerance=1e-6),native) + self.check("Occurrence origin transform has known root position", equivalent(world,point(self.pose(path),expected_local),tolerance=1e-6) and equivalent(row["transform"],independent["transform"]) and independent["context"]==path,{"public":row,"native":independent}) + + def redefinition(self): + self.pair(); self.create_joint("rigid") + initial = self.graph(); anchor = self.pose("Grounded:1",initial) + timeline_before = self.native("timeline_before")["timeline"] + # Successful and refused edits are followed by independent full timeline + # and relationship reads, never merely parameter echoes. + self.mechanical("regular_joint_edit", write=True, joint=self.public_named("joints","Joint"), + type="revolute",axis="z",angle=30,offset=2,flipped=False,locked=True) + after = self.graph(); joint = self.graph_joint(graph=after) + self.check("Redefinition changed native family and lock", joint["motion"]["type"] == "RevoluteJointMotion" and joint["locked"] is True) + timeline_after = self.native("timeline_after")["timeline"] + self.check("Successful redefinition preserves original timeline entities and ends after an optional native snapshot", + timeline_edit_preserved(timeline_before,timeline_after),{"before":timeline_before,"after":timeline_after}) + self.check("Redefinition preserves grounded pose", equivalent(self.pose("Grounded:1",after),anchor)) + self.check("Redefinition physical angle independent of parameter", same_rotation(self.pose("Movable:1",after),multiply(anchor,rotation_z(30)))) + self.check("Redefinition physical offset independent of parameter", equivalent(point(self.pose("Movable:1",after),[5,5,10]),point(anchor,[5,5,12]),tolerance=1e-6)) + self.mechanical("regular_joint_edit",write=True,joint=self.public_named("joints","Joint"),offsetX=1,offsetY=2) + tangential=self.graph_joint() + self.check("Tangential offsets alter physical face-center placement",equivalent(point(self.pose("Movable:1"),[5,5,10]),point(anchor,[6,7,12]),tolerance=1e-6),tangential) + self.failed("Invalid parameter expression retains existing graph", "regular_joint_edit", cause="invalid_expression", + joint=self.public_named("joints","Joint"),angle="not_a_parameter + 1 deg") + first = self.face("Movable:1") + self.failed("Invalid same occurrence redefinition restores graph", "regular_joint_edit", cause="invalid_joint", + joint=self.public_named("joints","Joint"),geometryOne={"entity":first,"keyPoint":"center"},geometryTwo={"entity":first,"keyPoint":"center"}) + one=self.face("Movable:1",local=(5,5,0),normal=(0,0,-1));two=self.face("Grounded:1") + prior=self.graph_joint() + history_before=self.native("timeline_before")["timeline"] + refused=self.failed("Rigid redefinition preserves incompatible captured-position history", + "regular_joint_edit",cause="unhealthy_model",joint=self.public_named("joints","Joint"), + geometryOne={"entity":one,"keyPoint":"center"},geometryTwo={"entity":two,"keyPoint":"center"}, + type="rigid",angle=0,offset=0,offsetX=0,offsetY=0,flipped=True,locked=False) + self.check("Refusal identifies only the emptied existing Position1 and retains exact timeline identity", + empty_position_refusal(refused,history_before,self.native("timeline_after")["timeline"]),refused) + # Keep the motion degree of freedom whose historical position exists. + one=self.face("Movable:1",local=(5,5,0),normal=(0,0,-1));two=self.face("Grounded:1") + self.mechanical("regular_joint_edit",write=True,joint=self.public_named("joints","Joint"), + geometryOne={"entity":one,"keyPoint":"center"},geometryTwo={"entity":two,"keyPoint":"center"}, + angle=0,offset=0,offsetX=0,offsetY=0,flipped=True) + changed=self.graph_joint() + self.check("Redefinition selected a different physical source face", prior["geometryOne"]["one"] != changed["geometryOne"]["one"]) + self.check("Reattachment retains the captured motion family and lock",changed["motion"]["type"]=="RevoluteJointMotion" and changed["locked"] is True) + self.check("Bottom source face aligns with grounded top face", equivalent(point(self.pose("Movable:1"),[5,5,0]),point(self.pose("Grounded:1"),[5,5,10]),tolerance=1e-6)) + self.check("Reattached physical faces oppose and grounded pose is preserved",mating_face_normals(self.pose("Movable:1"),self.pose("Grounded:1")) and equivalent(self.pose("Grounded:1"),anchor)) + self.healthy("Redefined regular joint healthy for archive") + # A later unlocked joint has no downstream position history. Native + # locking captures a new Snapshot, so keep that covered by the locked + # history refusal above and prove this conversion without locking. + self.box_component("FreshMovable",position=(80,60,30)) + self.box_component("FreshGrounded",position=(10,60,30)) + self.mechanical("occurrence_update",write=True,occurrence=self.occurrence("FreshGrounded:1")["ref"],grounded=True) + self.create_joint("revolute",name="FreshJoint",one="FreshMovable:1",two="FreshGrounded:1",axis="z") + fresh=self.graph_joint("FreshJoint") + self.check("Fresh source is an unlocked native revolute",fresh["motion"]["type"]=="RevoluteJointMotion" and fresh["locked"] is False) + timeline=self.native("timeline_before")["timeline"] + self.check("Fresh moving joint has no downstream snapshot",timeline["types"][-1:]==["adsk::fusion::Joint"]) + fresh_anchor=self.pose("FreshGrounded:1") + one=self.face("FreshMovable:1",local=(5,5,0),normal=(0,0,-1));two=self.face("FreshGrounded:1") + self.mechanical("regular_joint_edit",write=True,joint=self.public_named("joints","FreshJoint"), + geometryOne={"entity":one,"keyPoint":"center"},geometryTwo={"entity":two,"keyPoint":"center"}, + type="rigid",angle=0,offset=0,offsetX=0,offsetY=0,flipped=True,locked=False) + fresh=self.graph_joint("FreshJoint") + self.check("Fresh moving-to-rigid conversion has native rigid motion and no lock",fresh["motion"]["type"]=="RigidJointMotion" and fresh["locked"] is False) + self.check("Fresh rigid conversion aligns selected faces and preserves ground",equivalent(point(self.pose("FreshMovable:1"),[5,5,0]),point(fresh_anchor,[5,5,10]),tolerance=1e-6) and equivalent(self.pose("FreshGrounded:1"),fresh_anchor)) + self.check("Fresh converted physical face normals oppose",mating_face_normals(self.pose("FreshMovable:1"),fresh_anchor)) + self.check("Fresh conversion preserves original timeline entities",timeline_edit_preserved(timeline,self.native("timeline_after")["timeline"])) + self.healthy("Both redefined joints are healthy for archive") + + def drive(self, name, prefix, value): + self.mechanical("joint_edit",write=True,joint=self.public_named("joints",name),**{prefix+"Value":value}) + + def coupled_step(self,label,before,after,*,target,reversed): + values=[self.graph_joint(name,graph)["motion"]["rotationValue"] + for name in ("First","Second") for graph in (before,after)] + one_before,one_after,two_before,two_after=values + self.check(label+" exact target, absolute ratio and signed native propagation", + coupled_step_matches(*values,target,reversed),{"before":[one_before,two_before],"after":[one_after,two_after],"target":target,"reversed":reversed}) + for path,delta in (("Movable:1",one_after-one_before),("Follower:1",two_after-two_before)): + actual=signed_z_rotation(self.pose(path,before),self.pose(path,after)) + self.check(label+" signed physical rotation "+path,equivalent(actual,delta,tolerance=1e-6),{"actualDegrees":actual,"nativeDeltaDegrees":delta}) + # These revolute joints attach the known local top-face centers with + # zero offsets. Rotation about an off-origin anchor legitimately changes + # the occurrence translation; the attached physical point must stay put. + for path,anchor in (("Movable:1","Grounded:1"),("Follower:1","Second ground:1")): + points=[{"body":point(self.pose(path,graph),[5,5,10]), + "anchor":point(self.pose(anchor,graph),[5,5,10])} for graph in (before,after)] + self.check(label+" physical anchor retained "+path, + all(equivalent(row["body"],row["anchor"],tolerance=1e-6) for row in points),points) + self.check(label+" grounded bodies unchanged",all(equivalent(self.pose(path,before),self.pose(path,after)) for path in ("Grounded:1","Second ground:1"))) + + def links_two(self): + self.pair(); self.create_joint("revolute",name="First",axis="z") + self.box_component("Follower",position=(100,60,20),angle=20) + self.box_component("Second ground",position=(60,20,30),angle=90) + self.mechanical("occurrence_update",write=True,occurrence=self.occurrence("Second ground:1")["ref"],grounded=True) + self.create_joint("revolute",name="Second",one="Follower:1",two="Second ground:1",axis="z") + self.mechanical("motion_link_create",write=True,name="Link",jointOne=self.public_named("joints","First"), + jointTwo=self.public_named("joints","Second"),motionOne="rotation",motionTwo="rotation", + valueOne="90 deg",valueTwo="180 deg",reversed=True) + initial=self.graph(); link=self.graph_link(graph=initial) + first=self.graph_joint("First",initial);second=self.graph_joint("Second",initial) + self.check("Native link attaches distinct exact joints", link["one"] == first["node"] and link["two"] == second["node"] and link["one"] != link["two"],link) + self.drive("First","rotation",10) + after=self.graph() + self.check("Two joint native reversed 1:2 propagation", equivalent(self.graph_joint("First",after)["motion"]["rotationValue"],10) and equivalent(self.graph_joint("Second",after)["motion"]["rotationValue"],-20)) + self.check("Follower body physically rotates twenty degrees", equivalent(rotation_delta(self.pose("Follower:1",initial),self.pose("Follower:1",after)),20,tolerance=1e-6)) + self.check("Driver body physically rotates ten degrees", equivalent(rotation_delta(self.pose("Movable:1",initial),self.pose("Movable:1",after)),10,tolerance=1e-6)) + stale_link=self.public_named("motionLinks","Link") + self.mechanical("motion_link_edit",write=True,link=stale_link,valueTwo="90 deg") + edited=self.graph_link() + self.check("Ratio-only edit preserves both motions and reversal", all(edited[k]==link[k] for k in ("motionOne","motionTwo","reversed","one","two")),edited) + self.failed("Stale link reference refused","motion_link_edit",cause="stale_reference",link=stale_link,valueTwo="45 deg") + before=self.graph(); self.drive("First","rotation",15); after=self.graph() + self.coupled_step("Edited reversed ratio",before,after,target=15,reversed=True) + self.failed("Invalid link units preserve graph", "motion_link_edit", cause="invalid_expression",link=self.public_named("motionLinks","Link"),valueOne="5 mm") + self.mechanical("motion_link_edit",write=True,link=self.public_named("motionLinks","Link"),suppressed=True) + self.check("Native link suppression retained",self.graph_link()["suppressed"] is True) + prior=self.graph(); self.drive("First","rotation",20); uncoupled=self.graph() + self.check("Suppressed link permits independent physical motion", equivalent(self.pose("Follower:1",prior),self.pose("Follower:1",uncoupled)) and not equivalent(self.pose("Movable:1",prior),self.pose("Movable:1",uncoupled))) + self.mechanical("motion_link_edit",write=True,link=self.public_named("motionLinks","Link"),suppressed=False,reversed=False) + prior=self.graph(); self.drive("First","rotation",25); following=self.graph() + self.coupled_step("Unsuppressed positive ratio",prior,following,target=25,reversed=False) + # Save a healthy coupled graph before testing deletion, so both graph + # persistence and deletion receive actual native evidence. + self.checkpoint() + self.mechanical("motion_link_delete",write=True,link=self.public_named("motionLinks","Link")) + self.check("Native link deleted",all(not c["links"] for c in self.graph()["components"])) + prior=self.graph(); self.drive("First","rotation",30) + self.check("Deleted link no longer moves follower",equivalent(self.pose("Follower:1",prior),self.pose("Follower:1"))) + self.mechanical("regular_joint_edit",write=True,joint=self.public_named("joints","First"),type="rigid") + self.failed("Rigid member cannot couple","motion_link_create",cause="wrong_joint_type", + jointOne=self.public_named("joints","First"),jointTwo=self.public_named("joints","Second"), + motionOne="rotation",motionTwo="rotation",valueOne=1,valueTwo=1) + self.healthy("Unlinked joints remain healthy") + + def link_single(self): + self.pair(); self.create_joint("cylindrical",axis="z") + self.mechanical("motion_link_create",write=True,name="Link",jointOne=self.public_named("joints","Joint"), + motionOne="rotation",motionTwo="slide",valueOne="360 deg",valueTwo="2 mm") + initial=self.graph(); link=self.graph_link(graph=initial) + self.check("Single-joint link distinct native motions",link["motionOne"] != link["motionTwo"] and + (link["two"] is None or link["two"] == link["one"]),link) + self.drive("Joint","rotation",90); after=self.graph(); joint=self.graph_joint(graph=after) + self.check("Single-joint rotation drives half millimeter slide",equivalent(joint["motion"]["rotationValue"],90) and equivalent(joint["motion"]["slideValue"],0.5),joint) + self.check("Single-joint physical rotation is ninety degrees",equivalent(rotation_delta(self.pose("Movable:1",initial),self.pose("Movable:1",after)),90,tolerance=1e-6)) + displacement=[a-b for a,b in zip(point(self.pose("Movable:1",after),[5,5,10]),point(self.pose("Movable:1",initial),[5,5,10]))] + self.check("Single-joint physical translation is axial half millimeter",equivalent(math.sqrt(sum(x*x for x in displacement)),0.5,tolerance=1e-6) and axis_parallel(displacement,[0,0,1]),displacement) + self.failed("Same DOF link refused", "motion_link_create",cause="invalid_motion_link",jointOne=self.public_named("joints","Joint"),motionOne="rotation",motionTwo="rotation",valueOne=1,valueTwo=1) + self.failed("Zero ratio refused", "motion_link_edit",cause="invalid_input",link=self.public_named("motionLinks","Link"),valueTwo=0) + self.failed("Negative ratio refused", "motion_link_edit",cause="invalid_input",link=self.public_named("motionLinks","Link"),valueTwo=-1) + self.failed("Wrong motion family refused", "motion_link_edit",cause="wrong_joint_type",link=self.public_named("motionLinks","Link"),motionTwo="yaw") + self.healthy("Cylindrical coupling healthy for archive") + + def asbuilt(self): + before=self.pair() + self.mechanical("joint_create",write=True,name="Joint",type="revolute",axis="z", + occurrenceOne=self.occurrence("Movable:1")["ref"],occurrenceTwo=self.occurrence("Grounded:1")["ref"], + geometry={"entity":self.face("Movable:1"),"keyPoint":"center"},rotationLimits={"minimum":-30,"maximum":30,"rest":0}) + initial=self.graph() + self.check("As-built creation retains pre-existing placements",all(equivalent(self.pose(p,before),self.pose(p,initial)) for p in ("Movable:1","Grounded:1"))) + self.check("As-built API family retained",any(c["asbuilt"] for c in initial["components"])) + self.drive("Joint","rotation",15); after=self.graph() + self.check("As-built drive moves physical body fifteen degrees",equivalent(rotation_delta(self.pose("Movable:1",initial),self.pose("Movable:1",after)),15,tolerance=1e-6)) + self.check("As-built drive preserves ground",equivalent(self.pose("Grounded:1",initial),self.pose("Grounded:1",after))) + self.failed("As-built out-of-range rollback","joint_edit",cause="joint_limit_exceeded",joint=self.public_named("joints","Joint"),rotationValue=90) + self.healthy("As-built compatibility healthy") + + +def main(argv=None): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument("mode",choices=("plan","run","recover","review"),nargs="?",default="plan") + for name in ("target","host-name","evidence","expect-bridge","expect-addin","shotlog-channel","reviewer","annotations"): + parser.add_argument("--"+name) + parser.add_argument("--groups",nargs="+",choices=GROUPS,default=list(GROUPS)) + parser.add_argument("--inject-failure",choices=("after-new","after-model")) + args=parser.parse_args(argv) + if args.mode=="plan": + print(json.dumps({"groups":args.groups,"hostCalls":False,"contract":"Public typed writes, exact owned fixtures, independent native physical poses and attachment graph, F3D reopen, rollback and separately reviewed viewport hashes."},indent=2));return 0 + if args.mode=="review": + if not all((args.evidence,args.reviewer,args.annotations)):parser.error("review requires evidence, reviewer and annotations") + path=Path(args.evidence)/"report.json";report=json.loads(path.read_text()) + report["visual"]=visual_review(report,json.loads(Path(args.annotations).read_text()),args.reviewer) + report["overall"]="PASS" if report.get("native")=="PASS" and report.get("cleanup")=="PASS" and report["visual"]["status"]=="PASS" else "FAIL" + write_json(path,report) + return 0 if report["visual"]["status"]=="PASS" else 1 + if not all((args.target,args.host_name,args.evidence)):parser.error("explicit target, host name and evidence are required") + if args.mode=="recover":return shared_recover(args,campaign_type=Assemblies) + if not all((args.expect_bridge,args.expect_addin)):parser.error("run requires both expected live versions") + if len(set(args.groups))!=len(args.groups):parser.error("groups must be unique") + directory=Path(args.evidence) + if directory.exists():parser.error("evidence directory must be new") + directory.mkdir(parents=True) + args.fixtures,args.iterations=args.groups,1 + campaign=Assemblies(args) + def interrupted(signum,_):raise KeyboardInterrupt("Signal "+str(signum)) + signal.signal(signal.SIGINT,interrupted);signal.signal(signal.SIGTERM,interrupted) + code=campaign.run() + print(json.dumps({"overall":campaign.report["overall"],"counts":campaign.report.get("counts"),"report":str(directory/"report.json")},indent=2)) + return code + + +if __name__=="__main__":sys.exit(main()) diff --git a/tests/ralph_everyday.py b/tests/ralph_everyday.py new file mode 100644 index 0000000..afd6309 --- /dev/null +++ b/tests/ralph_everyday.py @@ -0,0 +1,589 @@ +#!/usr/bin/env python3 +"""Actual-app acceptance for dev.5 everyday modeling; default plan is offline. + +Uses the maintained Ralph transport, exact-object ownership, native checkpoints, +and separate hash-bound visual review. Only this file adds CP5 model fixtures. +""" +import argparse +import copy +import hashlib +import json +import math +from pathlib import Path +import signal +import sys + +from ralph_mechanical import Campaign, recover as shared_recover +from ralph_support import equivalent, native_script + + +GROUPS = ("query_root", "query_contexts", "measurements", "curve_trim", "curve_extend", + "curve_split", "curve_break", "offset_positive", "offset_negative", + "offset_chain", "axes", "axis_context", "diagnostics", "persistence_graph", "bracket", "assembly") + + +# Independent API witness: this never imports production topology/sketch helpers. +# Rich data is added to the existing archive/cleanup witness too, so persistence +# checks cover controlling dimensions, offset relationships and native axes. +EXTRA_WITNESS = r''' +def everyday_geometry(geometry): + kind = geometry.objectType.split('::')[-1] + cast = getattr(adsk.core, kind) + geometry = cast.cast(geometry) + out = {'type': kind} + for key in ('startPoint', 'endPoint', 'center', 'origin'): + if hasattr(geometry, key): out[key] = vector(getattr(geometry, key)) + for key in ('direction', 'normal'): + if hasattr(geometry, key): + item = getattr(geometry, key) + out[key] = [item.x, item.y, item.z] + for key in ('radius', 'majorRadius', 'minorRadius'): + if hasattr(geometry, key): out[key] = getattr(geometry, key) * 10 + if kind == 'Point3D': out['position'] = vector(geometry) + if kind == 'NurbsCurve3D': + valid, controls, degree, knots, rational, weights, periodic = geometry.getData() + require(valid, 'Native spline control data unavailable') + out.update(controls=[vector(p) for p in controls], degree=degree, knots=list(knots), + rational=rational, weights=list(weights), periodic=periodic) + return out + +def everyday_curve(curve): + out = {'type': curve.objectType.split('::')[-1], 'geometry': everyday_geometry(curve.geometry), + 'construction': curve.isConstruction, 'fixed': curve.isFixed, 'linked': curve.isLinked, + 'reference': curve.isReference, 'fullyConstrained': curve.isFullyConstrained} + for key in ('startSketchPoint', 'endSketchPoint', 'centerSketchPoint'): + if hasattr(curve, key): out[key] = vector(getattr(curve, key).geometry) + if hasattr(curve, 'fitPoints'): out['fitPoints'] = [vector(p.geometry) for p in curve.fitPoints] + return out + +def everyday_sketch(sketch): + curves = list(sketch.sketchCurves) + points, constraints = list(sketch.sketchPoints), list(sketch.geometricConstraints) + inventories = [('curve', curves), ('point', points), ('constraint', constraints)] + def node(entity): + matches = [(kind, i) for kind, values in inventories for i, value in enumerate(values) if entity == value] + require(len(matches) == 1, 'Independent graph endpoint missing or ambiguous') + return list(matches[0]) + def links(item): + item = getattr(adsk.fusion, item.objectType.split('::')[-1]).cast(item) + return {key: node(getattr(item, key)) if getattr(item, key) is not None else None for key in + ('point', 'entity', 'line', 'curve', 'pointOne', 'pointTwo', 'lineOne', 'lineTwo', + 'curveOne', 'curveTwo', 'entityOne', 'entityTwo', 'midPointCurve', 'symmetryLine', + 'offsetConstraint', 'circleOne', 'circleTwo', 'ellipse', 'circleOrArc') + if hasattr(item, key)} + def dimension(item): + item = getattr(adsk.fusion, item.objectType.split('::')[-1]).cast(item) + return {'name': item.parameter.name, 'expression': item.parameter.expression, 'driving': item.isDriving, + 'type': item.objectType, 'value': item.parameter.value, + 'orientation': int(item.orientation) if hasattr(item, 'orientation') else None, 'links': links(item)} + offsets = [] + for item in constraints: + if item.objectType.endswith('::OffsetConstraint'): + constraint = adsk.fusion.OffsetConstraint.cast(item) + offsets.append({'distance': constraint.distance * 10, 'topologyMatched': constraint.isTopologyMatched, + 'parameter': constraint.dimension.parameter.name, 'expression': constraint.dimension.parameter.expression, + 'parents': [everyday_curve(c) for c in constraint.parentCurves], + 'children': [everyday_curve(c) for c in constraint.childCurves]}) + return {'name': sketch.name, 'fullyConstrained': sketch.isFullyConstrained, + 'healthState': int(sketch.healthState), 'message': sketch.errorOrWarningMessage, + 'deferred': sketch.isComputeDeferred, 'revision': None, + 'curves': [everyday_curve(c) for c in curves], + 'points': [{'position': vector(p.geometry), 'fixed': p.isFixed, 'linked': p.isLinked, + 'reference': p.isReference, 'fullyConstrained': p.isFullyConstrained} for p in sketch.sketchPoints], + 'constraints': [{'type': c.objectType, 'links': links(c)} for c in constraints], + 'curvePointLinks': [{key: node(getattr(c, key)) for key in + ('startSketchPoint', 'endSketchPoint', 'centerSketchPoint') if hasattr(c, key)} for c in curves], + 'dimensions': [dimension(d) for d in sketch.sketchDimensions], 'offsets': offsets} + +_ordinary_witness = witness +def witness(document): + result = _ordinary_witness(document) + design = adsk.fusion.Design.cast(document.products.itemByProductType('DesignProductType')) + components = [] + for comp in design.allComponents: + components.append({'name': '' if comp == design.rootComponent else comp.name, + 'axes': [{'name': a.name, 'geometry': everyday_geometry(a.geometry), 'parametric': a.isParametric, + 'healthState': int(a.healthState), 'message': a.errorOrWarningMessage} for a in comp.constructionAxes], + 'sketches': [everyday_sketch(s) for s in comp.sketches]}) + result['everyday'] = sorted(components, key=lambda c: c['name']) + return result + +def everyday_resolve(reference): + design = adsk.fusion.Design.cast(app.activeDocument.products.itemByProductType('DesignProductType')) + candidates = [] + for candidate in design.findEntityByToken(reference['token']): + if candidate.isValid and not any(candidate == other for other in candidates): candidates.append(candidate) + require(len(candidates) == 1, 'Independent token lookup is ambiguous or absent') + entity = candidates[0] + if reference.get('occurrencePath'): + matches = [o for o in design.rootComponent.allOccurrences if o.fullPathName == reference['occurrencePath']] + require(len(matches) == 1, 'Independent occurrence path is not unique') + entity = (getattr(entity, 'nativeObject', None) or entity).createForAssemblyContext(matches[0]) + return entity + +def everyday_probe(reference): + entity = everyday_resolve(reference) + out = {'type': entity.objectType, 'geometry': everyday_geometry(entity.geometry), + 'occurrencePath': entity.assemblyContext.fullPathName if getattr(entity, 'assemblyContext', None) else None} + if entity.objectType.endswith('::BRepFace'): + out['area'] = entity.area * 100 + out['point'] = vector(entity.pointOnFace) + okay, normal = entity.evaluator.getNormalAtPoint(entity.pointOnFace) + require(okay, 'Independent face normal unavailable') + out['normal'] = [normal.x, normal.y, normal.z] + if entity.objectType.endswith('::BRepEdge'): out['length'] = entity.length * 10 + if entity.objectType.startswith('adsk::fusion::Sketch'): + out['flags'] = {'fullyConstrained': entity.isFullyConstrained, 'fixed': entity.isFixed, + 'linked': entity.isLinked, 'reference': entity.isReference} + return out +''' + + +class Everyday(Campaign): + def __init__(self, args, client=None): + super().__init__(args, client) + snapshots = self.directory / ("harness-" + self.id) + snapshots.mkdir(parents=True, exist_ok=False) + self.report["harnessSources"] = [] + for name in ("ralph_everyday.py", "ralph_mechanical.py", "ralph_support.py", "fusion_harness.py"): + source = Path(__file__).with_name(name) + data = source.read_bytes() + copied = snapshots / name + copied.write_bytes(data) + self.report["harnessSources"].append({"name": name, "path": str(copied), "sha256": hashlib.sha256(data).hexdigest()}) + + def native(self, action, **values): + if self.ambiguous: + raise RuntimeError("Unresolved operation prevents native lifecycle actions") + actual = "witness" if action in ("probe_refs", "native_measure", "same_entity") else action + if action in ("probe_refs", "native_measure", "same_entity"): + values.setdefault("documentId", self.document_id) + script = native_script(actual, run_id=self.id, host=self.args.host_name, session_id=self.session, **values) + marker = "\nrequire(platform.node()" + if script.count(marker) != 1: + raise RuntimeError("Maintained native adapter injection point changed") + script = script.replace(marker, "\n" + EXTRA_WITNESS + marker) + if action == "probe_refs": + script += "\nresult['probes'] = [everyday_probe(r) for r in REQUEST['refs']]\nentities = [everyday_resolve(r) for r in REQUEST['refs']]\nresult['distinct'] = all(a != b for i, a in enumerate(entities) for b in entities[i+1:])\nif REQUEST.get('sketch'): result['allInSketch'] = all(e.parentSketch == everyday_resolve(REQUEST['sketch']) for e in entities)\n" + elif action == "same_entity": + script += "\nresult['equal'] = everyday_resolve(REQUEST['refs'][0]) == everyday_resolve(REQUEST['refs'][1])\n" + elif action == "native_measure": + script += """ +entities = [everyday_resolve(r) for r in REQUEST['refs']] +measured = (app.measureManager.measureMinimumDistance(*entities) if REQUEST['kind'] == 'distance' + else app.measureManager.measureAngle(*entities)) +require(measured and measured.isValid, 'Independent native measure returned no result') +result['measurement'] = {'value': measured.value * (10 if REQUEST['kind'] == 'distance' else 180 / math.pi), + 'witnessPoints': [vector(getattr(measured, n)) for n in + (('positionOne', 'positionTwo') if REQUEST['kind'] == 'distance' else ('positionOne', 'positionTwo', 'positionThree'))]} +""" + result = self.call("fusion_run_modeling_script", {"script": script}, + mutating=actual in {"begin", "register", "activate", "close", "checkpoint", "capture", "restore"})["data"]["result"] + if result.get("success") is not True: + raise RuntimeError("Independent native adapter did not succeed") + return result + + def start(self): + self.new(self.fixture) + + def solid(self, name, size=(10, 10, 10), component=None): + sk = self.create_sketch(name + " outline", [{"type": "rectangle", "start": [0, 0], "end": list(size[:2])}], component=component) + values = {"type": "extrude", "name": name + " extrusion", "profiles": [sk["profiles"][0]["ref"]], "distance": size[2]} + if component: values["component"] = {**component, **self.target} + self.mechanical("feature_create", write=True, **values) + return self.inspect() + + def box_component(self, name, *, position=(0, 0, 0), angle=0): + self.mechanical("component_create", write=True, name=name, + transform={"position": list(position), "axis": [0, 0, 1], "angle": angle}) + component = self.component(name) + self.solid(name, component=component["ref"]) + return self.component(name) + + def native_sketch(self, name): + return next(s for c in self.witness()["everyday"] for s in c["sketches"] if s["name"] == name) + + def query(self, body, kind="faces", *, space="root", selection="one", **filters): + return self.mechanical("geometry_query", body=body, kind=kind, space=space, selection=selection, filters=filters) + + def reject(self, label, op, *, code, cause=None, write=False, **values): + before = self.witness() + result = self.mechanical(op, write=write, expected=code, **values) + if cause: + self.check(label + " exact cause", result.get("causeCode") == cause, result) + if write: + self.check(label + " verified recovery", result.get("recovery", {}).get("verified") is True, result) + self.check(label + " leaves native model unchanged", equivalent(before, self.witness()), result) + self.inspect() + return result + + def root_body(self, index=0): + return self.inspect()["bodies"][index]["ref"] + + def instance_body(self, path): + occurrence = next(o for o in self.inspect()["occurrences"] if o["path"] == path) + return self.entity(occurrence["ref"])["bodyRefs"][0] + + def query_root(self): + self.start() + self.solid("Root box", (10, 20, 30)) + body = self.root_body() + top = self.query(body, type="Plane", direction={"vector": [0, 0, 1]}, near={"point": [5, 10, 30], "tolerance": 0.001}) + face = top["matches"][0] + native = self.native("probe_refs", refs=[face["ref"]])["probes"][0] + self.check("Top face selection matches native surface", equivalent(native["area"], 200) and equivalent(face["area"], native["area"]) and equivalent(native["normal"], [0, 0, 1]) and equivalent(native["point"][2], 30), native) + self.check("Near filter reports a point on the selected face", equivalent(face["distanceToPick"], 0) and equivalent(face["nearestPoint"], [5, 10, 30]), face) + edges = self.query(body, "edges", selection="many", type="Line3D", length={"min": 29.999, "max": 30.001}) + edge_probe = self.native("probe_refs", refs=[r["ref"] for r in edges["matches"]]) + self.check("Edge length range selects four distinct native vertical edges", + edges["count"] == len(edges["matches"]) == len(edge_probe["probes"]) == 4 and edge_probe["distinct"] and + all(equivalent(p["length"], 30) and equivalent(row["length"], p["length"]) and + equivalent(p["geometry"]["startPoint"][:2], p["geometry"]["endPoint"][:2]) + for row, p in zip(edges["matches"], edge_probe["probes"])), {"public": edges, "native": edge_probe}) + vertex = self.query(body, "vertices", type="Point3D", near={"point": [10, 20, 30], "tolerance": 0.001}) + self.check("Vertex query selects exact corner", equivalent(self.native("probe_refs", refs=[vertex["matches"][0]["ref"]])["probes"][0]["geometry"]["position"], [10, 20, 30])) + self.reject("Unqualified planes are ambiguous", "geometry_query", code="ambiguous_selection", body=body, kind="faces", space="root", filters={"type": "Plane"}) + self.reject("Absent cylinder cannot select one", "geometry_query", code="selection_empty", body=self.root_body(), kind="faces", space="root", filters={"type": "Cylinder"}) + empty = self.query(self.root_body(), selection="many", type="Cylinder") + self.check("Explicit many may return complete empty result", empty["count"] == 0 and empty["complete"] is True, empty) + self.reject("Query budget refuses truncation", "geometry_query", code="query_budget", body=self.root_body(), kind="faces", space="root", selection="many", maxResults=1) + + def query_contexts(self): + self.start() + comp = self.box_component("Seed", position=(20, 0, 0)) + self.mechanical("occurrence_insert", write=True, component=comp["ref"], transform={"position": [60, 20, 0], "axis": [0, 0, 1], "angle": 90}) + self.mechanical("component_create", write=True, name="Container", transform={"position": [50, 50, 0], "axis": [0, 0, 1], "angle": 90}) + comp, parent = self.component("Seed"), self.component("Container") + self.mechanical("occurrence_insert", write=True, component=comp["ref"], parent=parent["ref"], transform={"position": [5, 0, 0]}) + data = self.inspect() + seed_id = next(c["id"] for c in data["components"] if c["name"] == "Seed") + instances = [o for o in data["occurrences"] if o["componentId"] == seed_id] + self.check("Shared definition appears in three root contexts", len(instances) == 3, instances) + for occurrence in instances: + matrix = occurrence["transform"] + expected_pose = ([0, -1, 0, 50, 1, 0, 0, 55, 0, 0, 1, 0, 0, 0, 0, 1] + if "+" in occurrence["path"] else + ([1, 0, 0, 20, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] if occurrence["path"].endswith(":1") else + [0, -1, 0, 60, 1, 0, 0, 20, 0, 0, 1, 0, 0, 0, 0, 1])) + self.check("Occurrence pose agrees with analytic placement " + occurrence["path"], equivalent(matrix, expected_pose), matrix) + matrix = expected_pose + near = [sum(matrix[r * 4 + k] * [10, 5, 5][k] for k in range(3)) + matrix[r * 4 + 3] for r in range(3)] + normal = [matrix[r * 4] for r in range(3)] + body = self.instance_body(occurrence["path"]) + root = self.query(body, type="Plane", direction={"vector": normal}, near={"point": near, "tolerance": 0.001}) + local = self.query(body, space="component", type="Plane", direction={"vector": [1, 0, 0]}, near={"point": [10, 5, 5], "tolerance": 0.001}) + self.check("Frame query retains exact instance " + occurrence["path"], root["frame"]["occurrencePath"] == local["frame"]["occurrencePath"] == occurrence["path"] and root["matches"][0]["ref"]["occurrencePath"] == occurrence["path"], {"root": root, "component": local}) + self.check("Component and root filters select the same native face", self.native("same_entity", refs=[root["matches"][0]["ref"], local["matches"][0]["ref"]])["equal"], {"root": root, "component": local}) + native = self.native("probe_refs", refs=[root["matches"][0]["ref"]])["probes"][0] + self.check("Root face orientation agrees with proxy evaluator", equivalent(native["normal"], normal) and equivalent(root["matches"][0]["nearestPoint"], near), native) + shared_paths = [o["path"] for o in instances if "+" not in o["path"]] + self.measured_pair("Separated repeated instance clearance", "distance", [self.instance_body(p) for p in shared_paths], math.sqrt(500)) + body = next(b["ref"] for b in self.inspect()["bodies"] if b["componentId"] == seed_id) + vertex = self.query(body, "vertices", space="component", near={"point": [10, 10, 10], "tolerance": 0.001}) + self.check("Context-free component corner query remains local", equivalent(vertex["matches"][0]["nearestPoint"], [10, 10, 10]), vertex) + edge = self.query(body, "edges", space="component", near={"point": [5, 0, 0], "tolerance": 0.001}) + self.check("Context-free component edge query remains local", equivalent(edge["matches"][0]["nearestPoint"], [5, 0, 0]), edge) + self.reject("Context-free child cannot query root", "geometry_query", code="occurrence_required", body=body, kind="faces", space="root", selection="many") + + def measured_pair(self, label, kind, refs, expected): + public = self.mechanical("measure", type=kind, space="root", entities=refs) + native = self.native("native_measure", refs=refs, kind=kind)["measurement"] + self.check(label + " expected value", equivalent(public["value"], expected, 1e-5), public) + self.check(label + " independent native witness", equivalent(public["value"], native["value"]) and equivalent(public["witnessPoints"], native["witnessPoints"]), native) + self.check(label + " root unit contract", public["frame"] == {"space": "root", "lengthUnit": "mm"} and public["unit"] == ("mm" if kind == "distance" else "deg"), public) + return public + + def measurements(self): + self.start() + self.solid("Measure box") + body = self.root_body() + faces = {} + for name, point, normal in (("top", [5, 5, 10], [0, 0, 1]), ("bottom", [5, 5, 0], [0, 0, -1]), ("side", [10, 5, 5], [1, 0, 0])): + faces[name] = self.query(body, type="Plane", direction={"vector": normal}, near={"point": point, "tolerance": 0.001})["matches"][0]["ref"] + self.measured_pair("Opposite-face thickness", "distance", [faces["top"], faces["bottom"]], 10) + self.measured_pair("Perpendicular planes", "angle", [faces["top"], faces["side"]], 90) + self.measured_pair("Parallel planes", "angle", [faces["top"], faces["bottom"]], 0) + self.create_sketch("Measure points", [{"type": "point", "position": p} for p in ([10, 0], [0, 0], [0, 10])]) + sk = self.sketch("Measure points") + points = [next(p["ref"] for p in sk["points"] if equivalent(p["position"][:2], xy)) for xy in ([10, 0], [0, 0], [0, 10])] + self.measured_pair("Three points with explicit apex", "angle", points, 90) + self.create_sketch("Measure lines", [{"type": "line", "start": [0, 0], "end": [10, 0]}, {"type": "line", "start": [0, 0], "end": [0, 10]}, {"type": "line", "start": [0, 5], "end": [10, 5]}, {"type": "circle", "center": [30, 0], "radius": 5}]) + sk = self.sketch("Measure lines") + lines = [c["ref"] for c in sk["curves"] if c["type"].endswith("::SketchLine")] + self.measured_pair("Perpendicular lines", "angle", lines[:2], 90) + self.measured_pair("Parallel lines", "angle", [lines[0], lines[2]], 0) + top = self.query(self.root_body(), type="Plane", direction={"vector": [0, 0, 1]})["matches"][0]["ref"] + self.measured_pair("Line parallel to plane", "angle", [lines[0], top], 0) + circle = next(c["ref"] for c in sk["curves"] if c["type"].endswith("::SketchCircle")) + self.reject("Curved angle input refused", "measure", code="unsupported_geometry", type="angle", space="root", entities=[circle, lines[0]]) + + def edit_setup(self, name, items): + self.create_sketch(name, items) + sk = self.sketch(name) + line = next(c for c in sk["curves"] if c["type"].endswith("::SketchLine") and equivalent(c["startSketchPoint"]["position"][1], 0) and equivalent(c["endSketchPoint"]["position"][1], 0)) + return sk, line + + def edit(self, action, name, pick, **extra): + sk = self.sketch(name) + line = next(c for c in sk["curves"] if c["type"].endswith("::SketchLine") and equivalent(c["startSketchPoint"]["position"][1], 0) and equivalent(c["endSketchPoint"]["position"][1], 0)) + old = copy.deepcopy(line["ref"]) + result = self.mechanical("sketch_curve_edit", write=True, sketch=sk["ref"], curve=line["ref"], action=action, pick=pick, **extra) + for curve in result["curves"]: + self.check(action + " fresh replacement reference resolves", self.entity(curve["ref"])["objectType"] == curve["type"]) + self.reject(action + " old revision is stale", "inspect_entity", code="stale_reference", entity=old) + return result + + def horizontal_segments(self, name): + curves = self.native_sketch(name)["curves"] + return sorted(sorted([c["startSketchPoint"][0], c["endSketchPoint"][0]]) for c in curves + if c["type"] == "SketchLine" and equivalent(c["startSketchPoint"][1], 0) and equivalent(c["endSketchPoint"][1], 0)) + + def curve_trim(self): + self.start() + self.edit_setup("Trim", [{"type": "line", "start": [0, 0], "end": [20, 0]}, {"type": "line", "start": [10, -5], "end": [10, 5]}]) + self.edit("trim", "Trim", [15, 0]) + self.check("Trim removes selected end segment", equivalent(self.horizontal_segments("Trim"), [[0, 10]]), self.native_sketch("Trim")) + sk, line = self.edit_setup("Whole trim", [{"type": "line", "start": [0, 0], "end": [10, 0]}]) + self.reject("Whole trim needs opt-in", "sketch_curve_edit", code="mutation_failed", cause="curve_edit_empty", write=True, + sketch=sk["ref"], curve=line["ref"], action="trim", pick=[5, 0]) + self.edit("trim", "Whole trim", [5, 0], allowDelete=True) + self.check("Explicit whole trim deletes only selected curve", self.native_sketch("Whole trim")["curves"] == []) + + def curve_extend(self): + self.start() + sk, line = self.edit_setup("Extend", [{"type": "line", "start": [0, 0], "end": [5, 0]}, {"type": "line", "start": [10, -5], "end": [10, 5]}]) + self.reject("Extension midpoint is ambiguous", "sketch_curve_edit", code="mutation_failed", cause="ambiguous_pick", write=True, + sketch=sk["ref"], curve=line["ref"], action="extend", pick=[2.5, 0]) + self.edit("extend", "Extend", [4.9, 0]) + self.check("Extend reaches known boundary", equivalent(self.horizontal_segments("Extend"), [[0, 10]]), self.native_sketch("Extend")) + + def curve_split(self): + self.start() + self.edit_setup("Split", [{"type": "line", "start": [0, 0], "end": [20, 0]}]) + for pick, cause in (([0, 0], "invalid_pick"), ([5, 2], "pick_off_curve")): + sk = self.sketch("Split") + self.reject("Invalid split point " + str(pick), "sketch_curve_edit", code="mutation_failed", cause=cause, write=True, + sketch=sk["ref"], curve=sk["curves"][0]["ref"], action="split", pick=pick) + self.edit("split", "Split", [8, 0]) + self.check("Split creates both measured subsegments", equivalent(self.horizontal_segments("Split"), [[0, 8], [8, 20]]), self.native_sketch("Split")) + + def curve_break(self): + self.start() + sk, line = self.edit_setup("No break", [{"type": "line", "start": [0, 0], "end": [20, 0]}]) + self.reject("Break without intersections is a no-op", "sketch_curve_edit", code="mutation_failed", cause="kernel_error", write=True, + sketch=sk["ref"], curve=line["ref"], action="break", pick=[10, 0]) + self.edit_setup("Break", [{"type": "line", "start": [0, 0], "end": [20, 0]}, + {"type": "line", "start": [5, -5], "end": [5, 5]}, {"type": "line", "start": [15, -5], "end": [15, 5]}]) + self.edit("break", "Break", [10, 0]) + self.check("Break divides at neighboring intersections", equivalent(self.horizontal_segments("Break"), [[0, 5], [5, 15], [15, 20]]), self.native_sketch("Break")) + + def circle_offset(self, distance): + self.start() + self.create_sketch("Circle offset", [{"type": "circle", "center": [0, 0], "radius": 10}]) + sk = self.sketch("Circle offset") + result = self.mechanical("sketch_offset", write=True, sketch=sk["ref"], curves=[sk["curves"][0]["ref"]], distance=str(distance) + " mm") + native = self.native_sketch("Circle offset") + self.check("Signed circle offset has expected radii", equivalent(sorted(c["geometry"]["radius"] for c in native["curves"]), sorted([10, 10 + distance])), native) + self.check("Native signed distance and dimension persist", len(native["offsets"]) == 1 and equivalent(native["offsets"][0]["distance"], distance) and native["offsets"][0]["expression"] == result["offset"]["parameter"]["expression"], native) + self.check("Native offset links one parent and one child", len(native["offsets"][0]["parents"]) == len(native["offsets"][0]["children"]) == 1, native["offsets"]) + self.mechanical("parameter_batch", write=True, changes=[{"action": "create", "name": "Unrelated", "expression": "1 mm", "unit": "mm"}]) + self.reject("Failed follow-on edit preserves offset graph", "parameter_batch", code="mutation_failed", cause="invalid_expression", write=True, + changes=[{"action": "set", "name": "Unrelated", "expression": "2 mm"}, {"action": "set", "name": "Unrelated", "expression": "AbsentName + 1 mm"}]) + + def offset_positive(self): self.circle_offset(2) + def offset_negative(self): self.circle_offset(-2) + + def offset_chain(self): + self.start() + for sign in (2, -2): + name = "Corner " + str(sign) + self.create_sketch(name, [{"type": "line", "start": [0, 0], "end": [20, 0]}, {"type": "line", "start": [20, 0], "end": [20, 20]}]) + sk = self.sketch(name) + result = self.mechanical("sketch_offset", write=True, sketch=sk["ref"], curves=[c["ref"] for c in sk["curves"]], distance=sign) + native = self.native_sketch(name) + self.check("Ordered chain signed distance " + str(sign), equivalent(native["offsets"][0]["distance"], sign) and len(native["offsets"][0]["parents"]) == 2 and len(native["offsets"][0]["children"]) == 2, native) + child_geometries = [c["geometry"] for c in native["offsets"][0]["children"]] + horizontal = next(c for c in child_geometries if equivalent(c["startPoint"][1], c["endPoint"][1])) + vertical = next(c for c in child_geometries if equivalent(c["startPoint"][0], c["endPoint"][0])) + self.check("Ordered chain offset lies on expected side " + str(sign), equivalent(horizontal["startPoint"][1], -sign) and equivalent(vertical["startPoint"][0], 20 + sign), child_geometries) + self.create_sketch("Offset refusals", [{"type": "line", "start": [0, 0], "end": [10, 0]}, {"type": "line", "start": [30, 0], "end": [40, 0]}]) + for label, indexes, distance, cause in (("zero", [0], 0, "invalid_quantity"), ("duplicate", [0, 0], 2, "duplicate_entity"), ("disconnected", [0, 1], 2, "ambiguous_chain")): + sk = self.sketch("Offset refusals") + self.reject("Offset " + label + " refusal", "sketch_offset", code="mutation_failed", cause=cause, write=True, + sketch=sk["ref"], curves=[sk["curves"][i]["ref"] for i in indexes], distance=distance) + + self.create_sketch("Offset collapse", [{"type": "rectangle", "start": [0, 0], "end": [4, 10]}]) + sk = self.sketch("Offset collapse") + self.reject("Matched inward offset cannot collapse narrow rectangle", "sketch_offset", code="mutation_failed", write=True, + sketch=sk["ref"], curves=[c["ref"] for c in sk["curves"]], distance=-3, topologyMatched=True) + + def axis_line_check(self, name, expected_point, expected_direction, component=""): + axes = next(c for c in self.witness()["everyday"] if c["name"] == component)["axes"] + axis = next(a for a in axes if a["name"] == name) + geometry = axis["geometry"] + origin, direction = geometry["origin"], geometry["direction"] + cross = lambda a, b: [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]] + self.check(name + " native axis line", all(math.isfinite(x) for x in origin + direction) and sum(x*x for x in direction) > 1e-12 and equivalent(cross(direction, expected_direction), [0, 0, 0]) and + equivalent(cross([origin[i]-expected_point[i] for i in range(3)], expected_direction), [0, 0, 0]) and + axis["parametric"] is True and axis["healthState"] == 0, axis) + + def axes(self): + self.start() + self.solid("Axis box") + body = self.root_body() + edge = self.query(body, "edges", type="Line3D", direction={"vector": [1, 0, 0], "orientation": "parallel"}, near={"point": [5, 0, 0], "tolerance": 0.001})["matches"][0]["ref"] + self.mechanical("construction_axis", write=True, type="edge", entities=[edge], name="Edge axis") + self.axis_line_check("Edge axis", [0, 0, 0], [1, 0, 0]) + body = self.root_body() + first = self.query(body, type="Plane", direction={"vector": [0, 0, -1]})["matches"][0]["ref"] + second = self.query(body, type="Plane", direction={"vector": [0, -1, 0]})["matches"][0]["ref"] + self.mechanical("construction_axis", write=True, type="two_planes", entities=[first, second], name="Plane axis") + self.axis_line_check("Plane axis", [0, 0, 0], [1, 0, 0]) + body = self.root_body() + vertices = [self.query(body, "vertices", near={"point": p, "tolerance": 0.001})["matches"][0]["ref"] for p in ([0, 0, 0], [0, 0, 10])] + self.mechanical("construction_axis", write=True, type="two_points", entities=vertices, name="Point axis") + self.axis_line_check("Point axis", [0, 0, 0], [0, 0, 1]) + self.create_sketch("Cylinder axis section", [{"type": "circle", "center": [30, 0], "radius": 5}]) + sk = self.sketch("Cylinder axis section") + self.mechanical("feature_create", write=True, type="extrude", profiles=[sk["profiles"][0]["ref"]], distance=15, name="Cylinder axis solid") + body = next(b["ref"] for b in self.inspect()["bodies"] if equivalent(b["volume"], math.pi * 25 * 15, 1e-5)) + face = self.query(body, type="Cylinder", radius={"min": 4.999, "max": 5.001})["matches"][0]["ref"] + self.mechanical("construction_axis", write=True, type="circular_face", entities=[face], name="Cylinder axis") + self.axis_line_check("Cylinder axis", [30, 0, 0], [0, 0, 1]) + body = self.root_body() + parallel = [self.query(body, type="Plane", direction={"vector": direction})["matches"][0]["ref"] for direction in ([0, 0, 1], [0, 0, -1])] + self.reject("Parallel planes cannot define axis", "construction_axis", code="mutation_failed", cause="kernel_error", write=True, type="two_planes", entities=parallel, name="Must not exist") + + for name in ("Coincident point A", "Coincident point B"): + self.create_sketch(name, [{"type": "point", "position": [35, 35]}]) + points = [next(p["ref"] for p in self.sketch(name)["points"] if equivalent(p["position"][:2], [35, 35])) for name in ("Coincident point A", "Coincident point B")] + self.reject("Distinct coincident points cannot define axis", "construction_axis", code="mutation_failed", write=True, + type="two_points", entities=points, name="Coincident axis must not exist") + + def axis_context(self): + self.start() + self.solid("External geometry") + self.mechanical("component_create", write=True, name="Axis target", transform={"position": [50, 20, 0], "axis": [0, 0, 1], "angle": 90}) + def selections(): + comp = self.component("Axis target") + data = self.inspect() + occurrence = next(o for o in data["occurrences"] if o["componentId"] == comp["id"]) + edge = self.query(data["bodies"][0]["ref"], "edges", type="Line3D", direction={"vector": [1, 0, 0], "orientation": "parallel"}, near={"point": [5, 0, 0], "tolerance": 0.001})["matches"][0]["ref"] + return comp, occurrence, edge + comp, occurrence, edge = selections() + self.reject("Cross-component axis requires target occurrence", "construction_axis", code="mutation_failed", cause="creation_occurrence_required", write=True, component=comp["ref"], type="edge", entities=[edge]) + self.mechanical("component_create", write=True, name="Wrong axis target") + comp, occurrence, edge = selections() + wrong_comp = self.component("Wrong axis target") + wrong_occ = next(o for o in self.inspect()["occurrences"] if o["componentId"] == wrong_comp["id"]) + self.reject("Wrong creation occurrence refuses", "construction_axis", code="mutation_failed", cause="wrong_component", write=True, + component=comp["ref"], creationOccurrence=wrong_occ["ref"], type="edge", entities=[edge]) + comp, occurrence, edge = selections() + self.mechanical("construction_axis", write=True, component=comp["ref"], creationOccurrence=occurrence["ref"], type="edge", entities=[edge], name="Context axis") + self.axis_line_check("Context axis", [-20, 50, 0], [0, -1, 0], component="Axis target") + + def diagnostics(self): + self.start() + self.create_sketch("Diagnostic plate", [{"type": "rectangle", "start": [0, 0], "end": [20, 10]}]) + def inspect_diagnostics(expected): + sk = self.sketch("Diagnostic plate") + before = self.witness() + old_target = copy.deepcopy(self.target) + result = self.mechanical("sketch_diagnostics", sketch=sk["ref"]) + native = self.native_sketch("Diagnostic plate") + self.check("Diagnostics native constraint state " + str(expected), result["fullyConstrained"] is native["fullyConstrained"] is expected and result["healthState"] == native["healthState"], result) + probe_result = self.native("probe_refs", refs=[r["ref"] for r in result["entities"]], sketch=sk["ref"]) + probes = probe_result["probes"] + self.check("Diagnostics per-entity flags match exact native references", + probe_result["distinct"] and probe_result["allInSketch"] and len(probes) == len(result["entities"]) == len(native["curves"] + native["points"]) and + all(all(row[key] is probe["flags"][key] for key in ("fullyConstrained", "fixed", "linked", "reference")) + for row, probe in zip(result["entities"], probes))) + self.check("Diagnostics does not change revision or geometry", self.target == old_target and equivalent(before, self.witness())) + inspect_diagnostics(False) + for index in range(4): + sk = self.sketch("Diagnostic plate") + curve = sk["curves"][index] + kind = "horizontal" if equivalent(curve["startSketchPoint"]["position"][1], curve["endSketchPoint"]["position"][1]) else "vertical" + self.mechanical("sketch_constraint", write=True, sketch=sk["ref"], type=kind, entities=[curve["ref"]]) + sk = self.sketch("Diagnostic plate") + self.mechanical("sketch_constraint", write=True, sketch=sk["ref"], type="fix", entities=[sk["curves"][0]["startSketchPoint"]["ref"]]) + for kind, index, expression in (("horizontal", 0, "20 mm"), ("vertical", 1, "10 mm")): + sk = self.sketch("Diagnostic plate") + curve = sk["curves"][index] + self.mechanical("sketch_dimension", write=True, sketch=sk["ref"], type=kind, + entities=[curve["startSketchPoint"]["ref"], curve["endSketchPoint"]["ref"]], expression=expression, position=[25, 15]) + inspect_diagnostics(True) + sk = self.sketch("Diagnostic plate") + self.reject("Constrained plate failed split preserves graph", "sketch_curve_edit", code="mutation_failed", cause="pick_off_curve", write=True, + sketch=sk["ref"], curve=sk["curves"][0]["ref"], action="split", pick=[5, 5]) + + + + def persistence_graph(self): + self.start() + self.create_sketch("Graph fixture", [ + {"type": "line", "start": [0, 0], "end": [20, 0]}, + {"type": "line", "start": [20, 2], "end": [20, 10]}, + {"type": "circle", "center": [40, 0], "radius": 10}, + {"type": "spline", "points": [[0, 20], [5, 25], [10, 20]]}]) + for kind, index in (("horizontal", 0), ("vertical", 1)): + sk = self.sketch("Graph fixture") + self.mechanical("sketch_constraint", write=True, sketch=sk["ref"], type=kind, entities=[sk["curves"][index]["ref"]]) + sk = self.sketch("Graph fixture") + lines = [c for c in sk["curves"] if c["type"].endswith("::SketchLine")] + self.mechanical("sketch_constraint", write=True, sketch=sk["ref"], type="coincident", + entities=[lines[0]["endSketchPoint"]["ref"], lines[1]["startSketchPoint"]["ref"]]) + sk = self.sketch("Graph fixture") + circle = next(c for c in sk["curves"] if c["type"].endswith("::SketchCircle")) + self.mechanical("sketch_offset", write=True, sketch=sk["ref"], curves=[circle["ref"]], distance=2) + sk = self.sketch("Graph fixture") + line = next(c for c in sk["curves"] if c["type"].endswith("::SketchLine")) + self.mechanical("construction_axis", write=True, type="edge", entities=[line["ref"]], name="Graph axis") + self.mechanical("parameter_batch", write=True, changes=[{"action": "create", "name": "GraphScale", "expression": "1 mm", "unit": "mm"}]) + native = self.native_sketch("Graph fixture") + self.check("Rich graph includes coincidence, spline and offset relationships", + any(c["type"].endswith("::CoincidentConstraint") and len(c["links"]) == 2 for c in native["constraints"]) and + any(c["geometry"]["type"] == "NurbsCurve3D" and len(c["geometry"]["controls"]) >= 3 for c in native["curves"]) and + len(native["offsets"]) == 1, native) + self.reject("Atomic failure preserves mixed sketch graph and axis", "parameter_batch", code="mutation_failed", cause="invalid_expression", write=True, + changes=[{"action": "set", "name": "GraphScale", "expression": "2 mm"}, {"action": "set", "name": "GraphScale", "expression": "MissingGraphScale + 1 mm"}]) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("mode", choices=("plan", "run", "recover"), nargs="?", default="plan") + parser.add_argument("--target") + parser.add_argument("--host-name") + parser.add_argument("--evidence") + parser.add_argument("--expect-bridge") + parser.add_argument("--expect-addin") + parser.add_argument("--groups", nargs="+", choices=GROUPS, default=list(GROUPS)) + parser.add_argument("--shotlog-channel") + parser.add_argument("--inject-failure", choices=("after-new", "after-model")) + args = parser.parse_args(argv) + if args.mode == "plan": + print(json.dumps({"groups": args.groups, "hostCalls": False, + "contract": "Public typed operations; independent native geometry, signed offset relationships, axes and archive readback; exact owned cleanup; visual review remains separate."}, indent=2)) + return 0 + if args.mode == "recover": + if not all((args.target, args.host_name, args.evidence)): + parser.error("recover requires explicit target, host and existing evidence") + return shared_recover(args, campaign_type=Everyday) + if not all((args.target, args.host_name, args.evidence, args.expect_bridge, args.expect_addin)): + parser.error("run requires explicit target, host, evidence and expected versions") + if len(set(args.groups)) != len(args.groups): parser.error("groups must be unique") + directory = Path(args.evidence) + if directory.exists(): parser.error("evidence directory must be new") + directory.mkdir(parents=True) + args.fixtures, args.iterations = args.groups, 1 + campaign = Everyday(args) + def interrupt(signum, _): raise KeyboardInterrupt("Signal " + str(signum)) + signal.signal(signal.SIGTERM, interrupt) + signal.signal(signal.SIGINT, interrupt) + code = campaign.run() + print(json.dumps({"overall": campaign.report["overall"], "counts": campaign.report.get("counts"), "report": str(directory / "report.json")}, indent=2)) + return code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/ralph_jobs.py b/tests/ralph_jobs.py new file mode 100644 index 0000000..6479fe2 --- /dev/null +++ b/tests/ralph_jobs.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""CP8 ordinary durable-operation acceptance. Default plan makes no host calls. + +No restart, crash, storage fault, native busy hold or rendering campaign runs here. +Use exact-owned Ralph registration, checkpoint/reopen, recovery and visual review. +""" +import argparse +import copy +import hashlib +import json +import math +from pathlib import Path +import signal +import sys + +from fusion_harness import evaluate, mechanical_request_digest +from ralph_mechanical import Campaign, recover as shared_recover +from ralph_support import equivalent, native_script + + +EXCLUDED = ("restart_history", "interrupted_native_execution", "disk_or_commit_faults", "busy_native_queue") + +# Explicit outer annotations from the CLI envelope and Rust pass_through / +# etiquette. Native result fields (including errors, targets and durability) +# remain exact. Unknown additions fail comparison instead of being discarded. +BRIDGE_ANNOTATIONS = frozenset(("durationMs", "status", "schemaVersion", "shell", "_contribute", + "_hint", "_hintDocumentHygiene", "_timeoutHint", "documentHygiene", "statusVerb", "timeoutSeconds", + "windowEtiquette", "_timing")) + + +def native_envelope(response, expected=None): + """Validate every envelope before comparing its native semantic fields.""" + if expected is None and response.get('success') is False: + expected = response.get('errorCode') + errors = evaluate(response, {'errorCode':expected} if expected else {'ok':True}) + if errors: raise ValueError('Invalid public result envelope: ' + '; '.join(errors)) + def project(value): + result = {key:item for key,item in value.items() + if key not in BRIDGE_ANNOTATIONS and key not in ('replayed', 'output')} + # Actual CLI replies duplicate the native response in output. Validate + # that duplicate too: a conflicting data value is not just decoration. + if value.get('output') not in (None, ''): + nested = value['output'] + if isinstance(nested, str): nested = json.loads(nested) + # The CLI adds error:"" to successful outer replies even when + # the native duplicate has no error field. Only that proven + # envelope addition is removable; native errors remain exact. + if (isinstance(nested, dict) and value.get('success') is True + and nested.get('success') is True and result.get('error') == '' + and 'error' not in nested): + result.pop('error') + if (not isinstance(nested, dict) or + json.dumps(project(nested), sort_keys=True, allow_nan=False) != + json.dumps(result, sort_keys=True, allow_nan=False)): + raise ValueError('CLI output conflicts with the native response') + return result + return project(response) + + +def native_signature(response, expected=None): + # Python dictionary equality treats True, 1 and 1.0 as equal. Persisted + # result comparison retains the exact JSON types instead. + return json.dumps(native_envelope(response, expected), sort_keys=True, separators=(',', ':'), allow_nan=False) + + +def box_depth_parameter(inspection): + # The owned new_design box creates a named user parameter plus a model + # parameter driven by it; both can have the same numerical value. + matches = [p for p in inspection['parameters'] if p.get('name') == 'depth'] + if len(matches) != 1 or matches[0].get('unit') != 'mm' or not equivalent(matches[0].get('value'), 10): + raise ValueError('Owned box depth parameter identity, units or value differ') + return matches[0] + + +def terminal_contract(request, response, status, source_version, observed_document): + """Independent public-history assertions; no production module imports.""" + errors = [] + def need(condition, message): + if not condition: errors.append(message) + sid, oid = request['sessionId'], request['operationId'] + phase = 'succeeded' if response.get('success') is True else 'failed' + need(status.get('success') is True and status.get('sessionId') == sid and status.get('operationId') == oid, + 'Status identity does not match the submitted operation') + need(status.get('currentSessionId') == sid and status.get('historical') is False, + 'Ordinary status unexpectedly changed session') + need(status.get('state') == phase and status.get('phase') == phase and status.get('outcome') == 'recorded' + and status.get('reconciliationRequired') is False, 'Status is not the requested terminal outcome') + history = status.get('history') or {} + need(history.get('sessionId') == sid and history.get('operationId') == oid + and history.get('operation') == request['op'] and history.get('sourceVersion') == source_version + and history.get('storageVersion') == 1 and history.get('phase') == phase, 'History source/identity/phase differs') + need(history.get('requestDigest') == mechanical_request_digest(request), 'Canonical request digest differs') + need(history.get('requestedTarget') == request.get('target'), 'Recorded requested target differs') + observed = history.get('observedDocument') or {} + need(observed.get('documentId') == observed_document, 'Observed native document differs') + need('creationId' in observed and 'dataFileId' in observed, 'Observed native identity provenance is incomplete') + events = history.get('events') or [] + need([row.get('phase') for row in events] == ['admitted', 'dispatched', 'executing', phase], + 'Durable phase sequence differs') + times = [history.get('createdAt')] + [row.get('at') for row in events] + [history.get('updatedAt')] + need(all(type(t) in (int, float) and math.isfinite(t) and t > 0 for t in times), 'Invalid phase timestamps') + if all(type(t) in (int, float) and math.isfinite(t) for t in times): + need(times == sorted(times), 'Phase timestamps are not ordered') + for label, value in (('initial', response), ('recorded', status.get('result') or {})): + durability = value.get('durability') or {} + need(value.get('sessionId') == sid and value.get('operationId') == oid, + label + ' result identity differs') + need(durability.get('terminalRecorded') is True and durability.get('recordedSessionId') == sid + and durability.get('storageVersion') == 1 and durability.get('phase') == phase, + label + ' result is not durably terminal') + for label, value in (('initial', response), ('recorded', status.get('result') or {}), ('status', status)): + started = value.get('nativeExecutionStarted') + need(started is None or started is True, label + ' claims this ordinary executed operation did not execute') + try: + need(native_signature(response) == native_signature(status.get('result') or {}), + 'Recorded terminal result differs from acknowledged result') + except (ValueError, TypeError) as error: + errors.append(str(error)) + return errors + + +class DurableOperations(Campaign): + def __init__(self, args, client=None): + super().__init__(args, client) + snapshots = self.directory / ('harness-' + self.id) + snapshots.mkdir(parents=True, exist_ok=False) + self.report['harnessSources'] = [] + for name in ('ralph_jobs.py', 'ralph_mechanical.py', 'ralph_support.py', 'fusion_harness.py'): + data = Path(__file__).with_name(name).read_bytes() + copied = snapshots / name; copied.write_bytes(data) + self.report['harnessSources'].append({'name':name, 'path':str(copied), 'sha256':hashlib.sha256(data).hexdigest()}) + self.report['acceptanceScope'] = {'ordinary':'REQUESTED', **{name:'NOT_RUN' for name in EXCLUDED}} + self.report['limitations'] = ['Ordinary synchronous durable writes only; no native restart/crash/fault inference.', + 'Native, archive/cleanup and hash-bound visual review remain separate.'] + + def native(self, action, **values): + if action != 'document_set': return super().native(action, **values) + if self.ambiguous: raise RuntimeError('Unresolved operation prevents native lifecycle observations') + script = native_script('inventory', run_id=self.id, host=self.args.host_name, session_id=self.session) + script += ''' +labels = [] +for document in app.documents: + matches = ['baseline:' + str(i) for i, original in enumerate(run['baselineObjects']) if document == original] + matches += ['owned:' + row['documentId'] for row in run['documents'].values() if not row['closed'] and document == row['object']] + require(len(matches) <= 1, 'Native document ownership is ambiguous') + labels.append(matches[0] if matches else 'unowned:' + document.creationId) +result['openDocuments'] = sorted(labels) +''' + response = self.call('fusion_run_modeling_script', {'script':script}) + result = response['data']['result'] + if result.get('success') is not True: raise RuntimeError('Native document-set observation failed') + return result + + def dispatch(self, request, expected=None): + if self.ambiguous: raise RuntimeError('Previous operation is unresolved; no additional dispatch') + request = copy.deepcopy(request) + self.last_operation = request['operationId'] + self.owned.manifest['lastDispatch'] = copy.deepcopy(request) + self.owned.persist() + return self.call('fusion_mechanical', request, mutating=True, expected=expected) + + def request(self, label, op, **values): + result = dict(op=op, sessionId=self.session, operationId=self.id + '-' + label, **values) + if op != 'new_design': result.setdefault('target', copy.deepcopy(self.target)) + return result + + def history(self, request, response, observed_document): + expected = response.get('errorCode') if response.get('success') is False else None + status = self.call('fusion_mech_operation_status', + {'sessionId':self.session, 'operationId':request['operationId']}, expected=expected) + errors = terminal_contract(request, response, status, self.args.expect_addin, observed_document) + self.check(request['op'] + ' exact durable terminal contract', not errors, {'errors':errors, 'status':status}) + return native_signature(status, expected) + + def ordinary(self): + caps = self.call('fusion_mech_capabilities') + durable = caps.get('idempotency', {}) + self.check('Durable history capability is explicit', caps.get('sessionId') == self.session + and durable.get('storageVersion') == 1 and durable.get('capacity') == 10000 + and durable.get('databaseByteLimit') == 64 * 1024 * 1024 + and durable.get('recordByteLimit') == 4 * 1024 * 1024 + and durable.get('admission') == 'FULL SQLite commit before native dispatch', durable) + prior = self.call('fusion_mechanical', {'op':'inspect'})['data']['target']['documentId'] + before_documents = self.native('document_set')['openDocuments'] + creation = self.request('create', 'new_design', name='Untitled', box={'width':20,'height':30,'depth':10}) + result = self.dispatch(creation) + data = result['data']; self.document_id = data['target']['documentId']; self.target = data['target'] + # Register exact returned native identity before fragile acceptance checks. + self.owned.manifest.setdefault('pendingRegistrations', {})[self.document_id] = data + self.owned.persist() + self.bind_document(self.native('register', documentId=self.document_id, role=self.fixture)) + del self.owned.manifest['pendingRegistrations'][self.document_id]; self.owned.persist() + self.inspect() + if self.args.inject_failure == 'after-new': raise RuntimeError('Requested owned registration diagnostic') + created = self.measured('Created box', 6000) + self.check('Creation source and result are distinct exact documents', prior != self.document_id) + self.check('Exactly one document created', self.native('document_set')['openDocuments'] == sorted(before_documents + ['owned:' + self.document_id])) + original_status = self.history(creation, result, prior) + documents = self.native('document_set')['openDocuments'] + replay = self.dispatch(creation) + self.check('Creation replay is byte-equivalent semantic result', replay.get('replayed') is True and native_signature(replay) == native_signature(result), replay) + self.check('Creation replay creates no document or geometry', documents == self.native('document_set')['openDocuments'] and equivalent(created, self.witness())) + changed = copy.deepcopy(creation); changed['box']['depth'] = 11 + conflict = self.dispatch(changed, expected='operation_conflict') + self.check('Changed digest refuses without model/document effects', conflict.get('errorCode') == 'operation_conflict' + and documents == self.native('document_set')['openDocuments'] and equivalent(created, self.witness())) + self.check('Conflict preserves original durable record', self.history(creation, result, prior) == original_status) + + inspection = self.inspect() + parameter = box_depth_parameter(inspection) + self.check('Owned box depth identity, units and initial value match', True, parameter) + depth = parameter['name'] + edit = self.request('depth', 'parameter_set', name=depth, expression='20 mm') + edited = self.dispatch(edit) + self.inspect(); after = self.measured('Edited box', 12000) + edited_status = self.history(edit, edited, self.document_id) + transport_changed = {**edit, 'caller':{'sessionId':'ralph-envelope-only'}, '_callerBudgetMs':60000} + repeated = self.dispatch(transport_changed) + self.check('Transport metadata preserves canonical identity', repeated.get('replayed') is True + and native_signature(repeated) == native_signature(edited), repeated) + self.check('Parameter replay preserves exact geometry and terminal history', equivalent(after, self.witness()) + and self.history(edit, edited, self.document_id) == edited_status) + + self.inspect() + failing = self.request('abort', 'parameter_batch', changes=[ + {'action':'set','name':depth,'expression':'15 mm'}, + {'action':'set','name':depth,'expression':'MissingDurableDepth + 1 mm'}]) + failure = self.dispatch(failing, expected='mutation_failed') + self.check('Intended atomic failure verifies recovery', failure.get('causeCode') == 'invalid_expression' + and failure.get('recovery', {}).get('verified') is True, failure) + self.check('Independent native geometry/parameter witness restored', equivalent(after, self.witness())) + failed_status = self.history(failing, failure, self.document_id) + replay_failure = self.dispatch(failing, expected='mutation_failed') + self.check('Failed operation replay remains the original failure', replay_failure.get('replayed') is True + and native_signature(replay_failure) == native_signature(failure)) + self.check('Failed replay preserves witness and immutable terminal record', equivalent(after, self.witness()) + and self.history(failing, failure, self.document_id) == failed_status) + self.inspect() + self.report['acceptanceScope']['ordinary'] = 'PASS' + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('mode', choices=('plan','run','recover'), nargs='?', default='plan') + for name in ('target','host-name','evidence','expect-bridge','expect-addin','shotlog-channel'): + parser.add_argument('--' + name) + parser.add_argument('--inject-failure', choices=('after-new','after-model')) + args = parser.parse_args(argv) + if args.mode == 'plan': + print(json.dumps({'hostCalls':False, 'campaign':'ordinary', 'notRun':list(EXCLUDED), + 'checks':['public durable admission/status','exact creation and parameter replay','changed-payload conflict', + 'failed atomic update and immutable failure replay','native geometry and F3D roundtrip','exact owned cleanup'], + 'visualReview':'Use ralph_mechanical.py review with hash-bound annotations after native completion.'}, indent=2)) + return 0 + if not all((args.target,args.host_name,args.evidence)): parser.error('Explicit target, host and evidence are required') + if args.mode == 'recover': return shared_recover(args, campaign_type=DurableOperations) + if not all((args.expect_bridge,args.expect_addin)): parser.error('Explicit expected versions are required') + directory = Path(args.evidence) + if directory.exists(): parser.error('Evidence directory must be new') + directory.mkdir(parents=True) + args.fixtures, args.iterations = ['ordinary'], 1 + campaign = DurableOperations(args) + def interrupt(signum, _): raise KeyboardInterrupt('Signal ' + str(signum)) + signal.signal(signal.SIGTERM, interrupt); signal.signal(signal.SIGINT, interrupt) + code = campaign.run() + print(json.dumps({'overall':campaign.report['overall'], 'report':str(directory / 'report.json')}, indent=2)) + return code + + +if __name__ == '__main__': sys.exit(main()) diff --git a/tests/ralph_mechanical.py b/tests/ralph_mechanical.py new file mode 100644 index 0000000..1c2b14f --- /dev/null +++ b/tests/ralph_mechanical.py @@ -0,0 +1,614 @@ +#!/usr/bin/env python3 +"""Bounded background Fusion mechanical campaign using the public bridge CLI. + +Examples (no host calls are made by plan or review): + python3 tests/ralph_mechanical.py plan + python3 tests/ralph_mechanical.py baseline --target MACHINE --host-name MACHINE --evidence DIR + python3 tests/ralph_mechanical.py run --target MACHINE --host-name MACHINE --evidence NEW_DIR \ + --expect-bridge VERSION --expect-addin VERSION --iterations 1 + python3 tests/ralph_mechanical.py recover --evidence EXISTING_DIR --target MACHINE --host-name MACHINE + python3 tests/ralph_mechanical.py review --evidence DIR --reviewer NAME --annotations review.json + +review.json is a list of {sha256, verdict: PASS|FAIL, notes}. A successful native +run exits 0 but remains VISUAL_PENDING until every image is explicitly reviewed. +The runner sends no runtime lifecycle commands or foreground input, and requires +a running baseline. The underlying transport may still autoheal between calls; +session and ownership guards refuse continuation if that changes the runtime. +""" +import argparse +import base64 +import copy +import hashlib +import json +import math +from pathlib import Path +import signal +import subprocess +import sys +import time +import uuid + +from fusion_harness import BridgeClient, CallResult, OwnedRun, data_payload, evaluate, observe, run_id +from ralph_support import (BASELINE_SCRIPT, artifact, equivalent, health_identity_request, + health_probe_script, native_script, validate_live_identity, visual_review, write_json) + + +PLAN = { + "fixtures": { + "bracket": "Constrained plate with through hole; parameter changes, failed atomic batch, F3D reopen and viewport.", + "assembly": "Two shared blocks and one independent copy; source edit, placement, native mass, BOM/CSV and F3D.", + "supplier": "Optional explicit local supplier CAD and SHA/provenance; repeated instances, BOM and F3D."}, + "requiredVerbs": ["fusion_state", "fusion_addin_status", "bridge_list", "run_script", "fusion_document_info", "fusion_get_preferences", + "fusion_mech_capabilities", "fusion_mechanical", "fusion_mech_operation_status", "fusion_run_modeling_script"], + "constraints": ["Explicit target, native host name, and expected versions; runtime must already be ready.", + "Requires an existing active Design; refuses a zero-document baseline to avoid implicit creation.", + "Every typed mutation dispatched once; ambiguous results reconcile by operation status only.", + "Exact live document IDs plus native object/marker/checkpoint guards; creationId is evidence only.", + "Preferences and pins are observed and compared; no preference setters or runtime lifecycle calls.", + "One to five iterations. Native viewport evidence requires separate SHA-bound review.", + "Interrupted or changed-session cleanup retains documents rather than inferring ownership."], + "scope": "Representative maintained fixtures, not exhaustive feature/options or application UI coverage."} + + +class Campaign: + def __init__(self, args, client=None): + self.args = args + self.directory = Path(args.evidence).resolve() + self.id = run_id() + self.client = client or BridgeClient(args.target, self.id, ai_thread="Fusion mechanical Ralph", evidence_dir=self.directory / "calls") + self.report = {"runId": self.id, "target": args.target, "host": args.host_name, + "native": "RUNNING", "cleanup": "NOT_STARTED", "visual": {"status": "PENDING"}, + "overall": "RUNNING", "checks": [], "artifacts": [], "fixtures": [], + "requestedFixtures": ["%02d-%s" % (i, f) + for i in range(1, getattr(args, "iterations", 0) + 1) + for f in getattr(args, "fixtures", [])], + "limitations": PLAN["scope"]} + self.session = None + self.target = None + self.owned = None + self.document_id = None + self.fixture = None + self.ambiguous = False + self.last_operation = None + + def persist(self): + write_json(self.directory / "report.json", self.report) + + def check(self, label, condition, evidence=None): + row = {"label": label, "status": "PASS" if condition else "FAIL", "evidence": evidence} + self.report["checks"].append(row) + self.persist() + if not condition: + raise RuntimeError(label) + + def call(self, verb, args=None, *, mutating=False, expected=None, timeout=190): + result = self.client.call(verb, args, timeout=timeout, mutating=mutating) + if result.ambiguous: + self.ambiguous = True + if verb == "fusion_mechanical" and args and args.get("operationId"): + reconciled = self.client.reconcile_mechanical(result, self.session, args["operationId"], polls=8, interval=1) + write_json(self.directory / ("reconcile-" + args["operationId"] + ".json"), reconciled) + if reconciled["state"] in ("succeeded", "failed"): + self.ambiguous = False + raw = reconciled["result"] + else: + raise RuntimeError("Unresolved mutation; no replay and no dependent cleanup") + else: + raise RuntimeError("Uncertain native lifecycle action; use recover to inspect its registry") + else: + raw = result.raw + errors = evaluate(raw, {"errorCode": expected} if expected else {"ok": True}) + if errors: + raise RuntimeError(verb + ": " + "; ".join(errors)) + payload = observe(raw).payload + return payload + + def native(self, action, **values): + if self.ambiguous: + raise RuntimeError("Unresolved operation prevents native lifecycle actions") + script = native_script(action, run_id=self.id, host=self.args.host_name, session_id=self.session, **values) + response = self.call("fusion_run_modeling_script", {"script": script}, + mutating=action in {"begin", "register", "activate", "close", "checkpoint", "capture", "restore"}) + result = response["data"]["result"] + if result.get("success") is not True: + raise RuntimeError("Native adapter returned no explicit success") + return result + + def runtime_observation(self, require_versions=False): + state = self.call("fusion_state", {"screenshot": False, "nativeViewport": False}) + addin = self.call("fusion_addin_status") + bridges = self.call("bridge_list") + runtime = next((x for x in bridges.get("bridges", []) if x.get("name") == "fusion360"), None) + if not runtime: + raise RuntimeError("Fusion runtime is absent from target bridge inventory") + # Guard before pass-through queries: the transport can launch Fusion + # for a cold application, and raw scripts create an implicit document. + if not (state.get("running") is True and state.get("addinResponding") is True and + not state.get("mainThreadBusy") and not state.get("blockingDialogs") and + runtime.get("paused") is False and not runtime.get("entryMismatch")): + raise RuntimeError("Fusion must already be running, responsive, idle and free of blocking dialogs") + if require_versions and not (addin.get("addinStale") is False and addin.get("version") == self.args.expect_addin): + raise RuntimeError("Expected runtime versions differ; no native pass-through permitted") + request = health_identity_request(runtime, self.args.host_name) + response = self.call("run_script", {"interpreter": "powershell", + "scriptB64": base64.b64encode(health_probe_script(request).encode()).decode(), "timeoutSeconds": 30, + "reason": "Read the discovered Fusion bridge process and its loopback health version without changing runtime state"}, timeout=40) + if response.get("exitCode") != 0 or response.get("timedOut") is True: + raise RuntimeError("Read-only native bridge identity probe failed: " + str(response.get("stderr", ""))) + identity = validate_live_identity(request, json.loads(response["stdout"]), + self.args.expect_bridge if require_versions else None, self.args.expect_addin if require_versions else None) + runtime = {**runtime, "liveIdentity": identity} + write_json(self.directory / "live-runtime-identity.json", {"reportedRunningVersion": runtime.get("runningVersion"), + "installedVersion": runtime.get("installedVersion"), "expectedIdentity": request, "observed": identity}) + return state, addin, runtime + + def baseline(self, require_versions=False): + state, addin, runtime = self.runtime_observation(require_versions) + document_info = self.call("fusion_document_info") + # This verb's raw script path auto-creates a design when no docs exist. + # Confirm a nonempty document inventory before using even a read script. + payload = data_payload(observe(document_info)) + docs = payload.get("openDocuments", payload.get("documents")) + if not isinstance(docs, list) or not docs: + raise RuntimeError("No open document inventory; native baseline cannot remain read-only") + native = self.call("fusion_run_modeling_script", {"script": BASELINE_SCRIPT})["data"]["result"] + if native["host"].lower() != self.args.host_name.lower(): + raise RuntimeError("Native host differs from --host-name") + if native.get("activeProductType") != "DesignProductType": + raise RuntimeError("Baseline requires an existing active Design product") + preferences = self.call("fusion_get_preferences").get("current") + baseline = {"state": state, "addin": addin, "runtime": {k: runtime.get(k) for k in + ("name", "devPinned", "paused", "runningVersion", "installedVersion", "entryMismatch", "instanceCount")}, + "liveIdentity": runtime["liveIdentity"], "preferences": preferences, "native": native} + write_json(self.directory / "baseline.json", baseline) + if require_versions: + if not (state.get("running") is True and state.get("addinResponding") is True and + not state.get("mainThreadBusy") and not state.get("blockingDialogs") and + addin.get("addinStale") is False and addin.get("version") == self.args.expect_addin): + raise RuntimeError("Readiness or expected runtime version differs; no mutation permitted") + if runtime["liveIdentity"]["health"]["bridgeVersion"] != self.args.expect_bridge: + raise RuntimeError("Actual native bridge does not match the requested version") + return baseline + + def mechanical(self, op, *, write=False, expected=None, **values): + if self.ambiguous: + raise RuntimeError("Previous operation is unresolved") + args = {"op": op, **values} + if op not in ("new_design", "inspect"): + args.setdefault("target", self.target) + if write: + args.update(sessionId=self.session, operationId=self.id + "-" + uuid.uuid4().hex[:12]) + self.last_operation = args["operationId"] + if self.owned: + self.owned.manifest["lastDispatch"] = copy.deepcopy(args) + self.owned.persist() + result = self.call("fusion_mechanical", args, mutating=write, expected=expected) + if result.get("sessionId") != self.session: + raise RuntimeError("Mechanical session changed") + fresh = result.get("data", {}).get("target", result.get("target")) + if fresh: + if self.document_id and op != "new_design" and fresh["documentId"] != self.document_id: + raise RuntimeError("Unexpected runtime document identity") + self.target = fresh + return result if expected else result["data"] + + def inspect(self): + data = self.mechanical("inspect") + if not data.get("complete"): + raise RuntimeError("Mechanical inspection is incomplete") + return data + + def entity(self, ref): + return self.mechanical("inspect_entity", entity=ref)["entity"] + + def sketch(self, name): + row = next(x for x in self.inspect()["sketches"] if x["name"] == name) + return self.entity(row["ref"]) + + def component(self, name): + return next(x for x in self.inspect()["components"] if x["name"] == name) + + def witness(self): + return self.native("witness", documentId=self.document_id)["witness"] + + def bind_document(self, row): + doc_id = row["documentId"] + if doc_id not in self.owned.manifest["documents"]: + self.owned.register_document(doc_id, row["role"], metadata={k: row.get(k) for k in ("creationId", "name", "closed")}) + else: + self.owned.manifest["documents"][doc_id]["metadata"]["closed"] = row.get("closed") + self.owned.persist() + self.owned.bind_document_cleanup(doc_id, lambda identity: self.native("close", documentId=identity), + lambda identity: self.native("verify", documentId=identity)) + if row.get("checkpoint"): + cp = row["checkpoint"] + self.owned.mark_checkpoint(doc_id, cp["path"], cp["sha256"], verified=True) + + def new(self, fixture): + self.fixture = fixture + self.document_id = None + data = self.mechanical("new_design", write=True, name="Ralph " + self.id + " " + fixture) + self.document_id = data["target"]["documentId"] + # Persist the public creation result before native registration, so an + # interrupted register remains visible instead of an untracked doc. + self.owned.manifest.setdefault("pendingRegistrations", {})[self.document_id] = data + self.owned.persist() + row = self.native("register", documentId=self.document_id, role=fixture) + self.bind_document(row) + del self.owned.manifest["pendingRegistrations"][self.document_id] + self.owned.persist() + self.inspect() # Ownership attributes conservatively change revision. + if self.args.inject_failure == "after-new": + raise RuntimeError("Requested cleanup diagnostic after owned document creation") + + def measured(self, label, volume, instances=1): + native = self.witness() + inspection = self.inspect() + self.check(label + " complete healthy timeline", inspection["health"]["healthy"] is True and inspection["health"]["complete"] is True, inspection["health"]) + public = self.mechanical("assembly_properties") + self.check(label + " native volume", equivalent(native["physical"]["volume"], volume, 2e-5), native) + self.check(label + " independent instance volume", equivalent(native["instanceVolume"], volume, 2e-5), native["instanceVolume"]) + self.check(label + " native instance count", native["instanceCount"] == instances, native["instanceCount"]) + self.check(label + " public and native physical properties", all(equivalent(public[k], native["physical"][k]) for k in ("volume", "mass", "centerOfMass")), public) + return native + + def create_sketch(self, name, items, plane="xy", component=None): + self.inspect() + values = {"name": name, "plane": plane} + if component: values["component"] = component + sk = self.mechanical("sketch_create", write=True, **values)["sketch"] + return self.mechanical("sketch_geometry", write=True, sketch=sk["ref"], items=items)["sketch"] + + def bracket(self): + self.new(self.fixture) + self.mechanical("parameter_batch", write=True, changes=[{"action": "create", "name": n, "expression": v, "unit": "mm"} + for n, v in (("Width", "40 mm"), ("Height", "20 mm"), ("Thickness", "8 mm"))]) + self.create_sketch("Bracket outline", [{"type": "rectangle", "start": [0, 0], "end": [40, 20]}]) + for index in range(4): + sk = self.sketch("Bracket outline") + line = sk["curves"][index] + a, b = line["startSketchPoint"]["position"], line["endSketchPoint"]["position"] + kind = "horizontal" if abs(a[1] - b[1]) < 1e-7 else "vertical" + self.mechanical("sketch_constraint", write=True, sketch=sk["ref"], type=kind, entities=[line["ref"]]) + sk = self.sketch("Bracket outline") + self.mechanical("sketch_constraint", write=True, sketch=sk["ref"], type="fix", entities=[sk["curves"][0]["startSketchPoint"]["ref"]]) + for kind, expression, axis, position in (("horizontal", "Width", 1, [20, -5]), ("vertical", "Height", 0, [45, 10])): + sk = self.sketch("Bracket outline") + line = next(c for c in sk["curves"] if abs(c["startSketchPoint"]["position"][axis] - c["endSketchPoint"]["position"][axis]) < 1e-7) + self.mechanical("sketch_dimension", write=True, sketch=sk["ref"], type=kind, + entities=[line["startSketchPoint"]["ref"], line["endSketchPoint"]["ref"]], expression=expression, position=position) + sk = self.sketch("Bracket outline") + self.check("Bracket sketch is fully constrained", sk["fullyConstrained"] is True) + self.mechanical("feature_create", write=True, type="extrude", name="Bracket solid", profiles=[sk["profiles"][0]["ref"]], distance="Thickness") + self.measured("Bracket before hole", 6400) + plane = self.mechanical("plane_create", write=True, type="offset", plane="xy", offset="Thickness", name="Hole plane")["plane"]["ref"] + self.create_sketch("Hole point", [{"type": "point", "position": [20, 10]}], plane=plane) + data = self.inspect() + sk = self.sketch("Hole point") + point = next(p for p in sk["points"] if equivalent(p["position"][:2], [20, 10])) + self.mechanical("feature_create", write=True, type="hole", name="Mounting hole", points=[point["ref"]], diameter=6, depth="Thickness", bodies=[{**data["bodies"][0]["ref"], **self.target}]) + self.measured("Bracket with mounting hole", 6400 - math.pi * 9 * 8) + self.mechanical("parameter_batch", write=True, changes=[{"action": "set", "name": "Width", "expression": "50 mm"}, {"action": "set", "name": "Thickness", "expression": "10 mm"}]) + before = self.measured("Bracket after parameter edit", 10000 - math.pi * 9 * 10) + self.inspect() + failure = self.mechanical("parameter_batch", write=True, expected="mutation_failed", changes=[ + {"action": "set", "name": "Width", "expression": "60 mm"}, + {"action": "set", "name": "Thickness", "expression": "NoSuchParameter + 1 mm"}]) + self.check("Failed parameter batch confirms recovery", failure.get("recovery", {}).get("verified") is True, failure) + self.check("Failed parameter batch has the intended cause", failure.get("causeCode") == "invalid_expression", failure) + self.check("Failed parameter batch restores native geometry", equivalent(before, self.witness())) + self.inspect() + status = self.call("fusion_mech_operation_status", {"sessionId": self.session, "operationId": self.last_operation}, expected="mutation_failed") + self.check("Failed operation has a terminal ledger result", status.get("state") == "failed", status) + + def assembly(self): + self.new(self.fixture) + self.mechanical("component_create", write=True, name="Shared", transform={"position": [0, 0, 0]}) + component = self.component("Shared") + self.create_sketch("Block section", [{"type": "rectangle", "start": [0, 0], "end": [10, 10]}], component=component["ref"]) + before = {p["name"] for p in self.inspect()["parameters"]} + sk = self.sketch("Block section") + component = self.component("Shared") + self.mechanical("feature_create", write=True, type="extrude", name="Block depth", component=component["ref"], profiles=[{**sk["profiles"][0]["ref"], **self.target}], distance=10) + parameters = [p for p in self.inspect()["parameters"] if p["name"] not in before and p["unit"] == "mm" and equivalent(p["value"], 10)] + self.check("Assembly driving depth is unambiguous", len(parameters) == 1, parameters) + component = self.component("Shared") + self.mechanical("occurrence_insert", write=True, component=component["ref"], transform={"position": [30, 0, 0]}) + component = self.component("Shared") + copy_data = self.mechanical("occurrence_insert", write=True, component=component["ref"], independentCopy=True, transform={"position": [60, 0, 0]})["occurrence"] + self.check("Independent copy has a separate definition", copy_data["component"]["id"] != component["id"]) + self.mechanical("component_metadata", write=True, component=copy_data["component"]["ref"], changes={"name": "Independent", "partNumber": "RALPH-COPY"}) + component = self.component("Shared") + self.mechanical("component_metadata", write=True, component=component["ref"], changes={"partNumber": "RALPH-SHARED", "description": "Repeated block fixture"}) + self.inspect() + self.mechanical("parameter_set", write=True, name=parameters[0]["name"], expression="20 mm") + native = self.measured("Repeated assembly", 5000, 3) + self.check("Shared edit leaves independent copy unchanged", equivalent(sorted(sum(b["volume"] for b in c["bodies"]) for c in native["components"] if c["name"] != ""), [1000, 2000]), native["components"]) + self.check("All occurrence placements persist after recompute", equivalent(sorted([o["transform"][i] for i in (3, 7, 11)] for o in native["occurrences"]), [[0, 0, 0], [30, 0, 0], [60, 0, 0]]), native["occurrences"]) + self.verify_bom(5000, {"RALPH-SHARED": 2, "RALPH-COPY": 1}) + + def supplier(self): + self.new(self.fixture) + provenance = {"supplier": self.args.supplier_name, "supplierPartNumber": self.args.supplier_part, "sourceUrl": self.args.supplier_url} + self.mechanical("cad_import", write=True, filename=self.args.supplier_file, sha256=self.args.supplier_sha256, + name="Purchased", provenance=provenance) + comp = self.component("Purchased") + self.mechanical("component_metadata", write=True, component=comp["ref"], changes={"asPurchased": True, "partNumber": self.args.supplier_part}) + comp = self.component("Purchased") + self.mechanical("occurrence_insert", write=True, component=comp["ref"], transform={"position": [50, 0, 0]}) + native = self.witness() + self.check("Supplier import contains positive native volume", native["physical"]["volume"] > 0, native) + bom = self.verify_bom(native["physical"]["volume"], {self.args.supplier_part: 2}) + part = next(p for p in bom["parts"] if p["partNumber"] == self.args.supplier_part) + self.check("Supplier BOM preserves checksum and provenance", all(part.get(k) == v for k, v in {**provenance, "sha256": self.args.supplier_sha256.lower()}.items()), part) + + def verify_bom(self, volume, quantities): + self.inspect() + bom = self.mechanical("bom") + self.check("BOM has one row per expected definition", len(bom["parts"]) == len(quantities) and len({p["partNumber"] for p in bom["parts"]}) == len(quantities), bom["parts"]) + self.check("BOM quantities match actual instances", {p["partNumber"]: p["quantity"] for p in bom["parts"]} == quantities, bom) + self.check("BOM physical total agrees with native instances", equivalent(bom["physical"]["volume"], volume), bom["physical"]) + for mode, expected in (("flat", bom["parts"]), ("structured", bom["instances"])): + self.inspect() + path = self.owned.manifest["hostScratch"] + "/" + self.fixture + "-" + mode + ".csv" + self.mechanical("bom_export", write=True, filename=path, mode=mode) + out = self.native("file", path=path, csv=True) + rows = out["rows"] + self.check(mode + " CSV row count", len(rows) == len(expected), rows) + if mode == "flat": + self.check("Flat CSV definitions are unique", len(rows) == len(quantities) and len({x["partNumber"] for x in rows}) == len(quantities), rows) + self.check("Flat CSV quantities", {x["partNumber"]: int(x["quantity"]) for x in rows} == quantities, rows) + else: + self.check("Structured CSV hierarchy", [(x["path"], x["parentPath"], int(x["depth"])) for x in rows] == [(x["path"], x["parentPath"] or "", x["depth"]) for x in expected], rows) + self.save_artifact(out, "bom") + return bom + + def save_artifact(self, out, kind): + data = base64.b64decode(out["base64"], validate=True) + if hashlib.sha256(data).hexdigest() != out["sha256"]: + raise RuntimeError("Transferred artifact hash differs from host") + path = self.directory / "artifacts" / Path(out["path"].replace("\\", "/")).name + path.parent.mkdir(parents=True, exist_ok=True) + if path.exists(): + raise RuntimeError("Local artifact destination already exists") + path.write_bytes(data) + item = {**artifact(path, out["sha256"]), "kind": kind, "hostPath": out["path"], "fixture": self.fixture} + self.owned.register_file(out["path"], out["sha256"], retain=True) + self.report["artifacts"].append(item) + if kind == "viewport" and self.args.shotlog_channel: + process = subprocess.run(["shotlog", "inject", "--channel", self.args.shotlog_channel, "--native", "--desc", self.fixture, + "--source", "Fusion native viewport", str(path)], capture_output=True, text=True, timeout=30) + item["shotlog"] = {"returncode": process.returncode, "stdout": process.stdout, "stderr": process.stderr} + if process.returncode: + raise RuntimeError("Requested shotlog injection failed") + self.persist() + + def checkpoint(self, capture=True): + self.inspect() + stem = self.fixture + "-" + uuid.uuid4().hex[:8] + path = self.owned.manifest["hostScratch"] + "/" + stem + ".f3d" + if capture: + image_path = self.owned.manifest["hostScratch"] + "/" + stem + ".png" + self.native("capture", documentId=self.document_id, path=image_path) + self.save_artifact(self.native("file", path=image_path), "viewport") + self.mechanical("export", write=True, format="f3d", outputPath=path) + result = self.native("checkpoint", documentId=self.document_id, path=path) + self.check(self.fixture + " archive preserves native witness", equivalent(result["before"], result["after"]), result["archive"]) + for row in (result["source"], result["reopened"]): + self.bind_document(row) + self.save_artifact(self.native("file", path=path), "native-archive") + # Imported object is exact, registered and checkpointed before closing. + self.native("close", documentId=result["reopened"]["documentId"]) + self.native("activate", documentId=self.document_id) + + def cleanup(self): + if self.owned is None: + self.report["cleanup"] = "NOT_NEEDED" + return + try: + if self.ambiguous: + raise RuntimeError("Unknown operation still outstanding; owned documents retained") + inventory = self.native("inventory") + if inventory.get("provisionalImportCount"): + raise RuntimeError("An imported native object lacks public identity; retained in native registry") + for row in inventory["documents"]: + self.bind_document(row) + # If fixture assertions failed, checkpoint only the exact currently + # active owned object. Other uncheckpointed docs remain retained. + if self.document_id in self.owned.manifest["documents"]: + record = self.owned.manifest["documents"][self.document_id] + if not (record.get("checkpoint") or {}).get("verified"): + self.checkpoint(capture=False) + results = self.owned.cleanup() + self.report["cleanupEvidence"] = results + latest = self.owned.manifest.get("cleanupLatest", {}) + self.report["cleanupLatest"] = latest + if self.owned.manifest.get("pendingRegistrations"): + raise RuntimeError("An owned creation was not reconciled into native registry") + if any(x["status"] != "PASS" for x in (latest.values() if latest else results)): + raise RuntimeError("Exact guarded cleanup retained one or more documents") + restored = self.native("restore") + prefs = self.call("fusion_get_preferences")["current"] + _, _, current = self.runtime_observation(require_versions=True) + self.check("Original documents restored", restored["documents"] == self.report["baseline"]["native"]["documents"], restored) + self.check("Preferences preserved", prefs == self.report["baseline"]["preferences"], prefs) + original = self.report["baseline"]["runtime"] + stable_fields = set(original) - {"runningVersion"} + self.check("Runtime configuration and development pin preserved", all(current.get(k) == original[k] for k in stable_fields), {k: current.get(k) for k in original}) + live_before = self.report["baseline"]["liveIdentity"] + live_after = current["liveIdentity"] + self.check("Actual native runtime identity preserved", all(live_after.get(k) == live_before.get(k) for k in + ("host", "pid", "port", "executablePath", "processCreatedUtc")), live_after) + self.report["finalRuntimeIdentity"] = live_after + self.report["finalHost"] = restored + self.report["cleanup"] = "PASS" + except BaseException as exc: + self.report["cleanup"] = "FAIL" + self.report["cleanupError"] = str(exc) + self.persist() + + def run(self): + try: + baseline = self.baseline(require_versions=True) + self.report["baseline"] = baseline + capabilities = self.call("fusion_mech_capabilities") + self.session = capabilities["sessionId"] + self.report["sessionId"] = self.session + begin = self.native("begin", baseline=baseline["native"]["documents"]) + self.owned = OwnedRun(self.directory / "ownership.json", self.args.target, self.id, host_scratch=begin["scratch"]) + self.owned.manifest["sessionId"] = self.session + self.owned.manifest["host"] = self.args.host_name + self.owned.persist() + for iteration in range(1, self.args.iterations + 1): + for fixture in self.args.fixtures: + self.fixture = "%02d-%s" % (iteration, fixture) + start = len(self.report["checks"]) + getattr(self, fixture)() + if self.args.inject_failure == "after-model": + raise RuntimeError("Requested cleanup diagnostic after model creation") + self.checkpoint() + self.native("close", documentId=self.document_id) + self.report["fixtures"].append({"name": self.fixture, "status": "PASS", "checks": len(self.report["checks"]) - start}) + self.document_id = None + self.report["native"] = "PASS" + except BaseException as exc: + self.report["native"] = "FAIL" + self.report["failure"] = {"type": type(exc).__name__, "message": str(exc), "fixture": self.fixture} + finally: + self.cleanup() + completed = {row["name"] for row in self.report["fixtures"]} + for name in self.report["requestedFixtures"]: + if name not in completed: + self.report["fixtures"].append({"name": name, "status": "FAIL" if name == self.report.get("failure", {}).get("fixture") else "NOT_RUN"}) + self.report["overall"] = "VISUAL_PENDING" if self.report["native"] == "PASS" and self.report["cleanup"] == "PASS" else "FAIL" + self.report["counts"] = {status: sum(x["status"] == status for x in self.report["checks"]) for status in ("PASS", "FAIL")} + self.persist() + return 0 if self.report["overall"] == "VISUAL_PENDING" else 1 + + +def parser(): + command = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = command.add_subparsers(dest="mode", required=True) + sub.add_parser("plan", help="Print offline fixture/ownership contract; no host calls") + for mode in ("baseline", "run", "recover"): + p = sub.add_parser(mode) + p.add_argument("--target", required=True) + p.add_argument("--host-name", required=True, help="Expected native platform.node(), independent of routing alias") + p.add_argument("--evidence", required=True) + if mode == "run": + p.add_argument("--expect-bridge", required=True) + p.add_argument("--expect-addin", required=True) + p.add_argument("--iterations", type=int, choices=range(1, 6), default=1) + p.add_argument("--fixtures", nargs="+", choices=PLAN["fixtures"], default=["bracket", "assembly"]) + p.add_argument("--inject-failure", choices=("after-new", "after-model"), help="Owned-fixture cleanup diagnostic; expected nonzero run") + p.add_argument("--shotlog-channel", help="Inject every captured viewport into this explicitly selected channel") + p.add_argument("--supplier-file", help="Explicit existing host STEP/SAT path; never downloaded automatically") + p.add_argument("--supplier-sha256") + p.add_argument("--supplier-name") + p.add_argument("--supplier-part") + p.add_argument("--supplier-url") + review = sub.add_parser("review", help="Record independent visual annotations bound to exact PNG hashes") + review.add_argument("--evidence", required=True) + review.add_argument("--reviewer", required=True) + review.add_argument("--annotations", required=True) + return command + + +def recover(args, campaign_type=Campaign): + directory = Path(args.evidence).resolve() + report = json.loads((directory / "report.json").read_text()) + ownership = json.loads((directory / "ownership.json").read_text()) + if ownership["target"] != args.target or ownership.get("host") != args.host_name: + raise ValueError("Recovery target/host differs from recorded ownership") + args.shotlog_channel = None + runner = campaign_type(args) + runner.id, runner.report, runner.session = ownership["runId"], report, ownership["sessionId"] + runner.client = BridgeClient(args.target, runner.id, ai_thread="Fusion mechanical Ralph recovery", evidence_dir=directory / ("recovery-calls-" + uuid.uuid4().hex[:8])) + args.expect_bridge = report["baseline"]["liveIdentity"]["health"]["bridgeVersion"] + args.expect_addin = report["baseline"]["addin"]["version"] + runner.runtime_observation(require_versions=True) + caps = runner.call("fusion_mech_capabilities") + if caps["sessionId"] != runner.session: + raise RuntimeError("Runtime session changed; cannot prove native object identity. Documents retained.") + last = ownership.get("lastDispatch") + if last: + original = CallResult({"outcome": "unknown_after_interruption"}, observe({}), + [{"verb": "fusion_mechanical", "args": last, "requestJson": json.dumps(last), + "source": "persisted lastDispatch"}], False, 0, True) + reconciled = runner.client.reconcile_mechanical(original, runner.session, last["operationId"], polls=1) + write_json(directory / ("recovery-ledger-" + uuid.uuid4().hex[:8] + ".json"), reconciled) + if reconciled["state"] not in ("succeeded", "failed"): + raise RuntimeError("Last operation lacks a terminal status; no recovery mutation") + runner.owned = OwnedRun.resume(directory / "ownership.json", args.target, runner.id) + inventory = runner.native("inventory") + for row in inventory["documents"]: + runner.bind_document(row) + runner.owned.manifest.get("pendingRegistrations", {}).pop(row["documentId"], None) + runner.owned.persist() + # Recovery only closes already verified checkpoints. It never exports an + # unknown current model or guesses ownership from a document-list delta. + runner.cleanup() + report["overall"] = "VISUAL_PENDING" if report["native"] == "PASS" and report["cleanup"] == "PASS" else "FAIL" + report["counts"] = {status: sum(row["status"] == status for row in report["checks"]) + for status in ("PASS", "FAIL")} + runner.persist() + return 0 if report["cleanup"] == "PASS" else 1 + + +def main(argv=None): + args = parser().parse_args(argv) + if args.mode == "plan": + print(json.dumps(PLAN, indent=2)) + return 0 + if args.mode == "review": + path = Path(args.evidence) / "report.json" + report = json.loads(path.read_text()) + annotations = json.loads(Path(args.annotations).read_text()) + report["visual"] = visual_review(report, annotations, args.reviewer) + report["overall"] = "PASS" if report["native"] == "PASS" and report["cleanup"] == "PASS" and report["visual"]["status"] == "PASS" else "FAIL" + write_json(path, report) + print(json.dumps({"overall": report["overall"], "visual": report["visual"]}, indent=2)) + return 0 if report["overall"] == "PASS" else 1 + if args.mode == "recover": + return recover(args) + directory = Path(args.evidence) + if directory.exists(): + raise ValueError("Evidence directory must be new; use recover for an interrupted run") + if args.mode == "run" and "supplier" in args.fixtures: + if not all((args.supplier_file, args.supplier_sha256, args.supplier_name, args.supplier_part, args.supplier_url)): + raise ValueError("Supplier fixture requires explicit path, SHA-256, supplier, part and HTTPS source URL") + if len(args.supplier_sha256) != 64 or any(c not in "0123456789abcdefABCDEF" for c in args.supplier_sha256): + raise ValueError("Invalid supplier SHA-256") + if args.mode == "run" and len(set(args.fixtures)) != len(args.fixtures): + raise ValueError("Each fixture can appear once; use bounded --iterations for repetition") + directory.mkdir(parents=True) + runner = Campaign(args) + runner.persist() + if args.mode == "baseline": + runner.report.update(native="NOT_RUN", cleanup="NOT_NEEDED") + try: + runner.report["baseline"] = runner.baseline() + runner.report["overall"] = "BASELINE_RECORDED" + code = 0 + except BaseException as exc: + runner.report["overall"] = "BASELINE_REFUSED" + runner.report["failure"] = {"type": type(exc).__name__, "message": str(exc)} + code = 1 + runner.persist() + print(json.dumps({"overall": runner.report["overall"], "report": str(directory / "report.json")}, indent=2)) + return code + def interrupted(signum, _frame): + raise KeyboardInterrupt("Signal " + str(signum)) + signal.signal(signal.SIGTERM, interrupted) + signal.signal(signal.SIGINT, interrupted) + result = runner.run() + print(json.dumps({"overall": runner.report["overall"], "counts": runner.report.get("counts"), "report": str(directory / "report.json")}, indent=2)) + return result + + +if __name__ == "__main__": + try: + sys.exit(main()) + except (RuntimeError, ValueError, OSError) as exc: + print(str(exc), file=sys.stderr) + sys.exit(1) diff --git a/tests/ralph_support.py b/tests/ralph_support.py new file mode 100644 index 0000000..4bea870 --- /dev/null +++ b/tests/ralph_support.py @@ -0,0 +1,423 @@ +"""Small, stdlib-only support for the background mechanical Ralph campaign. + +The native adapter uses mechanical.core only to obtain the public session-bound +document identity. Geometry measurements use independent Autodesk API calls. +No document is selected by name or creationId, and no preference is written. +""" +import hashlib +import json +import math +import base64 +from pathlib import Path, PureWindowsPath +import struct + + +def write_json(path, value): + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text(json.dumps(value, indent=2, allow_nan=False) + "\n") + temporary.replace(path) + + +def equivalent(a, b, tolerance=1e-7): + if isinstance(a, bool) or isinstance(b, bool): + return type(a) is type(b) and a == b + if isinstance(a, (float, int)) and isinstance(b, (float, int)): + return math.isfinite(a) and math.isfinite(b) and math.isclose(a, b, rel_tol=tolerance, abs_tol=tolerance) + if isinstance(a, dict) and isinstance(b, dict): + return a.keys() == b.keys() and all(equivalent(a[k], b[k], tolerance) for k in a) + if isinstance(a, list) and isinstance(b, list): + return len(a) == len(b) and all(equivalent(x, y, tolerance) for x, y in zip(a, b)) + return a == b + + +def health_identity_request(runtime, host): + """Accept only a single discovered native process and a concrete TCP port.""" + pids = runtime.get("livePids") + port = runtime.get("spawn", {}).get("runtimePort") + if not isinstance(pids, list) or len(pids) != 1 or type(pids[0]) is not int or pids[0] <= 0: + raise ValueError("Live bridge inventory must identify exactly one process") + if type(port) is not int or not 1 <= port <= 65535 or runtime.get("port") != port: + raise ValueError("Discovered bridge runtime port is absent or inconsistent") + path = PureWindowsPath(runtime.get("entrypoint", "")) + expected_image = runtime.get("expectedImage") + if not path.is_absolute() or ".." in path.parts or not isinstance(expected_image, str) or path.name.lower() != expected_image.lower(): + raise ValueError("Discovered executable identity is incomplete") + if runtime.get("spawn", {}).get("kind") != "exe" or runtime.get("instanceCount") != 1: + raise ValueError("A single native executable bridge is required") + return {"host": host, "pid": pids[0], "port": port, "executablePath": str(path), "image": expected_image} + + +def health_probe_script(request): + """Read process/listener identity and GET only its discovered loopback health.""" + encoded = base64.b64encode(json.dumps(request).encode()).decode() + return r''' +$ErrorActionPreference = 'Stop' +try { + $ralphExpected = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__REQUEST_B64__')) | ConvertFrom-Json + $ralphPort = [int]$ralphExpected.port + $ralphOwner = [int]$ralphExpected.pid + if ([Environment]::MachineName -ine [string]$ralphExpected.host) { throw 'Native host identity differs' } + $ralphProcess = Get-CimInstance Win32_Process -Filter ('ProcessId=' + $ralphOwner) + if (-not $ralphProcess -or $ralphProcess.Name -ine [string]$ralphExpected.image) { throw 'Inventory PID/image is not the native bridge' } + if ([IO.Path]::GetFullPath($ralphProcess.ExecutablePath) -ine [IO.Path]::GetFullPath([string]$ralphExpected.executablePath)) { throw 'Native executable path differs from inventory' } + $ralphListeners = @(Get-NetTCPConnection -State Listen -LocalPort $ralphPort) + if ($ralphListeners.Count -eq 0) { throw 'Discovered port has no listener' } + foreach ($ralphListener in $ralphListeners) { + if ($ralphListener.OwningProcess -ne $ralphOwner -or $ralphListener.LocalAddress -notin @('127.0.0.1','::1')) { throw 'Listener is not owned by the expected loopback bridge' } + } + $ralphHealth = Invoke-RestMethod -Uri ('http://127.0.0.1:' + $ralphPort + '/health') -Method Get -TimeoutSec 8 + $ralphAgain = Get-CimInstance Win32_Process -Filter ('ProcessId=' + $ralphOwner) + if (-not $ralphAgain -or $ralphAgain.CreationDate -ne $ralphProcess.CreationDate -or $ralphAgain.ExecutablePath -ine $ralphProcess.ExecutablePath) { throw 'Native process changed during health observation' } + $ralphListenersAfter = @(Get-NetTCPConnection -State Listen -LocalPort $ralphPort) + if ($ralphListenersAfter.Count -eq 0) { throw 'Listener disappeared during health observation' } + foreach ($ralphListener in $ralphListenersAfter) { + if ($ralphListener.OwningProcess -ne $ralphOwner -or $ralphListener.LocalAddress -notin @('127.0.0.1','::1')) { throw 'Listener changed during health observation' } + } + @{ host=[Environment]::MachineName; pid=$ralphOwner; port=$ralphPort; executablePath=$ralphProcess.ExecutablePath; + image=$ralphProcess.Name; processCreatedUtc=$ralphProcess.CreationDate.ToUniversalTime().ToString('o'); + listenerAddresses=@($ralphListenersAfter | ForEach-Object { $_.LocalAddress }); + stable=$true; health=$ralphHealth } | ConvertTo-Json -Depth 12 -Compress + exit 0 +} catch { + [Console]::Error.WriteLine($_.Exception.Message) + exit 1 +} +'''.replace('__REQUEST_B64__', encoded) + + +def validate_live_identity(request, evidence, bridge_version=None, addin_version=None): + """An inventory version is descriptive; live identity/version is decisive.""" + if not (evidence.get("host", "").lower() == request["host"].lower() and + evidence.get("pid") == request["pid"] and evidence.get("port") == request["port"] and + evidence.get("image", "").lower() == request["image"].lower() and + PureWindowsPath(evidence.get("executablePath", "")) == PureWindowsPath(request["executablePath"]) and + evidence.get("stable") is True and evidence.get("processCreatedUtc")): + raise ValueError("Observed native process identity differs from inventory or host") + addresses = evidence.get("listenerAddresses") + if not isinstance(addresses, list) or not addresses or any(x not in ("127.0.0.1", "::1") for x in addresses): + raise ValueError("Native health was not bound to an owned loopback listener") + health = evidence.get("health", {}) + if "sidecarPort" in health: + raise ValueError("Sidecar health cannot attest the native executable version") + if not (health.get("status") == "ok" and health.get("exePort") == request["port"] and + health.get("fusion", {}).get("running") is True and health.get("busy") is False and + health.get("addin", {}).get("busy") is False): + raise ValueError("Native loopback health is not idle and ready on the discovered port") + if bridge_version is not None and health.get("bridgeVersion") != bridge_version: + raise ValueError("Actual native bridge version differs from expected version") + if addin_version is not None and health.get("addin", {}).get("version") != addin_version: + raise ValueError("Actual native add-in version differs from expected version") + return evidence + + +def artifact(path, expected_sha=None): + path = Path(path) + data = path.read_bytes() + digest = hashlib.sha256(data).hexdigest() + if expected_sha is not None and digest != expected_sha: + raise ValueError("Artifact bytes differ from the recorded SHA-256: " + str(path)) + result = {"path": str(path.resolve()), "sha256": digest, "bytes": len(data)} + if path.suffix.lower() == ".png": + if data[:8] != b"\x89PNG\r\n\x1a\n" or data[12:16] != b"IHDR" or len(data) < 24: + raise ValueError("Invalid PNG header") + width, height = struct.unpack(">II", data[16:24]) + if width < 640 or height < 480: + raise ValueError("Viewport capture is too small for review") + result.update(width=width, height=height) + return result + + +def visual_review(report, annotations, reviewer): + """Bind explicit human/independent-agent review to every captured byte set.""" + if not reviewer.strip(): + raise ValueError("An identified reviewer is required") + images = [x for x in report.get("artifacts", []) if x.get("kind") == "viewport"] + if not images: + raise ValueError("No viewport evidence exists") + by_sha = {row.get("sha256"): row for row in annotations} + if len(by_sha) != len(annotations) or set(by_sha) != {x["sha256"] for x in images}: + raise ValueError("Review must identify every current viewport SHA exactly once") + for image in images: + artifact(image["path"], image["sha256"]) + row = by_sha[image["sha256"]] + if row.get("verdict") not in ("PASS", "FAIL") or not isinstance(row.get("notes"), str) or not row["notes"].strip(): + raise ValueError("Each image needs a PASS/FAIL verdict and concrete visual notes") + return {"status": "PASS" if all(x["verdict"] == "PASS" for x in annotations) else "FAIL", + "reviewer": reviewer, "annotations": annotations, + "scope": "Native model viewport appearance; does not prove application chrome or picker UI."} + + +BASELINE_SCRIPT = r''' +import platform, tempfile +result = {'host': platform.node(), 'platform': platform.system(), 'fusionVersion': app.version, + 'temporaryDirectory': tempfile.gettempdir(), + 'activeCreationId': app.activeDocument.creationId if app.activeDocument else None, + 'activeProductType': app.activeProduct.productType if app.activeProduct else None, + 'activeCommand': ui.activeCommand, + 'documents': [{'creationId': d.creationId, 'name': d.name, + 'modified': d.isModified, 'saved': d.isSaved} for d in app.documents]} +''' + + +# This program executes inside Fusion through the public modeling-script verb. +# All actions are bounded and use a session-local registry of actual API objects. +# Import records the returned object before any readback that could fail. A +# timed-out adapter is reconciled by inventory, never re-dispatched blindly. +NATIVE_PROGRAM = r''' +import base64, builtins, csv, hashlib, json, math, os, platform, re, sys, tempfile + +def require(condition, message): + if not condition: + raise RuntimeError(message) + +def same(a, b): + if isinstance(a, bool) or isinstance(b, bool): + return type(a) is type(b) and a == b + if isinstance(a, (int, float)) and isinstance(b, (int, float)): + return math.isfinite(a) and math.isfinite(b) and math.isclose(a, b, rel_tol=1e-7, abs_tol=1e-7) + if isinstance(a, dict) and isinstance(b, dict): + return a.keys() == b.keys() and all(same(a[k], b[k]) for k in a) + if isinstance(a, list) and isinstance(b, list): + return len(a) == len(b) and all(same(x, y) for x, y in zip(a, b)) + return a == b + +def vector(p): + return [p.x * 10, p.y * 10, p.z * 10] + +def body_measure(body): + properties = body.getPhysicalProperties(adsk.fusion.CalculationAccuracy.HighCalculationAccuracy) + box = body.boundingBox + return {'name': body.name, 'volume': properties.volume * 1000, 'mass': properties.mass, + 'centerOfMass': vector(properties.centerOfMass), 'faces': body.faces.count, + 'bounds': [vector(box.minPoint), vector(box.maxPoint)], + 'material': body.material.name if body.material else None} + +def witness(document): + design = adsk.fusion.Design.cast(document.products.itemByProductType('DesignProductType')) + require(design is not None, 'Owned document is no longer a design') + root = design.rootComponent + components, occurrences = [], [] + all_bodies = [body_measure(b) for b in root.bRepBodies] + for comp in design.allComponents: + components.append({'name': '' if comp == root else comp.name, + 'partNumber': comp.partNumber, 'description': comp.description, + 'material': comp.material.name if comp.material else None, + 'attributes': {a.name: a.value for a in comp.attributes if a.groupName == 'AdomMechanical'}, + 'bodies': [body_measure(b) for b in comp.bRepBodies], + 'sketches': [{'name': s.name, 'fullyConstrained': s.isFullyConstrained, + 'curves': s.sketchCurves.count, 'profiles': s.profiles.count} for s in comp.sketches]}) + for occurrence in root.allOccurrences: + matrix = list(occurrence.transform2.asArray()) + for index in (3, 7, 11): matrix[index] *= 10 + bodies = [body_measure(b) for b in occurrence.bRepBodies] + all_bodies.extend(bodies) + occurrences.append({'path': occurrence.fullPathName, 'component': occurrence.component.name, + 'transform': matrix, 'bodies': bodies}) + props = root.getPhysicalProperties(adsk.fusion.CalculationAccuracy.HighCalculationAccuracy) + return {'parameters': {p.name: p.expression for p in design.allParameters}, + 'timelineCount': design.timeline.count, + 'components': sorted(components, key=lambda c: c['name']), + 'occurrences': sorted(occurrences, key=lambda o: o['path']), + 'physical': {'volume': props.volume * 1000, 'mass': props.mass, + 'centerOfMass': vector(props.centerOfMass)}, + 'instanceVolume': sum(b['volume'] for b in all_bodies), + 'instanceCount': len(all_bodies)} + +require(platform.node().lower() == REQUEST['host'].lower(), 'Host identity differs from expected host') +require(re.fullmatch(r'[A-Za-z0-9_-]{12,100}', REQUEST['runId']) is not None, 'Invalid run ID') +modules = [m for n, m in list(sys.modules.items()) + if n.endswith('.mechanical.core') and getattr(m, 'SESSION_ID', None) == REQUEST['sessionId']] +require(len(modules) == 1, 'Exact serving mechanical session is unavailable; no ownership inference') +core = modules[0] +registries = getattr(builtins, '_adom_mechanical_ralph_runs', None) +if registries is None: + registries = {} + builtins._adom_mechanical_ralph_runs = registries +action = REQUEST['action'] +run = registries.get(REQUEST['runId']) + +def documents(): + return [{'creationId': d.creationId, 'name': d.name, 'modified': d.isModified, 'saved': d.isSaved} + for d in app.documents] + +def idle(): + require(ui.activeCommand in ('', 'SelectCommand', 'Select'), 'Interactive command active; refusing document lifecycle action') + +def fingerprint(document): + return {'creationId': document.creationId, 'name': document.name, + 'modified': document.isModified, 'saved': document.isSaved} + +def owned(document_id, allow_closed=False): + require(document_id in run['documents'], 'Document has no exact native ownership entry') + row = run['documents'][document_id] + if row.get('closed'): + require(allow_closed, 'Owned document is already closed') + return row + document = row['object'] + require(any(d == document for d in app.documents), 'Exact owned document is no longer open') + require(not any(d == document for d in run['baselineObjects']), 'Refusing a baseline document') + require(document.name == row['name'] and not document.isSaved, 'Owned document was renamed or saved externally') + design = adsk.fusion.Design.cast(document.products.itemByProductType('DesignProductType')) + marker = design.rootComponent.attributes.itemByName('AdomRalph', 'owner') + require(marker and marker.value == REQUEST['runId'], 'Run ownership marker changed') + marker = design.rootComponent.attributes.itemByName('AdomRalph', 'documentId') + require(marker and marker.value == document_id, 'Runtime document marker changed') + return row + +def path_in_scratch(path): + root = os.path.realpath(run['scratch']) + path = os.path.abspath(path) + require(os.path.commonpath([root, os.path.realpath(path)]) == root and os.path.realpath(path) != root, + 'Artifact is not inside the owned scratch directory') + require(not os.path.islink(path), 'Artifact is a symlink') + return path + +def file_info(path): + path = path_in_scratch(path) + require(os.path.isfile(path), 'Checkpoint file is absent') + require(os.path.getsize(path) <= 32 * 1024 * 1024, 'Fixture artifact exceeds 32 MiB budget') + with open(path, 'rb') as stream: data = stream.read() + return {'path': path.replace('\\', '/'), 'sha256': hashlib.sha256(data).hexdigest(), 'bytes': len(data)} + +def register(document, document_id, role): + require(not any(d == document for d in run['baselineObjects']), 'Creation returned a baseline document') + require(document_id not in run['documents'], 'Runtime ID is already registered') + # Retain exact object first, including if marker creation later fails. + row = {'object': document, 'documentId': document_id, 'role': role, + 'creationId': document.creationId, 'name': document.name, 'closed': False, 'checkpoint': None} + run['documents'][document_id] = row + require(not document.isSaved and document.name.startswith('Untitled'), 'Only new unnamed designs are owned') + design = adsk.fusion.Design.cast(document.products.itemByProductType('DesignProductType')) + design.rootComponent.attributes.add('AdomRalph', 'owner', REQUEST['runId']) + design.rootComponent.attributes.add('AdomRalph', 'documentId', document_id) + return row + +def public_row(row): + return {k: v for k, v in row.items() if k not in ('object', 'witness')} + +if action == 'begin': + idle() + require(run is None, 'Native run registry already exists; use recover') + require(app.documents.count > 0, 'An existing active design is required for a read-only baseline') + baseline = documents() + require(baseline == REQUEST['baseline'], 'Documents changed after baseline; refusing to start') + scratch = os.path.join(tempfile.gettempdir(), 'adom-fusion-ralph', REQUEST['runId']) + require(not os.path.exists(scratch), 'Run scratch already exists') + parent = os.path.dirname(scratch) + require(os.path.realpath(parent) == os.path.abspath(parent), 'Scratch parent resolves through a link') + os.makedirs(scratch) + run = {'sessionId': REQUEST['sessionId'], 'documents': {}, 'scratch': scratch, + 'baselineObjects': list(app.documents), 'baseline': baseline, + 'activeObject': app.activeDocument, 'activeFingerprint': fingerprint(app.activeDocument)} + registries[REQUEST['runId']] = run + result = {'success': True, 'scratch': scratch.replace('\\', '/'), 'documents': baseline} +else: + require(run is not None and run['sessionId'] == REQUEST['sessionId'], 'Native registry/session missing; no close attempted') + if action == 'inventory': + result = {'success': True, 'documents': [public_row(r) for r in run['documents'].values()], + 'provisionalImportCount': len(run.get('provisionalImports', [])), + 'scratch': run['scratch'].replace('\\', '/')} + elif action == 'register': + idle() + document, design, document_id = core.active(app) + require(document_id == REQUEST['documentId'], 'Active runtime document differs from typed creation result') + row = register(document, document_id, REQUEST['role']) + result = {'success': True, **public_row(row)} + elif action in ('witness', 'verify', 'activate', 'close', 'checkpoint', 'capture'): + row = owned(REQUEST['documentId'], action in ('close', 'verify')) + document = row['object'] + if action == 'verify': + if not row.get('closed'): + checkpoint = row['checkpoint'] + require(checkpoint is not None, 'No verified native checkpoint') + require(file_info(checkpoint['path'])['sha256'] == checkpoint['sha256'], 'Checkpoint bytes changed') + require(same(witness(document), row['witness']), 'Model changed since checkpoint') + result = {'success': True, 'documentId': row['documentId'], 'ownerRunId': REQUEST['runId'], 'closed': row['closed']} + elif action == 'witness': + require(app.activeDocument == document, 'Active document changed; no implicit activation') + result = {'success': True, 'documentId': row['documentId'], 'witness': witness(document)} + elif action == 'activate': + idle() + require(any(app.activeDocument == r['object'] for r in run['documents'].values() if not r['closed']), + 'User selected an external document; refusing activation') + if app.activeDocument != document: + require(document.activate(), 'Owned document activation failed') + result = {'success': True, 'documentId': row['documentId']} + elif action == 'close': + idle() + if not row['closed']: + require(any(app.activeDocument == r['object'] for r in run['documents'].values() if not r['closed']), + 'User selected an external document; refusing lifecycle changes') + checkpoint = row['checkpoint'] + require(checkpoint is not None, 'No verified native checkpoint') + require(file_info(checkpoint['path'])['sha256'] == checkpoint['sha256'], 'Checkpoint bytes changed') + require(same(witness(document), row['witness']), 'Model changed since checkpoint') + require(document.close(False), 'Owned document close returned false') + require(not any(d == document for d in app.documents), 'Owned document still appears in open documents') + row['closed'] = True + result = {'success': True, 'closedDocumentId': row['documentId'], 'closed': True} + elif action == 'capture': + require(app.activeDocument == document, 'Active document changed before capture') + path = path_in_scratch(REQUEST['path']) + require(not os.path.exists(path), 'Capture destination already exists') + viewport = app.activeViewport + viewport.fit() + viewport.refresh() + require(viewport.saveAsImageFile(path, 1280, 900), 'Viewport capture failed') + result = {'success': True, **file_info(path)} + else: + idle() + require(app.activeDocument == document, 'Active document changed before roundtrip') + path = path_in_scratch(REQUEST['path']) + archive = file_info(path) + before = witness(document) + options = app.importManager.createFusionArchiveImportOptions(path) + imported = app.importManager.importToNewDocument(options) + # Keep the actual returned API object before any fragile getter or + # identity lookup. A provisional object can only be retained/reported. + provisional = {'object': imported} + run.setdefault('provisionalImports', []).append(provisional) + require(imported is not None and app.activeDocument == imported, 'Archive import returned no active document') + reopened_doc, reopened_design, reopened_id = core.active(app) + require(reopened_doc == imported, 'Imported document identity mismatch') + reopened = register(imported, reopened_id, row['role'] + '-reopened') + run['provisionalImports'].remove(provisional) + after = witness(imported) + require(same(before, after), 'Native archive roundtrip changed measured model state') + # Mark both sides only after independent semantic comparison. + for item in (row, reopened): + item['checkpoint'] = archive + item['witness'] = before + result = {'success': True, 'archive': archive, 'before': before, 'after': after, + 'reopened': public_row(reopened), 'source': public_row(row)} + elif action == 'file': + info = file_info(REQUEST['path']) + with open(info['path'], 'rb') as stream: data = stream.read() + result = {'success': True, **info, 'base64': base64.b64encode(data).decode('ascii')} + if REQUEST.get('csv'): + with open(info['path'], encoding='utf-8', newline='') as stream: + result['rows'] = list(csv.DictReader(stream)) + elif action == 'restore': + idle() + require(not run.get('provisionalImports'), 'Unresolved imported object remains; restoration refused') + require(all(row['closed'] for row in run['documents'].values()), 'Owned documents remain open') + require(len(list(app.documents)) == len(run['baselineObjects']) and + all(any(d == original for d in app.documents) for original in run['baselineObjects']), + 'Document set changed; refusing to change active document') + require(documents() == run['baseline'], 'Original document flags changed') + original = run['activeObject'] + if app.activeDocument != original: + require(original.activate(), 'Original document activation failed') + require(fingerprint(original) == run['activeFingerprint'], 'Original active document changed') + result = {'success': True, 'documents': documents(), 'activeCreationId': original.creationId} + else: + raise RuntimeError('Unknown native action') +''' + + +def native_script(action, *, run_id, host, session_id, **values): + request = dict(values, action=action, runId=run_id, host=host, sessionId=session_id) + # repr around JSON prevents input being interpreted as Python source. + return "import json\nREQUEST = json.loads(" + repr(json.dumps(request)) + ")\n" + NATIVE_PROGRAM diff --git a/tests/run_verb_tests.py b/tests/run_verb_tests.py index d26527a..7738868 100644 --- a/tests/run_verb_tests.py +++ b/tests/run_verb_tests.py @@ -5,10 +5,10 @@ Runs IN THE CONTAINER, through the real relay, exactly the way a user's AI calls adom-bridge --target --ai-thread "fusion-verb-tests" '' Tiers: - daily read-only + reversible state (safe every day, leaves the box as found) - weekly heavy flows (library builds, cloud writes into the test workspace, exports) + daily explicit read-only allowlist; no automatic fixture/lifecycle changes + weekly additional read-only checks; heavy inventory rows require owned campaigns manual human-present only (sign-in, uninstall-for-real, notify, install, demo) - negative always run: verbs that MUST refuse (deprecated verbs, the document guard) + negative only explicitly contracted, safe refusal cases execute Usage: python3 tests/run_verb_tests.py --target AdomLapper [--tier daily] [--only verb1,verb2] @@ -23,91 +23,23 @@ sys.path.insert(0, str(REPO)) import describe # noqa: E402 CATALOG_TIMEOUTS = {v[0]: v[4] for v in describe._T} -THREAD = "fusion-verb-tests" -# user-neutral: the suite runs on any box (it said C:/Users/john until arav-rog joined, 2026-09-20) -SCRATCH = "C:/tmp/fusion-verb-tests" -LAST_RESULTS: dict = {} +from fusion_harness import BridgeClient, OwnedRun, data_payload, evaluate, observe, run_id + +THREAD = "Fusion verb tests" +# Inventory placeholders only. They cannot execute through this read-only runner. +SCRATCH = "${OWNED_RUN_SCRATCH}" +LAST_RESULTS = {} + + +def expect(response, spec): + return evaluate(response, spec) -def call(target, verb, args=None, timeout=90): - cmd = ["adom-bridge", "--target", target, "--ai-thread", THREAD, verb, json.dumps(args or {})] - t0 = time.time() - try: - for _attempt in range(3): - p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) - raw = p.stdout.strip() or p.stderr.strip() - d = json.loads(raw[raw.index("{"):]) if "{" in raw else {"error": raw[:200]} - if d.get("errorCode") in ("main_thread_busy", "fusion_addin_busy", "bridge_restarting") and _attempt < 2: - time.sleep(15); continue - break - except subprocess.TimeoutExpired: - return {"_testTimeout": True}, time.time() - t0 - except Exception as e: - return {"error": f"runner: {e}"}, time.time() - t0 - # unwrap the nested output envelope when present - out = d.get("output") - if isinstance(out, str) and out.strip().startswith("{"): - try: - inner = json.loads(out) - merged = dict(d); merged.update(inner if not isinstance(inner.get("data"), dict) else inner["data"]) - if isinstance(inner.get("data"), dict): merged.update(inner["data"]) - d = merged - except Exception: - pass - if isinstance(d.get("data"), dict): - merged = dict(d); merged.update(d["data"]); d = merged - return d, time.time() - t0 - -def expect(d, spec): - """spec: {'ok':True} needs success/status ok; {'fields':[...]} need present; - {'errorCode':'x'} exact; {'pred':name} named predicate.""" - errs = [] - if spec.get("okOrCode"): - # success, or the one honest refusal that depends on the box's history (not on the verb) - if not (d.get("success") is True or d.get("status") == "ok" or d.get("errorCode") == spec["okOrCode"]): - errs.append("expected success or errorCode=%s, got error=%s code=%s" % (spec["okOrCode"], str(d.get("error"))[:80], d.get("errorCode"))) - if spec.get("ok"): - if not (d.get("success") is True or d.get("status") == "ok" or d.get("ok") is True): - errs.append("expected success, got error=%s code=%s" % (str(d.get("error"))[:80], d.get("errorCode"))) - for f in spec.get("fields", []): - if f not in d: errs.append("missing field %r" % f) - if "errorCode" in spec and d.get("errorCode") != spec["errorCode"]: - errs.append("expected errorCode=%s got %s (error=%s)" % (spec["errorCode"], d.get("errorCode"), str(d.get("error"))[:60])) - if spec.get("pred") == "count_gt_0" and not (d.get("count", 0) > 0 or len(d.get("results") or []) > 0): - errs.append("expected >0 results") - if spec.get("pred") == "verbs_match_plan": - got = {v["name"] if isinstance(v, dict) else v for v in (d.get("verbs") or [])} - planned = {k.split("#")[0] for k in PLAN} - if got and (got - planned): errs.append("verbs described but UNTRIAGED in test plan: %s" % sorted(got - planned)[:6]) - if spec.get("pred") == "must_refuse" and not (d.get("success") is False or d.get("error")): - errs.append("expected a refusal, got success") - if spec.get("pred") == "ok_or_budget": - ok = (d.get("success") is True and all(f in d for f in ("parts", "collapsedUnits"))) - budget = d.get("errorCode") == "bom_walk_budget_exceeded" - no_design = "No active Fusion Design" in str(d.get("error")) # honest refusal when the open document is not a Design - if not (ok or budget or no_design): - errs.append("expected a BOM or the budget refusal, got error=%s code=%s" % (str(d.get("error"))[:60], d.get("errorCode"))) - if d.get("_testTimeout"): errs.append("timed out") - return errs - - -def call_polled(target, verb, args, doc, poll_sec): - """Fire a long open, then poll get_app_state until the doc is active (the bridge's own - _timeoutHint prescribes exactly this instead of trusting the relay timeout).""" - args = {k: v for k, v in args.items() if not k.startswith("_")} - d, dt = call(target, verb, args, timeout=70) - ok = (d.get("success") is True or d.get("status") == "ok") - t0 = time.time() - while time.time() - t0 < poll_sec: - st, _ = call(target, "fusion_get_app_state", {}, timeout=45) - if st.get("activeDocument") == doc: - return {"success": True, "activeDocument": doc}, dt + (time.time() - t0) - time.sleep(8) - return (d if not ok else {"success": False, "error": "document never became active"}), dt + (time.time() - t0) # tier, args, expectation, [cleanup verb, cleanup args] P = {} def t(verb, tier, args=None, exp=None, note=""): - P[verb] = {"tier": tier, "args": args or {}, "exp": exp or {"ok": True}, "note": note} + if verb in P: raise ValueError("Duplicate test case: " + verb) + P[verb] = {"tier": tier, "args": args or {}, "exp": exp if exp is not None else {"ok": True}, "note": note} # ── stage 0: bridge + catalog ──────────────────────────────────────────────── t("fusion_readiness", "daily", {}, {"ok": True, "fields": ["bridgeVersion"]}) @@ -151,6 +83,14 @@ t("fusion_mcp_enable", "manual", note="drives the prefs UI, takes foreground") t("fusion_prefs_open", "manual", note="opens a dialog on the user's screen") t("fusion_prefs_close", "manual") t("fusion_set_auto_update", "daily", {}, {"ok": True}, "no args = read-only") +t("fusion_mech_capabilities", "daily", {}, {"ok": True, "fields": ["sessionId", "operations"]}, + "read-only schema/session discovery; no design or mutation required") +t("fusion_mechanical", "manual", note="typed sketch/feature/assembly/import/joint/material mutations and flat/structured BOM exports require a dedicated scratch-design workflow, runtime transaction probe, current session/target and operation IDs; checkpoint verifier exercises real geometry") +t("fusion_mechanical#session_guard", "negative", + {"op": "new_design", "sessionId": "NOT_THE_CURRENT_SESSION", "operationId": "daily-refusal"}, + {"errorCode": "session_mismatch"}, "must refuse before creating a document") +t("fusion_mech_operation_status", "negative", {"sessionId": "NOT_THE_CURRENT_SESSION", "operationId": "daily-refusal"}, + {"errorCode": "operation_unknown"}, "absent durable history must never prove a completed or unexecuted mutation") t("fusion_assembly_bom", "daily", {}, {"pred": "ok_or_budget"}, "either a BOM (small doc) or the fast bom_walk_budget_exceeded refusal (3D PCB) - both are the verb working as designed") t("fusion_dismiss_blocking_dialogs", "daily", {}, {"ok": True}, "no-op when nothing is blocking") @@ -403,76 +343,6 @@ _SECTION_BY_VERB = { "fusion_uninstall": "bridge-lifecycle", "fusion_set_seat_takeover": "bridge-lifecycle", "fusion_set_auto_update": "bridge-lifecycle", } -def call_tour(target, verb, spec_args): - """fusion_demo tour: async start, then poll fusion_demo_progress until done.""" - budget = int(spec_args.get("_pollTour", 300)) - args = {k: v for k, v in spec_args.items() if not k.startswith("_")} - t0 = time.time() - d, _ = call(target, verb, args, timeout=90) - demo_id = d.get("demoId") - if not demo_id: - return d, time.time() - t0 - last = {} - while time.time() - t0 < budget: - time.sleep(5) - last, _ = call(target, "fusion_demo_progress", {"demoId": demo_id, "imagesAs": "paths"}, timeout=30) - if last.get("done"): - break - ok = bool(last.get("done")) and not last.get("failed") - out = {"success": ok, "demoId": demo_id, "percent": last.get("percent"), - "shots": len(last.get("shots") or []), "step": last.get("step")} - if not ok: - out["error"] = last.get("error") or ("tour not done after %ss (step=%s)" % (budget, last.get("stepLabel"))) - return out, time.time() - t0 - - -def stage_dynamic(target, kind): - """Stage a dynamic fixture on the box at call time. - - Returns (args, None) on success, (None, why) for a FAIL, or (None, "SKIP: why") - when the fixture is legitimately absent this run (not a failure). - Fixtures travel via send_files (the real container->desktop transfer verb; - write_file-in-argv blew Errno 7 on the 245KB STEP, 2026-08-14). - """ - try: - if kind == "staged_lbr_mtp": - fname = "ADS8588SIPM.fusion.lbr" - src = str(REPO / "examples" / fname) - r, _ = call(target, "send_files", {"filePaths": [src], "dest": SCRATCH}, timeout=180) - if not (r.get("success") or r.get("status") == "ok"): - return None, "send_files: " + str(r.get("error"))[:80] - return {"libraryPath": SCRATCH + "/" + fname}, None - if kind in ("staged_step", "staged_lbr"): - fname = "ADS8588SIPM.step" if kind == "staged_step" else "ADS8588SIPM.fusion.lbr" - src = str(REPO / "examples" / fname) - r, _ = call(target, "send_files", {"filePaths": [src], "dest": SCRATCH}, timeout=180) - if not (r.get("success") or r.get("status") == "ok"): - return None, "send_files: " + str(r.get("error"))[:80] - return {"filePath": SCRATCH + "/" + fname}, None - if kind == "glb_job": - prev = LAST_RESULTS.get("fusion_export_optimized_glb") or {} - eid = prev.get("exportId") - if not eid: - return None, "export_optimized_glb returned no exportId (async start failed)" - deadline = time.time() + 420 - while time.time() < deadline: - st, _ = call(target, "fusion_fetch_optimized_glb", {"exportId": eid}, timeout=45) - if st.get("done"): - break - time.sleep(10) - return {"exportId": eid}, None - if kind == "urn_from_search": - r, _ = call(target, "fusion_aps_search", {"query": "BQ25792", "limit": 5}, timeout=90) - for item in (r.get("results") or r.get("items") or []): - urn = item.get("urn") or item.get("id") - if urn and str(urn).startswith("urn:"): - return {"urn": urn, "wait": False}, None - return None, "no URN in aps_search results" - return None, "unknown fixture kind " + kind - except Exception as e: - return None, str(e)[:100] - - def section_of(name): base = name.split("#")[0] if base in _SECTION_BY_VERB: @@ -482,146 +352,127 @@ def section_of(name): return "bridge-lifecycle" -def main(): - ap = argparse.ArgumentParser() +# Explicitly reviewed read-only calls. A daily label alone grants no permission. +READ_ONLY = { + "fusion_describe", + "fusion_aps_status", "fusion_aps_get_browser", "fusion_aps_recent", "fusion_aps_browse", + "fusion_aps_get", "fusion_uninstall", "fusion_set_seat_takeover", + "fusion_mcp_tools", "fusion_mcp_resources", "fusion_set_auto_update", + "fusion_action_ledger", "fusion_addin_status", "fusion_alerts", + "fusion_verb_times", "fusion_live_feed", "fusion_set_driving_badge", "fusion_set_window_sizing", +} +# Only guards with a known, exact refusal contract execute without a fixture. +SAFE_NEGATIVE = {"fusion_library_parts", "fusion_eagle_import_status#guard"} +PLAN["fusion_eagle_import_status#guard"]["exp"] = {"errorCode": "unknown_run_id"} +PLAN["fusion_live_feed"]["args"] = {"action": "status"} +PLAN["fusion_aps_recent"]["exp"] = {"ok": True} +READ_ONLY_ARGS = {name: {} for name in READ_ONLY} +READ_ONLY_ARGS.update({"fusion_aps_recent": {"limit": 5}, "fusion_aps_get": {"path": "/project/v1/hubs"}, + "fusion_uninstall": {"dryRun": True}, "fusion_action_ledger": {"limit": 5}, + "fusion_alerts": {"sinceSec": 300}, "fusion_live_feed": {"action": "status"}}) +NEGATIVE_ARGS = {"fusion_library_parts": {"libraryPath": "C:/nope/missing.lbr"}, + "fusion_eagle_import_status#guard": {"runId": "no-such-run"}} +LIFECYCLE_SIDE_EFFECTS = {"fusion_readiness", "fusion_account", "fusion_get_app_state", "fusion_get_preferences", + "fusion_mech_capabilities", "fusion_mechanical#session_guard", "fusion_mech_operation_status", "fusion_mcp_status"} + + +def case_gate(name, spec, tier="daily", only=(), campaign="read-only"): + if only and name not in only: + return "NOT_RUN", "not selected" + if spec["tier"] == "manual": + return "MANUAL", spec["note"] or "human/fixture workflow; --only does not authorize it" + tiers = {"daily": {"daily", "negative"}, "weekly": {"daily", "weekly", "negative"}, "all": {"daily", "weekly", "negative"}}[tier] + if spec["tier"] not in tiers: + return "NOT_RUN", "outside selected tier" + if name in LIFECYCLE_SIDE_EFFECTS: + return "SKIP", "native transport may auto-start/repair Fusion or create a document; requires an owned campaign" + if name not in READ_ONLY and name not in SAFE_NEGATIVE: + return "SKIP", "requires an owned fixture campaign; legacy name-based workflows are disabled" + if any(k.startswith("_") for k in spec["args"]): + return "SKIP", "dynamic fixture has no safe executable adapter" + if name in SAFE_NEGATIVE and not spec["exp"].get("errorCode"): + return "BLOCKED", "negative case has no explicit refusal code" + # Python equality equates True, 1 and 1.0; the native JSON boolean parser + # does not. In particular dryRun:1 means a real uninstall, not a dry run. + try: + same_args = json.dumps(spec["args"], sort_keys=True, allow_nan=False) == json.dumps({**READ_ONLY_ARGS, **NEGATIVE_ARGS}[name], sort_keys=True, allow_nan=False) + except (TypeError, ValueError): + same_args = False + if not same_args: + return "BLOCKED", "arguments differ from the reviewed read-only/refusal contract" + return None, "" + + +def main(argv=None, client_factory=BridgeClient): + ap = argparse.ArgumentParser(description="Read-only Fusion checks; fixture workflows are separately maintained campaigns.") ap.add_argument("--target", required=True) ap.add_argument("--tier", default="daily", choices=["daily", "weekly", "all"]) - ap.add_argument("--only", default="") - a = ap.parse_args() - tiers = {"daily": {"daily", "negative"}, "weekly": {"daily", "weekly", "negative"}, - "all": {"daily", "weekly", "negative"}}[a.tier] - only = set(x.strip() for x in a.only.split(",") if x.strip()) - - print("warmup: waking the bridge (unscored)...") - call(a.target, "fusion_get_app_state", {}, timeout=45) - pre, _ = call(a.target, "fusion_get_app_state", {}, timeout=45) - pre = pre or {} - # Cleanup contract (John, 2026-08-14): close what we opened. Snapshot whether Fusion - # was running BEFORE the run; if the run started it, the run closes it at the end. - # errorCode fusion_not_running is the definitive "was closed" signal; any successful - # app-state read means it was already up (and stays up - it is the user's session). - fusion_was_running = pre.get("errorCode") != "fusion_not_running" - - global LAST_RESULTS - described = {v[0] for v in describe._T} - planned = {k.split("#")[0] for k in PLAN} - {"focus_invariance"} - results, t0 = [], time.time() - # Catalog drift fails the run in BOTH directions: a described verb nobody triaged, - # AND a planned verb missing from describe (fusion_import_step shipped undescribed - # for weeks until this reverse check was added, 2026-08-14). - for name in sorted(described - planned): - results.append({"verb": name, "status": "FAIL", "why": "described but untriaged in tests/run_verb_tests.py"}) - for name in sorted(planned - described): - results.append({"verb": name, "status": "FAIL", "why": "in the test plan but MISSING from describe.py - undescribed verbs are invisible to callers"}) - - for name, spec in PLAN.items(): - if only: - # An explicit --only list overrides the tier gates entirely - naming a - # manual/weekly test IS the operator's consent to run it now. - if name not in only: - continue - elif spec["tier"] == "manual" or spec["tier"] not in tiers: - if spec["tier"] == "manual": - results.append({"verb": name, "status": "MANUAL", "why": spec.get("note", "")}) - continue - run_args = spec["args"] - if "_dynamic" in run_args: - run_args, stage_err = stage_dynamic(a.target, run_args["_dynamic"]) - if run_args is None: - if str(stage_err).startswith("SKIP:"): - results.append({"verb": name, "status": "SKIP", "why": stage_err[5:].strip()}) - print(" %-42s SKIP - %s" % (name, stage_err[5:].strip())) - else: - results.append({"verb": name, "status": "FAIL", "why": "fixture staging failed: " + stage_err}) - print(" %-42s FAIL - fixture staging failed: %s" % (name, stage_err)) - continue - verb = name.split("#")[0] - vt = max(90, CATALOG_TIMEOUTS.get(verb, 60) + 30) - if "_pollTour" in run_args: - d, dt = call_tour(a.target, verb, run_args) - elif "_pollDoc" in run_args: - d, dt = call_polled(a.target, verb, run_args, run_args["_pollDoc"], run_args["_pollSec"]) - else: - d, dt = call(a.target, verb, run_args, timeout=vt) - # A busy main thread right after a heavy op (cloud open, export) is congestion, - # not a verb failure - a real caller retries per the _hint. One retry, like they would. - if d.get("errorCode") == "main_thread_busy" or str(d.get("error")) == "addin_command_timeout": - time.sleep(12) - d2, dt2 = call(a.target, verb, run_args, timeout=vt) - if not (d2.get("errorCode") == "main_thread_busy" or str(d2.get("error")) == "addin_command_timeout"): - d, dt = d2, dt + dt2 - LAST_RESULTS[verb] = d - if str(d.get("error") or "") == "target_not_found": - flaps = getattr(sys.modules[__name__], "_FLAPS", 0) + 1 - setattr(sys.modules[__name__], "_FLAPS", flaps) - if flaps >= 4: - print("\nABORT: the relay lost target %s %d times in a row - the box is offline or its ab is mid-update. Re-run when `adom-bridge targets` shows it stable; these are not verb failures." % (a.target, flaps)) - results.append({"verb": name, "status": "FAIL", "why": "relay lost the target (run aborted; not a verb failure)"}) - break - else: - setattr(sys.modules[__name__], "_FLAPS", 0) - errs = expect(d, spec["exp"]) - if name.startswith("fusion_search_cloud_files") or name.startswith("fusion_walk_cloud_tree"): - # must refuse: success:true here is the FAILURE - errs = [] if (d.get("success") is False or "disabled" in str(d.get("error", "")).lower() - or "DISABLED" in str(d.get("error", ""))) else ["deprecated verb did NOT refuse"] - results.append({"verb": name, "status": "PASS" if not errs else "FAIL", - "why": "; ".join(errs), "sec": round(dt, 1)}) - print(" %-42s %s %s" % (name, "PASS" if not errs else "FAIL", ("- " + "; ".join(errs)) if errs else "")) - - # Focus invariance (fusion-background-etiquette): the whole run must leave the user's - # foreground alone. z=0 belonging to a Fusion-family window at the end = a steal happened. - fg, _ = call(a.target, "desktop_list_windows", {}, timeout=45) + ap.add_argument("--only", default="", help="filter cases; never bypasses manual/fixture gates") + ap.add_argument("--campaign", default="read-only", choices=["read-only"], help="only reviewed read-only cases are supported here") + ap.add_argument("--dry-run", action="store_true", help="write the selection report without contacting the host") + ap.add_argument("--report-dir", type=pathlib.Path, default=REPO / "tests" / "reports") + a = ap.parse_args(argv) + only = {name.strip() for name in a.only.split(",") if name.strip()} + unknown = only - set(PLAN) + if unknown: + ap.error("unknown --only cases: " + ", ".join(sorted(unknown))) + rid = run_id();folder = a.report_dir / rid;folder.mkdir(parents=True, exist_ok=False) + owned = OwnedRun(folder / "ownership.json", a.target, rid) + results, started = [], time.time() + described = {row[0] for row in describe._T};planned = {name.split("#")[0] for name in PLAN} + for name in sorted(described ^ planned): + results.append({"verb": name, "status": "FAIL", "why": "catalog/plan drift"}) try: - wins = fg.get("windows") or [] - top = min(wins, key=lambda w: w.get("z", 999)) if wins else {} - stolen = "Autodesk Fusion" in str(top.get("title", "")) or str(top.get("title", "")).strip() == "Fusion360" - results.append({"verb": "focus_invariance", "status": "FAIL" if stolen else "PASS", - "why": ("foreground is %r after the run" % top.get("title")) if stolen else ""}) - print(" %-42s %s" % ("focus_invariance", "FAIL" if stolen else "PASS")) - except Exception as e: - results.append({"verb": "focus_invariance", "status": "SKIP", "why": str(e)}) - - call(a.target, "shell_execute", - {"command": 'cmd /c if exist "%TEMP%\\fusion-verb-tests" rd /s /q "%TEMP%\\fusion-verb-tests"', - "reason": "verb-test cleanup: wipe the scratch dir"}, timeout=60) - if not fusion_was_running: - print("cleanup: this run started Fusion - closing it (fusion_stop)...") - stop_resp, _ = call(a.target, "fusion_stop", - {"reason": "verb-test cleanup: run started Fusion, run closes it"}, - timeout=120) - stopped = (stop_resp or {}).get("success") - print(" fusion_stop:", "ok" if stopped else "FAILED - close it by hand") - else: - print("cleanup: Fusion was already running before the run - leaving it (user's session).") - - passed = sum(1 for r in results if r["status"] == "PASS") - failed = [r for r in results if r["status"] == "FAIL"] - for r in results: - r["section"] = section_of(r["verb"]) - stamp = datetime.datetime.now().strftime("%Y-%m-%d_%H%M") - rollup = {} - for r in results: - s = rollup.setdefault(r["section"], {"PASS": 0, "FAIL": 0, "MANUAL": 0, "SKIP": 0}) - s[r["status"]] += 1 - print("\nSection coverage (John's product map, 2026-08-14):") - for sec in SECTION_ORDER: - s = rollup.get(sec) - if not s: - continue - badge = "RED" if s["FAIL"] else ("green" if s["PASS"] else "untested-this-tier") - print(" %-38s %d pass / %d fail (%d manual, %d skip) %s" - % (sec, s["PASS"], s["FAIL"], s["MANUAL"], s["SKIP"], badge)) - report = {"date": stamp, "target": a.target, "tier": a.tier, - "pass": passed, "fail": len(failed), "sections": rollup, "results": results, - "durationSec": round(time.time() - t0, 1)} - out = REPO / "tests" / "reports" / f"verb-test-{stamp}.json" - out.write_text(json.dumps(report, indent=1)) - print(f"\n{passed} PASS, {len(failed)} FAIL " - f"({sum(1 for r in results if r['status']=='MANUAL')} manual-tier, " - f"{sum(1 for r in results if r['status']=='SKIP')} skipped) -> {out}") - for r in failed: print(" FAIL:", r["verb"], "-", r["why"]) - sys.exit(1 if failed else 0) + with owned: + client = client_factory(a.target, rid, ai_thread=THREAD + " " + rid, evidence_dir=folder / "raw") + for name, spec in PLAN.items(): + status, why = case_gate(name, spec, a.tier, only, a.campaign) + if status is None and a.dry_run: status, why = "NOT_RUN", "dry run: eligible read-only case" + row = {"verb": name, "tier": spec["tier"], "status": status, "why": why, + "expectation": spec["exp"], "startedAt": time.time()} + results.append(row) + if status is not None: continue + verb = name.split("#")[0] + result = client.call(verb, dict(spec["args"]), timeout=max(90, CATALOG_TIMEOUTS.get(verb, 60)+30)) + errors = expect(result.raw, spec["exp"]) + if spec["exp"].get("pred") == "verbs_match_plan" and not errors: + names = {v["name"] if isinstance(v, dict) else v for v in data_payload(result.observation)["verbs"]} + if names != planned: errors.append("runtime catalog differs from plan: " + str(sorted(names ^ planned))) + row.update(status="FAIL" if errors else "PASS", why="; ".join(errors), + finishedAt=time.time(), sec=round(result.elapsed, 3), raw=result.raw, + attempts=result.attempts, timedOut=result.timed_out) + print("%-45s %s %s" % (name, row["status"], row["why"])) + except BaseException as exc: + for row in results: + if row.get("status") is None: + row.update(status="FAIL", why="case interrupted before a verified result") + results.append({"verb": "harness", "status": "FAIL", "why": type(exc).__name__ + ": " + str(exc)}) + finally: + # There is no end-window heuristic: neither unchanged foreground nor a + # Fusion foreground window can prove that no transient steal occurred. + results.append({"verb": "focus_invariance", "status": "UNVERIFIED", + "why": "No timestamped foreground transition stream with user/action attribution; background behavior is not verified."}) + for cleanup in owned.manifest["cleanup"]: + results.append({"verb": "cleanup", "status": cleanup["status"], "why": cleanup.get("error", ""), "evidence": cleanup}) + recorded = {r["verb"] for r in results} + for name, spec in PLAN.items(): + if name not in recorded: + results.append({"verb": name, "status": "NOT_RUN", "why": "run interrupted", "tier": spec["tier"]}) + counts, sections = {}, {} + for row in results: + row["section"] = section_of(row["verb"]) + counts[row["status"]] = counts.get(row["status"], 0) + 1 + section = sections.setdefault(row["section"], {}) + section[row["status"]] = section.get(row["status"], 0) + 1 + report = {"runId": rid, "target": a.target, "tier": a.tier, "campaign": a.campaign, + "counts": counts, "sections": sections, "coverage": "partial inventory; PASS covers only the recorded expectation", + "results": results, "ownershipManifest": str(owned.path), "durationSec": time.time()-started} + path = folder / "report.json";path.write_text(json.dumps(report, indent=2, default=str)) + print(json.dumps(counts, sort_keys=True) + " -> " + str(path)) + if any(counts.get(key, 0) for key in ("FAIL", "BLOCKED", "AMBIGUOUS")): return 1 + if not counts.get("PASS") and not a.dry_run: return 2 + return 0 + if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/tests/test_addin_lifecycle.py b/tests/test_addin_lifecycle.py new file mode 100644 index 0000000..40393d6 --- /dev/null +++ b/tests/test_addin_lifecycle.py @@ -0,0 +1,315 @@ +"""Real loopback listener ownership; Fusion registration is the simulated boundary.""" +import errno +import http.client +import importlib.util +import json +from pathlib import Path +import socket +import sys +import tempfile +import threading +import types +import unittest +from unittest.mock import Mock, patch + +ROOT = Path(__file__).resolve().parents[1] +NS = types.SimpleNamespace + + +class Event: + def __init__(self, event_id, app): + self.eventId, self.app, self.handlers = event_id, app, [] + + def add(self, handler): + if self.app.add_result: + self.handlers.append(handler) + return self.app.add_result + + def remove(self, handler): + if handler in self.handlers: + self.handlers.remove(handler) + return True + + +class App: + def __init__(self): + self.events, self.registered, self.unregistered, self.dispatched = {}, [], [], [] + self.null_registration, self.add_result, self.fire_result = False, True, True + self.userInterface = NS(messageBox=Mock()) + + def registerCustomEvent(self, event_id): + self.registered.append(event_id) + if self.null_registration or event_id in self.events: + return None + event = self.events[event_id] = Event(event_id, self) + return event + + def unregisterCustomEvent(self, event_id): + self.unregistered.append(event_id) + del self.events[event_id] + return True + + def fireCustomEvent(self, event_id, body): + if self.fire_result: + for handler in self.events[event_id].handlers: + handler.notify(NS(additionalInfo=body)) + return self.fire_result + + +class AddinLifecycle(unittest.TestCase): + def setUp(self): + self.app, self.copies = App(), [] + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.env = patch.dict('os.environ', TEMP=self.tmp.name) + self.env.start(); self.addCleanup(self.env.stop) + self.modules = patch.dict(sys.modules) + self.modules.start(); self.addCleanup(self.modules.stop) + adsk = types.ModuleType('adsk') + adsk.core = types.ModuleType('adsk.core') + adsk.fusion = types.ModuleType('adsk.fusion') + adsk.core.CustomEventHandler = adsk.core.CustomEventArgs = object + self.log = Mock() + adsk.core.Application = NS(get=lambda:self.app, log=self.log) + for module in (adsk, adsk.core, adsk.fusion):sys.modules[module.__name__] = module + self.addCleanup(self.stop_copies) + + def stop_copies(self): + for bridge, server, palette in self.copies: + bridge.stop(None) + self.assertIsNone(server._server) + self.assertFalse(bridge._serving) + self.assertEqual(self.app.events, {}) + + def load_copy(self): + name = 'owned_addin_' + str(len(self.copies)) + package = types.ModuleType(name); package.__path__ = [] + commands = types.ModuleType(name + '.commands'); commands.__path__ = [] + def dispatch(app, command, args): + self.app.dispatched.append((name, command, args)) + return {'success':True, 'copy':name, 'args':args} + commands.dispatch_command = dispatch + cloud = types.ModuleType(name + '.commands.cloud_documents') + cloud.get_walk_progress = lambda:None + palette = types.ModuleType(name + '.commands.activity_palette') + palette.teardown = Mock() + for module in (package, commands, cloud, palette):sys.modules[module.__name__] = module + def load(suffix, filename): + spec = importlib.util.spec_from_file_location(name + '.' + suffix, ROOT / 'addin/AdomBridge' / filename) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module; setattr(package, suffix, module) + spec.loader.exec_module(module) + return module + server = load('http_server', 'http_server.py'); server.PORT = 0 + bridge = load('AdomBridge', 'AdomBridge.py') + self.copies.append((bridge, server, palette)) + return bridge, server, palette + + def call(self, server,timeout=None): + conn = http.client.HTTPConnection(*server._server.server_address, timeout=2) + try: + request = {'command':'get_app_state', 'args':{}} + if timeout is not None:request['timeout'] = timeout + conn.request('POST', '/command', json.dumps(request)) + response = conn.getresponse() + return response.status, json.loads(response.read()) + finally:conn.close() + + def test_two_loaded_copies_have_one_listener_and_loser_stop_cannot_unregister_winner(self): + first, one, palette = self.load_copy(); first.run(None) + winner_id, winner_event = first._custom_event_id, first._custom_event + second, two, losing_palette = self.load_copy(); two.PORT = one._server.server_address[1] + second.run(None) + self.assertTrue(first._serving); self.assertFalse(second._serving) + self.assertIsNone(two._server); self.assertIsNone(second._custom_event) + self.assertIsNone(second._event_handler); self.assertIsNone(second._custom_event_id) + self.assertEqual(len(set(self.app.registered)), 2) + self.assertEqual(set(self.app.events), {winner_id}) + self.assertEqual(len(winner_event.handlers), 1) + second.stop(None); second.stop(None) + losing_palette.teardown.assert_not_called(); palette.teardown.assert_not_called() + self.assertNotIn(winner_id, self.app.unregistered) + self.assertEqual(self.call(one)[1]['copy'], 'owned_addin_0') + self.app.userInterface.messageBox.assert_not_called() + + def test_repeated_run_preserves_current_registration_and_restart_gets_new_id(self): + bridge, server, palette = self.load_copy(); bridge.run(None) + before = (bridge._custom_event_id, bridge._http_thread, server._server, server._app) + bridge.run(None) + self.assertEqual((bridge._custom_event_id, bridge._http_thread, server._server, server._app), before) + self.assertEqual(len(self.app.registered), 1) + bridge.stop(None) + self.assertFalse(before[1].is_alive()); self.assertEqual(before[2].socket.fileno(), -1) + palette.teardown.assert_called_once() + bridge.run(None) + self.assertNotEqual(bridge._custom_event_id, before[0]) + self.assertTrue(self.call(server)[1]['success']) + + def test_refused_registration_and_handler_unwind_without_listener(self): + for mode in ('null', 'false'): + with self.subTest(mode=mode): + bridge, server, palette = self.load_copy() + self.app.null_registration = mode == 'null' + self.app.add_result = mode != 'false' + with patch.object(server, 'start') as start: + bridge.run(None) + start.assert_not_called() + self.assertEqual(self.app.events, {}) + self.assertIsNone(bridge._custom_event); self.assertIsNone(bridge._custom_event_id) + palette.teardown.assert_not_called() + self.assertEqual(len(self.app.unregistered), 1, 'null registration does not confer ownership') + + def test_non_collision_bind_error_is_failure_with_owned_event_cleanup(self): + bridge, server, palette = self.load_copy() + with patch.object(server, 'ExclusiveHTTPServer', side_effect=OSError(errno.EACCES, 'permission denied')): + bridge.run(None) + self.app.userInterface.messageBox.assert_called_once() + self.assertFalse(any('standing down' in str(call) for call in self.log.call_args_list)) + self.assertEqual(self.app.events, {}); palette.teardown.assert_not_called() + + def test_thread_start_failures_close_bound_socket_and_stop_started_threads(self): + actual_thread = threading.Thread + for fail_number in (1, 2): + with self.subTest(fail_number=fail_number): + bridge, server, palette = self.load_copy() + bound, threads = [], [] + actual_server = server.ExclusiveHTTPServer + def make_server(*args, **kwargs): + result = actual_server(*args, **kwargs); bound.append(result); return result + def make_thread(*args, **kwargs): + result = actual_thread(*args, **kwargs); threads.append(result) + if len(threads) == fail_number:result.start = Mock(side_effect=RuntimeError('thread unavailable')) + return result + with patch.object(server, 'ExclusiveHTTPServer', side_effect=make_server), patch.object(server.threading, 'Thread', side_effect=make_thread): + bridge.run(None) + self.assertEqual(len(bound), 1); self.assertEqual(bound[0].socket.fileno(), -1) + self.assertTrue(all(not thread.is_alive() for thread in threads)) + self.assertIsNone(server._server); self.assertIsNone(server._server_thread) + self.assertIsNone(server._watchdog_thread); self.assertEqual(self.app.events, {}) + palette.teardown.assert_not_called() + + def test_live_http_start_does_not_replace_app_or_event_and_draining_restart_refuses(self): + bridge, server, _ = self.load_copy(); bridge.run(None) + old = (server._app, server._custom_event_id, server._server) + with self.assertRaisesRegex(RuntimeError, 'already owns'): + server.start('other-event', object()) + self.assertEqual((server._app, server._custom_event_id, server._server), old) + bridge.stop(None) + server._main_thread_lock.acquire() + try: + with self.assertRaisesRegex(RuntimeError, 'have not finished'):server.start('other-event', object()) + finally:server._main_thread_lock.release() + self.assertIsNone(server._server) + + def test_false_event_return_without_callback_expires_without_dispatch(self): + bridge, server, _ = self.load_copy(); bridge.run(None) + self.app.fire_result = False + status, reply = self.call(server,timeout=.01) + self.assertEqual(status, 200); self.assertEqual(reply['errorCode'], 'request_wait_timeout') + self.assertFalse(reply['success']); self.assertEqual(self.app.dispatched, []) + self.assertEqual(server._pending_request_ids, set()) + + def test_failed_unregister_retains_only_owned_identity_for_cleanup_retry(self): + bridge, server, _ = self.load_copy(); bridge.run(None) + owned_id = bridge._custom_event_id + with patch.object(self.app, 'unregisterCustomEvent', return_value=False): + bridge.stop(None) + self.assertFalse(bridge._serving); self.assertIsNone(server._server) + self.assertEqual(bridge._custom_event_id, owned_id) + bridge.run(None) + self.assertEqual(self.app.registered, [owned_id]) + bridge.stop(None) + self.assertIsNone(bridge._custom_event_id) + self.assertEqual(self.app.events, {}) + + def test_windows_exclusivity_is_set_before_bind_with_no_reuse(self): + _, server, _ = self.load_copy() + events = [] + fake = server.ExclusiveHTTPServer.__new__(server.ExclusiveHTTPServer) + fake.socket = NS(setsockopt=lambda *args:events.append(('option', args))) + with patch.object(server.os, 'name', 'nt'), patch.object(socket, 'SO_EXCLUSIVEADDRUSE', -5, create=True), \ + patch.object(server.ThreadingHTTPServer, 'server_bind', side_effect=lambda:events.append(('bind',))) as bind: + server.ExclusiveHTTPServer.server_bind(fake) + self.assertEqual(events, [('option', (socket.SOL_SOCKET, -5, 1)), ('bind',)]) + bind.assert_called_once() + self.assertFalse(server.ExclusiveHTTPServer.allow_reuse_address) + self.assertFalse(server.ExclusiveHTTPServer.allow_reuse_port) + + def test_accepted_slow_body_cannot_use_successor_listener_or_admit_history(self): + for command in ('get_app_state', 'mechanical', 'mech_operation_status'): + with self.subTest(command=command): + bridge, server, _ = self.load_copy(); bridge.run(None) + entered = threading.Event() + original = server.AdomBridgeHandler.do_POST + class SignalRead: + def __init__(self, wrapped):self.wrapped = wrapped + def __getattr__(self, name):return getattr(self.wrapped, name) + def read(self, count): + entered.set();return self.wrapped.read(count) + def posted(handler): + handler.rfile = SignalRead(handler.rfile) + original(handler) + sock = socket.create_connection(server._server.server_address, timeout=2) + body = json.dumps({'command':command, 'args':{}}).encode() + try: + with patch.object(server.AdomBridgeHandler, 'do_POST', posted): + sock.sendall(b'POST /command HTTP/1.1\r\nHost: localhost\r\nContent-Length: ' + str(len(body)).encode() + b'\r\n\r\n' + body[:1]) + self.assertTrue(entered.wait(1)) + self.assertEqual(server._pending_request_ids, set()) + previous = bridge._custom_event_id + bridge.stop(None); bridge.run(None) + self.assertNotEqual(previous, bridge._custom_event_id) + sock.sendall(body[1:]) + response = http.client.HTTPResponse(sock);response.begin() + reply = json.loads(response.read()) + self.assertEqual(response.status, 503) + self.assertEqual(reply['errorCode'], 'listener_stopped') + self.assertEqual(self.app.dispatched, []) + self.assertEqual(server._pending_request_ids, set()) + self.assertIsNone(server._get_busy()) + finally:sock.close() + + def test_accepted_slow_headers_cannot_report_successor_health(self): + bridge, server, _ = self.load_copy(); bridge.run(None) + entered = threading.Event(); setup = server.AdomBridgeHandler.setup + def capture(handler):setup(handler);entered.set() + sock = None + try: + with patch.object(server.AdomBridgeHandler, 'setup', capture): + sock = socket.create_connection(server._server.server_address, timeout=2) + sock.sendall(b'GET /health HTTP/1.1\r\nHost: localhost\r\n') + self.assertTrue(entered.wait(1)) + bridge.stop(None);bridge.run(None) + sock.sendall(b'\r\n') + response = http.client.HTTPResponse(sock);response.begin() + reply = json.loads(response.read()) + self.assertEqual(response.status, 503) + self.assertEqual(reply['errorCode'], 'listener_stopped') + self.assertNotIn('main_thread', reply) + finally: + if sock is not None:sock.close() + + def test_retirement_after_admission_resolves_through_existing_nonexecution_cas(self): + bridge, server, _ = self.load_copy(); bridge.run(None) + module = types.ModuleType(server.__package__ + '.commands.mechanical') + descriptor = {'operationId':'retired', 'dispatchId':'fixture'} + module.admit_for_dispatch = Mock(side_effect=lambda args, rid:(args, descriptor, None)) + module.not_executed = Mock(side_effect=lambda admission, failure:{**failure, 'nativeExecutionStarted':False}) + sys.modules[module.__name__] = module + def retire(timeout): + server._server._bridge_context.stopped.set() + return True + lock = NS(acquire=retire, release=Mock(), locked=lambda:False) + with patch.object(server, '_main_thread_lock', lock): + conn = http.client.HTTPConnection(*server._server.server_address, timeout=2) + try: + conn.request('POST', '/command', json.dumps({'command':'mechanical','args':{}})) + response = conn.getresponse();reply = json.loads(response.read()) + finally:conn.close() + self.assertEqual(response.status, 200);self.assertFalse(reply['nativeExecutionStarted']) + self.assertEqual(reply['errorCode'], 'listener_stopped') + self.assertEqual(module.not_executed.call_args.args[0], descriptor) + self.assertEqual(self.app.dispatched, []);lock.release.assert_called_once() + + +if __name__ == '__main__':unittest.main() diff --git a/tests/test_bridge_contracts.py b/tests/test_bridge_contracts.py new file mode 100644 index 0000000..868eb64 --- /dev/null +++ b/tests/test_bridge_contracts.py @@ -0,0 +1,138 @@ +"""Source/packaging contract checks. These are not native workflow coverage.""" +import ast +import importlib.util +import json +import os +from pathlib import Path +import re +import tomllib +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +def read_json(path): + return json.loads((ROOT / path).read_text()) + + +def assigned_mapping(tree, name): + return next(n.value for n in tree.body if isinstance(n, ast.Assign) + and any(isinstance(t, ast.Name) and t.id == name for t in n.targets)) + + +class BridgeContracts(unittest.TestCase): + @classmethod + def setUpClass(cls): + spec = importlib.util.spec_from_file_location("contract_describe", ROOT / "describe.py") + cls.describe = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cls.describe) + cls.catalog = read_json("contracts/describe.json")["verbs"] + + def test_frozen_catalog_matches_all_authoring_fields(self): + generated = [] + for item in self.describe._T: + name, summary, inp, out, timeout, status, long_running, example = (list(item) + [None] * 8)[:8] + generated.append(dict(name=name, summary=summary, input=inp or {}, output=out or {}, + timeoutSeconds=timeout, statusVerb=status, + longRunning=bool(long_running), example=example or {})) + self.assertEqual(self.catalog, sorted(generated, key=lambda v: v["name"]), + "regenerate contracts with tools/freeze-contract.py") + + def test_catalog_has_no_duplicates_and_manifest_exposes_exact_set(self): + names = [item["name"] for item in self.catalog] + self.assertTrue(names) + self.assertEqual(len(names), len(set(names))) + self.assertEqual(read_json("bridge.json")["verbs"], sorted(names)) + self.assertTrue(all(name.startswith("fusion_") for name in names)) + + def test_runtime_packaging_versions_are_consistent(self): + version = (ROOT / "BRIDGE_VERSION").read_text().strip() + for path in ("bridge.json", "adom-bridge-fusion-manifest.json", "package.json", "page.json"): + with self.subTest(path=path): + self.assertEqual(read_json(path)["version"], version) + for path, route in (("rust/Cargo.toml", ("workspace", "package")), + ("dashboard/Cargo.toml", ("package",))): + data = tomllib.loads((ROOT / path).read_text()) + for key in route: + data = data[key] + self.assertEqual(data["version"], version, path) + for path in ("rust/Cargo.lock", "dashboard/Cargo.lock"): + data = tomllib.loads((ROOT / path).read_text()) + for package in data["package"]: + if "source" not in package: + self.assertEqual(package["version"], version, (path, package["name"])) + + def test_addin_health_and_history_use_the_separate_manifest_version(self): + # Upstream releases version the native bridge and Fusion add-in separately. + # Both add-in identity surfaces must report the manifest's actual version. + manifest = read_json("addin/AdomBridge/AdomBridge.manifest") + self.assertRegex(manifest["version"], r"^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$") + folder = ROOT / "addin/AdomBridge" + server = ast.parse((folder / "http_server.py").read_text()) + reader = next(n for n in server.body if isinstance(n, ast.FunctionDef) and n.name == "_read_addin_version") + environment = {"__file__": str(folder / "http_server.py"), "os": os, "json": json} + exec(compile(ast.Module(body=[reader], type_ignores=[]), "version-reader", "exec"), environment) + self.assertEqual(environment["_read_addin_version"](), manifest["version"]) + module = folder / "commands/mechanical/__init__.py" + tree = ast.parse(module.read_text()) + assignment = next(n for n in tree.body if isinstance(n, ast.Assign) + and any(isinstance(t, ast.Name) and t.id == "SOURCE_VERSION" for t in n.targets)) + environment = {"__file__": str(module), "Path": Path, "json": json} + exec(compile(ast.Module(body=[assignment], type_ignores=[]), "history-version", "exec"), environment) + self.assertEqual(environment["SOURCE_VERSION"], manifest["version"]) + + def test_addin_dispatch_handlers_resolve_to_declared_functions(self): + folder = ROOT / "addin/AdomBridge/commands" + tree = ast.parse((folder / "__init__.py").read_text()) + handlers = assigned_mapping(tree, "COMMAND_HANDLERS") + names = [ast.literal_eval(key) for key in handlers.keys] + self.assertEqual(len(names), len(set(names))) + declarations = {n.name for n in ast.walk(tree) if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))} + imports = {} + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.level == 1 and node.module: + for alias in node.names: + imports[alias.asname or alias.name] = (node.module, alias.name) + for key, handler in zip(names, handlers.values): + with self.subTest(command=key): + self.assertIsInstance(handler, ast.Name) + if handler.id in declarations: + continue + self.assertIn(handler.id, imports) + module, function = imports[handler.id] + source = folder / (module.replace(".", "/") + ".py") + if not source.exists(): + source = folder / module.replace(".", "/") / "__init__.py" + native_tree = ast.parse(source.read_text()) + functions = {n.name for n in native_tree.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))} + self.assertIn(function, functions, str(source)) + + def test_each_catalog_verb_has_a_rust_route_declaration(self): + # Static routing inventory only: semantics are covered by Rust and live + # campaign tests. Add-in routes are stripped-prefix entries; native + # routes use the public verb spelling in the dispatcher. + main = (ROOT / "rust/bridge/src/main.rs").read_text() + addin = (ROOT / "rust/bridge/src/addin.rs").read_text() + arrays = re.findall(r"(?:ADDIN_COMMANDS|OPEN_WITH_SCREENSHOT):\s*&\[&str\]\s*=\s*&\[(.*?)\];", addin, re.S) + passthrough = set(re.findall(r'"([a-z0-9_]+)"', " ".join(arrays))) + routing = (ROOT / "rust/bridge/src/routing.rs").read_text() + routing_owns = re.search(r"pub fn owns\(.*?matches!\(cmd,(.*?)\)\s*}", routing, re.S) + self.assertIsNotNone(routing_owns) + self.assertIn('routing::owns(v.strip_prefix("fusion_").unwrap_or(v))', main) + passthrough.update(re.findall(r'"([a-z0-9_]+)"', routing_owns.group(1))) + for verb in self.catalog: + name = verb["name"] + self.assertTrue('"' + name + '"' in main or name.removeprefix("fusion_") in passthrough, + name + " lacks a dispatch declaration") + + def test_python_sources_parse_including_all_addin_commands(self): + paths = list((ROOT / "addin/AdomBridge").rglob("*.py")) + paths += [ROOT / "describe.py"] + self.assertGreater(len(paths), 30) + for path in paths: + with self.subTest(path=str(path.relative_to(ROOT))): + ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_fusion_harness.py b/tests/test_fusion_harness.py new file mode 100644 index 0000000..495950c --- /dev/null +++ b/tests/test_fusion_harness.py @@ -0,0 +1,451 @@ +"""Host-free regressions: broken evidence and unsafe runner choices turn red.""" +import contextlib +import io +import json +from pathlib import Path +import subprocess +import tempfile +from types import SimpleNamespace as NS +import unittest +from unittest.mock import patch + +import fusion_harness as h +import run_verb_tests as runner + + +class Envelopes(unittest.TestCase): + def test_full_catalog_domain_data_does_not_exhaust_envelope_budget(self): + catalog = json.loads((Path(__file__).resolve().parents[1] / 'contracts/describe.json').read_text()) + # Real bridge responses repeat the catalog inside native data and + # transport output. Schema fields are data, even when named error. + native = {'success': True, 'data': catalog} + raw = {'success': True, 'data': catalog, 'output': json.dumps(native)} + self.assertFalse(h.evaluate(raw, {'ok': True})) + raw['result'] = {'success': False, 'errorCode': 'actual_native_failure'} + self.assertTrue(h.evaluate(raw, {'ok': True})) + self.assertTrue(any(f.get('code') == 'actual_native_failure' for f in h.observe(raw).failures)) + + def test_semantic_failures_survive_every_transport_wrapper(self): + bad = {"success": False, "errorCode": "native_failed", "error": "native failed", "data": {"count": 1}} + for raw in (bad, {"status": "ok", **bad}, {"status": "ok", "output": json.dumps(bad)}, + {"success": True, "data": bad}, {"status": "ok", "output": {"success": True, "data": bad}}, + {"success": True, "result": bad}, {"content": [{"type": "text", "text": json.dumps(bad)}], "status": "ok"}): + with self.subTest(raw=raw): self.assertTrue(h.evaluate(raw, {"ok": True})) + + def test_expected_refusal_does_not_accept_infrastructure_or_wrong_cause(self): + for code in ("bridge_unavailable", "main_thread_busy", "native_other", None): + self.assertTrue(h.evaluate({"success": False, "errorCode": code, "error": "bridge disconnected"}, {"errorCode": "wrong_document"})) + self.assertTrue(h.evaluate({"success": False, "errorCode": "wrong_document", "cause": "other"}, {"refusal": {"code": "wrong_document", "cause": "target"}})) + self.assertFalse(h.evaluate({"status": "ok", "output": {"success": False, "errorCode": "wrong_document", "cause": "target"}}, {"refusal": {"code": "wrong_document", "cause": "target"}})) + self.assertTrue(h.evaluate({"success": False, "error": "anything"}, {"pred": "must_refuse"})) + + def test_native_data_stays_nested_and_domain_flags_are_not_status(self): + inner = {"success": True, "data": {"isValid": False, "count": 0, "history": [{"success": False, "error": "old unrelated job"}]}} + raw = {"status": "ok", "output": json.dumps(inner)} + self.assertEqual(h.observe(raw).payload, inner) + self.assertFalse(h.evaluate(raw, {"ok": True, "fields": ["count"]})) + self.assertEqual(raw["output"], json.dumps(inner)) + + def test_malformed_unknown_predicate_and_empty_catalog_fail(self): + for raw in ({"status": "ok", "output": "{bad"}, {"status":"ok","output":"native failed"}, {"status":"ok","output":"[]"}, [], None): + self.assertTrue(h.evaluate(raw, {"ok": True})) + self.assertTrue(h.evaluate({"success": True}, {"pred": "typo"})) + self.assertTrue(h.evaluate({"success": True, "verbs": []}, {"pred": "verbs_match_plan"})) + self.assertTrue(h.evaluate({"history": {"ok": True}}, {"ok": True})) + + def test_script_stdout_and_plain_mcp_text_are_domain_data(self): + raw={'success':True,'output':'{"success":false} printed text','data':{'result':{'volume':10},'stdout':'failed is just a printed word'}} + self.assertFalse(h.evaluate(raw,{'ok':True}));self.assertEqual(h.observe(raw).payload,raw) + self.assertFalse(h.evaluate({'success':True,'content':[{'type':'text','text':'ordinary text'}]},{'ok':True})) + + def test_rich_native_replies_keep_fields_outside_output_summary(self): + # Sanitized real CLI reply shapes; names/paths/PIDs/timing history omitted. + bridges={'success':True,'ok':True,'status':'ok','error':'','output':'', + 'durationMs':70,'timeoutSeconds':10,'schemaVersion':1, + 'bridges':[{'name':'fusion360','version':'1.12.0-dev.4','status':'running'}], + 'summary':{'total':1},'current_os':'windows'} + state={'success':True,'status':'ok','error':'','addinResponding':True, + 'running':True,'activeDocument':'Untitled','blockingDialogs':[], + 'output':json.dumps({'running':True,'activeDocument':'Untitled','mainThreadBusy':None, + 'blockingDialogs':[],'screenshot':None}), + 'schemaVersion':1,'durationMs':248,'timeoutSeconds':60,'shell':'rust'} + for raw,field in ((bridges,'bridges'),(state,'addinResponding')): + with self.subTest(field=field): + self.assertIs(h.observe(raw).payload,raw) + self.assertFalse(h.evaluate(raw,{'ok':True,'fields':[field]})) + wrapped={'status':'ok','output':json.dumps(raw)} + self.assertEqual(h.observe(wrapped).payload,raw) + self.assertFalse(h.evaluate(wrapped,{'ok':True,'fields':[field]})) + state['output']=json.dumps({'success':False,'errorCode':'native_failed'}) + self.assertTrue(h.evaluate(state,{'ok':True})) + self.assertIs(h.observe(state).payload,state) + + +class Transport(unittest.TestCase): + def test_terminal_history_digest_binds_immutable_payload_not_only_operation_id(self): + request={'op':'parameter_set','sessionId':'s','operationId':'a','name':'幅','expression':'10 mm', + 'caller':{'sessionId':'relay'},'_callerBudgetMs':1000} + original=h.CallResult({},h.observe({}),[{'verb':'fusion_mechanical','args':request, + 'requestJson':json.dumps(request)}],True,1,True) + digest=h.mechanical_request_digest(request) + changed={**request,'expression':'20 mm'} + # Same original IDs and a terminal success cannot reconcile a different payload. + for recorded,expected in ((digest,'succeeded'),(h.mechanical_request_digest(changed),'ambiguous')): + status={'success':True,'sessionId':'s','operationId':'a','state':'succeeded', + 'history':{'requestDigest':recorded}, + 'result':{'success':True,'sessionId':'s','operationId':'a'}} + client=h.BridgeClient('box','run',runner=lambda *a,**k:NS(stdout=json.dumps(status),stderr='',returncode=0)) + self.assertEqual(client.reconcile_mechanical(original,'s','a',polls=1)['state'],expected) + self.assertEqual(digest,h.mechanical_request_digest({**request,'caller':{'different':True},'_callerBudgetMs':2})) + # Mutable caller/attempt args cannot replace the actual original wire request. + original.attempts[0]['args']['expression']='20 mm' + status['history']['requestDigest']=digest + self.assertEqual(client.reconcile_mechanical(original,'s','a',polls=1)['state'],'succeeded') + + def test_durable_pending_and_unknown_outcomes_are_ambiguous_through_envelopes(self): + replies = [ + {'success':False,'errorCode':'operation_in_progress','phase':'executing','outcome':'pending'}, + {'success':False,'errorCode':'operation_storage_failed','outcome':'unknown'}, + {'success':False,'errorCode':'operation_reconciliation_required','reconciliationRequired':True, + 'observedResult':{'success':True},'durability':{'terminalRecorded':False}}, + ] + for reply in replies: + for raw in (reply, {'status':'ok','output':json.dumps(reply)}, {'success':True,'data':reply}): + with self.subTest(raw=raw): + calls=[] + def process(command,**kwargs): + calls.append(command[-2]);return NS(stdout=json.dumps(raw),stderr='',returncode=0) + result=h.BridgeClient('box','run',runner=process).call( + 'fusion_mechanical',{'sessionId':'s','operationId':'a'},mutating=True) + self.assertTrue(result.ambiguous) + self.assertEqual(calls,['fusion_mechanical']) + + def test_only_exact_durable_not_executed_timeout_is_nonambiguous(self): + reply={'success':False,'sessionId':'s','operationId':'a','nativeExecutionStarted':False, + 'errorCode':'request_wait_timeout','error':'Command timed out after 1s', + 'durability':{'terminalRecorded':True,'recordedSessionId':'s','phase':'not_executed'}} + def call(raw): + return h.BridgeClient('box','run',runner=lambda *a,**k:NS(stdout=json.dumps(raw),stderr='',returncode=0)).call( + 'fusion_mechanical',{'sessionId':'s','operationId':'a'},mutating=True) + for raw in (reply, {'status':'ok','output':json.dumps(reply)}, {'success':True,'data':reply}): + self.assertFalse(call(raw).ambiguous) + for replacement in ({'operationId':'other'}, {'sessionId':'other'}, + {'durability':{'terminalRecorded':False,'phase':'not_executed'}}, + {'nativeExecutionStarted':None}, {'outcome':'unknown'}, + {'reconciliationRequired':True}): + self.assertTrue(call({**reply,**replacement}).ambiguous) + self.assertTrue(call({'success':False,'error':'bridge disconnected','output':reply}).ambiguous) + + def test_pending_and_lost_terminal_ack_reconcile_by_status_without_replay(self): + for initial in ({'success':False,'errorCode':'operation_in_progress','outcome':'pending'}, + {'success':False,'errorCode':'operation_reconciliation_required','outcome':'unknown', + 'observedResult':{'success':True},'durability':{'terminalRecorded':False}}): + replies=iter([initial, + {'success':True,'sessionId':'s','operationId':'a','state':'running','phase':'executing','outcome':'pending'}, + {'success':True,'sessionId':'s','operationId':'a','state':'reconciliation_required','outcome':'unknown'}, + {'success':True,'sessionId':'s','operationId':'a','state':'succeeded','phase':'succeeded', + 'result':{'success':True,'sessionId':'s','operationId':'a', + 'durability':{'terminalRecorded':True,'recordedSessionId':'s'}}}]) + calls=[] + def process(command,**kwargs): + calls.append(command[-2]);return NS(stdout=json.dumps({'status':'ok','output':json.dumps(next(replies))}),stderr='',returncode=0) + client=h.BridgeClient('box','run',runner=process) + result=client.call('fusion_mechanical',{'sessionId':'s','operationId':'a'},mutating=True) + self.assertTrue(result.ambiguous) + reconciled=client.reconcile_mechanical(result,'s','a',polls=3,interval=0) + self.assertEqual(reconciled['state'],'succeeded') + self.assertEqual(calls,['fusion_mechanical']+['fusion_mech_operation_status']*3) + + def test_terminal_label_cannot_hide_nested_unresolved_lifecycle(self): + original=h.CallResult({},h.observe({}),[{'verb':'fusion_mechanical', + 'requestJson':json.dumps({'sessionId':'s','operationId':'a'})}],True,1,True) + reply={'success':True,'sessionId':'s','operationId':'a','state':'failed', + 'result':{'success':False,'sessionId':'s','operationId':'a', + 'errorCode':'operation_reconciliation_required','outcome':'unknown'}} + client=h.BridgeClient('box','run',runner=lambda *a,**k:NS(stdout=json.dumps(reply),stderr='',returncode=0)) + self.assertEqual(client.reconcile_mechanical(original,'s','a',polls=1)['state'],'ambiguous') + + def test_campaign_retains_owned_fixtures_when_durable_outcome_stays_unknown(self): + from ralph_mechanical import Campaign + for reply in ({'success':False,'errorCode':'operation_in_progress','outcome':'pending'}, + {'success':False,'errorCode':'operation_storage_failed','outcome':'unknown'}, + {'success':False,'errorCode':'operation_reconciliation_required','reconciliationRequired':True}): + with tempfile.TemporaryDirectory() as folder: + calls=[] + def process(command,**kwargs): + calls.append(command[-2]);return NS(stdout=json.dumps(reply),stderr='',returncode=0) + campaign=object.__new__(Campaign) + campaign.client=h.BridgeClient('box','run',runner=process) + campaign.session='s';campaign.directory=Path(folder);campaign.ambiguous=False + campaign.owned=object();campaign.report={};campaign.persist=lambda:None + with patch.object(h.time,'sleep'),patch.object(campaign,'native') as native: + with self.assertRaisesRegex(RuntimeError,'Unresolved mutation'): + campaign.call('fusion_mechanical',{'sessionId':'s','operationId':'a'},mutating=True) + campaign.cleanup() + native.assert_not_called() + self.assertTrue(campaign.ambiguous) + self.assertEqual(campaign.report['cleanup'],'FAIL') + self.assertIn('retained',campaign.report['cleanupError']) + self.assertEqual(calls,['fusion_mechanical']+['fusion_mech_operation_status']*8) + + def test_absent_or_malformed_mutation_outcome_is_ambiguous(self): + for raw in ({},{'status':'ok','output':'{broken'},{'status':'ok','output':{}}, + {'status':'running'},{'status':'ok','output':'[]'}): + with self.subTest(raw=raw): + result=h.BridgeClient('box','run',runner=lambda *a,**k:NS(stdout=json.dumps(raw),stderr='',returncode=0)).call('fusion_mechanical',mutating=True) + self.assertTrue(result.ambiguous) + self.assertTrue(h.evaluate(result.raw,{'ok':True})) + raw={'status':'ok','output':{'success':False,'errorCode':'wrong_document','error':'Refused target'}} + result=h.BridgeClient('box','run',runner=lambda *a,**k:NS(stdout=json.dumps(raw),stderr='',returncode=0)).call('fusion_mechanical',mutating=True) + self.assertFalse(result.ambiguous) + + def test_uncoded_native_timeout_is_ambiguous_for_mutation(self): + for error in ('addin_command_timeout','addin_main_thread_blocked','bridge disconnected'): + result=h.BridgeClient('box','run',runner=lambda *a,error=error,**k:NS(stdout=json.dumps({'success':False,'error':error}),stderr='',returncode=0)).call('mutation',mutating=True) + self.assertTrue(result.ambiguous) + self.assertTrue(h.evaluate(result.raw,{'errorCode':'expected_guard'})) + + def test_timeout_mutation_is_dispatched_once_and_raw_attempt_preserved(self): + calls=[] + def process(command,**kwargs): + calls.append(command);raise subprocess.TimeoutExpired(command,1,output='partial stdout',stderr='late') + client=h.BridgeClient('box','run',runner=process) + result=client.call('fusion_mechanical',{'sessionId':'s','operationId':'a'},mutating=True) + self.assertEqual(len(calls),1);self.assertTrue(result.ambiguous) + self.assertEqual(result.attempts[0]['stdout'],'partial stdout') + self.assertTrue(h.evaluate(result.raw,{'ok':True})) + + def test_nonzero_exit_cannot_pass_even_if_stdout_claims_success(self): + result=h.BridgeClient('box','run',runner=lambda *a,**k:NS(stdout='{"success":true}',stderr='native crashed',returncode=1)).call('read') + self.assertTrue(h.evaluate(result.raw,{'ok':True})) + self.assertEqual(result.attempts[0]['returncode'],1) + self.assertEqual(result.attempts[0]['stderr'],'native crashed') + + def test_reconcile_only_polls_original_operation_and_retains_first_timeout(self): + calls=[] + def process(command,**kwargs): + calls.append(command[-2]) + if len(calls)==1:raise subprocess.TimeoutExpired(command,1) + return NS(stdout=json.dumps({'success':True,'sessionId':'s','operationId':'a','state':'succeeded','result':{'success':True,'sessionId':'s','operationId':'a','data':{'volume':10}}}),stderr='',returncode=0) + client=h.BridgeClient('box','run',runner=process) + original=client.call('fusion_mechanical',{'sessionId':'s','operationId':'a'},mutating=True) + self.assertEqual(client.reconcile_mechanical(original,'s','different',polls=1)['state'],'ambiguous') + result=client.reconcile_mechanical(original,'s','a',polls=1) + self.assertEqual(result['state'],'succeeded') + self.assertEqual(calls,['fusion_mechanical','fusion_mech_operation_status']) + self.assertTrue(result['original'][0]['raw']['_testTimeout']) + + def test_running_unknown_wrong_identity_and_contradictory_terminal_remain_ambiguous(self): + originals=h.CallResult({},h.observe({}),[{'verb':'fusion_mechanical','args':{'sessionId':'s','operationId':'a'},'requestJson':json.dumps({'sessionId':'s','operationId':'a'})}],True,1,True) + for reply in ({'success':True,'state':'running'}, {'success':False,'errorCode':'operation_unknown'}, + {'success':True,'state':'succeeded','sessionId':'s','operationId':'other','result':{'success':True}}, + {'success':True,'state':'succeeded','sessionId':'s','operationId':'a','result':{'success':False,'sessionId':'s','operationId':'a','error':'failed'}}): + client=h.BridgeClient('box','run',runner=lambda *a,reply=reply,**k:NS(stdout=json.dumps(reply),stderr='',returncode=0)) + self.assertEqual(client.reconcile_mechanical(originals,'s','a',polls=1)['state'],'ambiguous') + failed={'success':True,'state':'failed','sessionId':'s','operationId':'a','result':{'success':False,'sessionId':'s','operationId':'a','errorCode':'unhealthy_model','error':'bad model'}} + client=h.BridgeClient('box','run',runner=lambda *a,**k:NS(stdout=json.dumps(failed),stderr='',returncode=0)) + self.assertEqual(client.reconcile_mechanical(originals,'s','a',polls=1)['state'],'failed') + + def test_terminal_identity_is_required_inside_matching_wrapped_status(self): + original=h.CallResult({},h.observe({}),[{'verb':'fusion_mechanical', + 'requestJson':json.dumps({'sessionId':'s','operationId':'a'})}],True,1,True) + for state in ('succeeded','failed'): + native={'success':state=='succeeded','sessionId':'s','operationId':'a', + 'durability':{'terminalRecorded':True,'recordedSessionId':'s'}} + if state=='failed':native.update(errorCode='unhealthy_model',error='bad model') + variants=[(native,state)] + for field,value in (('sessionId','other'),('operationId','other'), + ('durability',{'terminalRecorded':True,'recordedSessionId':'other'})): + variants.append(({**native,field:value},'ambiguous')) + for missing in ('sessionId','operationId'): + variants.append(({k:v for k,v in native.items() if k!=missing},'ambiguous')) + for terminal,expected in variants: + with self.subTest(state=state,terminal=terminal): + status={'success':True,'sessionId':'s','operationId':'a','state':state,'result':terminal} + raw={'status':'ok','output':json.dumps({'success':True,'data':status})} + client=h.BridgeClient('box','run',runner=lambda *a,raw=raw,**k:NS(stdout=json.dumps(raw),stderr='',returncode=0)) + self.assertEqual(client.reconcile_mechanical(original,'s','a',polls=1)['state'],expected) + + def test_terminal_status_rejects_explicitly_nonterminal_or_conflicting_durability(self): + original=h.CallResult({},h.observe({}),[{'verb':'fusion_mechanical', + 'requestJson':json.dumps({'sessionId':'s','operationId':'a'})}],True,1,True) + for state in ('succeeded','failed'): + native={'success':state=='succeeded','sessionId':'s','operationId':'a'} + if state=='failed':native.update(errorCode='unhealthy_model',error='bad model') + for status_phase,durability,expected in ( + (None,None,state), + (state,{'terminalRecorded':True,'phase':state,'recordedSessionId':'s'},state), + (state,{'terminalRecorded':False},'ambiguous'), + (state,{'terminalRecorded':True,'phase':'executing'},'ambiguous'), + ('executing',{'terminalRecorded':True,'phase':state},'ambiguous'), + (state,{'terminalRecorded':True,'phase':'succeeded' if state=='failed' else 'failed'},'ambiguous')): + with self.subTest(state=state,status_phase=status_phase,durability=durability): + terminal=dict(native) + if durability is not None:terminal['durability']=durability + status={'success':True,'sessionId':'s','operationId':'a','state':state,'result':terminal} + if status_phase is not None:status['phase']=status_phase + client=h.BridgeClient('box','run',runner=lambda *a,status=status,**k:NS(stdout=json.dumps(status),stderr='',returncode=0)) + self.assertEqual(client.reconcile_mechanical(original,'s','a',polls=1)['state'],expected) + skipped={'success':True,'sessionId':'s','operationId':'a','state':'failed','phase':'not_executed', + 'result':{'success':False,'sessionId':'s','operationId':'a','nativeExecutionStarted':False, + 'errorCode':'request_wait_timeout','durability':{'terminalRecorded':True, + 'recordedSessionId':'s','phase':'not_executed'}}} + client=h.BridgeClient('box','run',runner=lambda *a,**k:NS(stdout=json.dumps(skipped),stderr='',returncode=0)) + self.assertEqual(client.reconcile_mechanical(original,'s','a',polls=1)['state'],'failed') + skipped['result']['durability']['phase']='failed' + self.assertEqual(client.reconcile_mechanical(original,'s','a',polls=1)['state'],'ambiguous') + + def test_terminal_proof_rejects_outer_unknown_and_contradictory_execution(self): + original=h.CallResult({},h.observe({}),[{'verb':'fusion_mechanical', + 'requestJson':json.dumps({'sessionId':'s','operationId':'a'})}],True,1,True) + for state,phase in (('succeeded','succeeded'),('failed','failed'),('failed','not_executed')): + native={'success':state=='succeeded','sessionId':'s','operationId':'a', + 'durability':{'terminalRecorded':True,'recordedSessionId':'s','phase':phase}} + if state=='failed':native.update(errorCode='guard_failure',error='Refused') + if phase=='not_executed':native['nativeExecutionStarted']=False + baseline={'success':True,'sessionId':'s','operationId':'a','state':state,'phase':phase, + 'nativeExecutionStarted':None,'result':native} + variants=[(baseline,state)] + variants.extend(({**baseline,**change},'ambiguous') for change in ( + {'outcome':'unknown'},{'outcome':'pending'},{'reconciliationRequired':True})) + contradictory=phase=='not_executed' + variants.append(({**baseline,'nativeExecutionStarted':contradictory},'ambiguous')) + variants.append(({**baseline,'result':{**native,'nativeExecutionStarted':contradictory}},'ambiguous')) + if phase=='not_executed': + variants.append(({**baseline,'result':{k:v for k,v in native.items() if k!='nativeExecutionStarted'}},'ambiguous')) + for status,expected in variants: + with self.subTest(phase=phase,status=status): + raw={'status':'ok','output':json.dumps(status)} + client=h.BridgeClient('box','run',runner=lambda *a,raw=raw,**k:NS(stdout=json.dumps(raw),stderr='',returncode=0)) + self.assertEqual(client.reconcile_mechanical(original,'s','a',polls=1)['state'],expected) + + def test_caller_argument_reuse_cannot_rewrite_timed_out_operation_identity(self): + calls=[] + def process(command,**kwargs):calls.append(command);raise subprocess.TimeoutExpired(command,1) + client=h.BridgeClient('box','run',runner=process) + args={'sessionId':'s','operationId':'a','nested':{'value':1}} + original=client.call('fusion_mechanical',args,mutating=True) + args['operationId']='b';args['nested']['value']=2 + self.assertEqual(original.attempts[0]['args']['operationId'],'a') + self.assertEqual(original.attempts[0]['args']['nested']['value'],1) + self.assertEqual(client.reconcile_mechanical(original,'s','b',polls=1)['state'],'ambiguous') + self.assertEqual(len(calls),1) + + +class Ownership(unittest.TestCase): + def make(self,folder):return h.OwnedRun(Path(folder)/'ownership.json','box','run-123456789012') + + def test_exception_runs_cleanup_only_for_verified_owned_document(self): + with tempfile.TemporaryDirectory() as folder: + owned=self.make(folder);closed=[] + with self.assertRaises(RuntimeError),owned: + owned.register_document('session:doc','scratch',cleanup=lambda doc:closed.append(doc) or {'success':True,'closedDocumentId':doc,'closed':True},verify_owner=lambda doc:{'documentId':doc,'ownerRunId':owned.run_id}) + owned.mark_checkpoint('session:doc',owned.manifest['hostScratch']+'/proof.f3d','a'*64,True) + raise RuntimeError('test interrupted') + self.assertEqual(closed,['session:doc']);self.assertEqual(owned.manifest['state'],'complete') + owned.cleanup();self.assertEqual(closed,['session:doc']) + + def test_missing_checkpoint_and_wrong_identity_never_call_closer(self): + for checkpoint in (False,True): + with tempfile.TemporaryDirectory() as folder: + owned=self.make(folder);closed=[] + owned.register_document('session:doc','scratch',cleanup=lambda doc:closed.append(doc),verify_owner=lambda doc:{'documentId':'user-doc','ownerRunId':owned.run_id}) + if checkpoint:owned.mark_checkpoint('session:doc',owned.manifest['hostScratch']+'/proof.f3d','a'*64,True) + self.assertEqual(owned.cleanup()[0]['status'],'FAIL');self.assertFalse(closed) + + def test_failed_owner_read_cannot_authorize_close_or_file_delete(self): + with tempfile.TemporaryDirectory() as folder: + owned=self.make(folder);effects=[];root=owned.manifest['hostScratch'] + owned.register_document('session:doc','scratch',cleanup=lambda doc:effects.append(doc),verify_owner=lambda doc:{'success':False,'error':'reread failed','documentId':doc,'ownerRunId':owned.run_id}) + owned.mark_checkpoint('session:doc',root+'/proof.f3d','a'*64,True) + owned.register_file(root+'/stage.step','a'*64,cleanup=lambda path:effects.append(path),verify_owner=lambda path:{'success':False,'error':'reread failed','path':path,'ownerRunId':owned.run_id,'sha256':'a'*64,'isSymlink':False}) + owned.cleanup();self.assertFalse(effects);self.assertEqual(owned.manifest['state'],'cleanup_failed') + + def test_only_registered_scratch_files_are_deleted_and_unowned_sentinel_survives(self): + with tempfile.TemporaryDirectory() as folder: + owned=self.make(folder);root=owned.manifest['hostScratch'];files={root+'/owned.step',root+'/sentinel.txt'} + owned.register_file(root+'/owned.step','a'*64,cleanup=lambda path:files.remove(path) or {'success':True},verify_owner=lambda path:{'path':path,'ownerRunId':owned.run_id,'isSymlink':False,'sha256':'a'*64}) + owned.cleanup();self.assertEqual(files,{root+'/sentinel.txt'}) + for path in ('C:/user/design.f3d',root+'/../other.step',root): + with self.assertRaises(ValueError):owned.register_file(path) + + def test_resume_preserves_target_and_requires_rebound_identity_adapters(self): + with tempfile.TemporaryDirectory() as folder: + owned=self.make(folder);owned.register_document('session:doc','scratch') + owned.mark_checkpoint('session:doc',owned.manifest['hostScratch']+'/proof.f3d','a'*64,True) + with self.assertRaises(ValueError):h.OwnedRun.resume(owned.path,'different box') + resumed=h.OwnedRun.resume(owned.path,'box');self.assertEqual(resumed.cleanup()[0]['status'],'FAIL') + with self.assertRaises(ValueError):self.make(folder) + + def test_discovered_scratch_root_remains_fixed_after_registration(self): + with tempfile.TemporaryDirectory() as folder: + owned=self.make(folder);root='C:/Users/Tester/AppData/Local/Temp/adom-fusion-ralph/'+owned.run_id + owned.set_host_scratch(root);owned.register_file(root+'/native.f3d',retain=True) + self.assertEqual(owned.manifest['hostScratch'],root) + with self.assertRaises(ValueError):owned.set_host_scratch('D:/tmp/'+owned.run_id) + + def test_preferences_restore_actual_baseline_and_preserve_concurrent_change(self): + for concurrent in (False,True): + with tempfile.TemporaryDirectory() as folder: + owned=self.make(folder);state={'theme':'light'} + set_theme=owned.preserve_preference('theme',lambda:state['theme'],lambda value:state.update(theme=value)) + set_theme('darkblue') + if concurrent:state['theme']='user-new-choice' + owned.cleanup();self.assertEqual(state['theme'],'user-new-choice' if concurrent else 'light') + self.assertEqual(owned.manifest['state'],'cleanup_failed' if concurrent else 'complete') + + def test_resumed_preference_and_custom_cleanup_never_disappear(self): + with tempfile.TemporaryDirectory() as folder: + owned=self.make(folder);state={'theme':'light'} + read=lambda:state['theme'];write=lambda value:state.update(theme=value) + owned.preserve_preference('theme',read,write)('darkblue') + owned.add_cleanup('owned resource',lambda:{'success':True}) + resumed=h.OwnedRun.resume(owned.path,'box',owned.run_id);resumed.cleanup() + self.assertEqual(resumed.manifest['state'],'cleanup_failed');self.assertEqual(state['theme'],'darkblue') + resumed.bind_preference_restore('theme',read,write);resumed.add_cleanup('owned resource',lambda:{'success':True}) + resumed.cleanup();self.assertEqual(resumed.manifest['state'],'complete');self.assertEqual(state['theme'],'light') + self.assertTrue(any(row['status']=='FAIL' for row in resumed.manifest['cleanup'])) + self.assertTrue(all(row['status']=='PASS' for row in resumed.manifest['cleanupLatest'].values())) + + +class RunnerSafety(unittest.TestCase): + def test_only_never_authorizes_manual_or_fixture_or_changed_args(self): + for name in ('fusion_kill','fusion_open_cloud_file','fusion_set_preference','fusion_run_modeling_script', + 'fusion_readiness','fusion_account','fusion_get_app_state','fusion_get_preferences', + 'fusion_mech_capabilities','fusion_mechanical#session_guard','fusion_mech_operation_status'): + status,_=runner.case_gate(name,runner.PLAN[name],'all',{name}) + self.assertIn(status,('MANUAL','SKIP')) + for value in (False,1,1.0,'true',None): + changed={**runner.PLAN['fusion_uninstall'],'args':{'dryRun':value}} + self.assertEqual(runner.case_gate('fusion_uninstall',changed)[0],'BLOCKED') + self.assertIsNone(runner.case_gate('fusion_uninstall',runner.PLAN['fusion_uninstall'])[0]) + + def test_unknown_only_refuses_before_constructing_client(self): + with patch.object(runner,'BridgeClient') as client,contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as error:runner.main(['--target','offline','--only','misspelled']) + self.assertEqual(error.exception.code,2);client.assert_not_called() + + def test_manual_only_makes_zero_host_calls_and_reports_no_pass(self): + calls=[] + class Client: + def __init__(self,*a,**k):pass + def call(self,*a,**k):calls.append(a);raise AssertionError('host called') + with tempfile.TemporaryDirectory() as folder,contextlib.redirect_stdout(io.StringIO()): + code=runner.main(['--target','offline','--only','fusion_kill','--report-dir',folder],client_factory=Client) + report=json.loads(next(Path(folder).glob('*/report.json')).read_text()) + self.assertEqual(code,2);self.assertEqual(calls,[]);self.assertNotIn('PASS',report['counts']) + self.assertEqual(next(r for r in report['results'] if r['verb']=='focus_invariance')['status'],'UNVERIFIED') + + def test_runner_exception_still_writes_final_report_and_manifest(self): + class Client: + def __init__(self,*a,**k):pass + def call(self,*a,**k):raise KeyboardInterrupt('injected') + with tempfile.TemporaryDirectory() as folder,contextlib.redirect_stdout(io.StringIO()): + code=runner.main(['--target','offline','--only','fusion_describe','--report-dir',folder],client_factory=Client) + report=json.loads(next(Path(folder).glob('*/report.json')).read_text()) + manifest=json.loads(next(Path(folder).glob('*/ownership.json')).read_text()) + self.assertEqual(code,1);self.assertEqual(manifest['state'],'complete') + self.assertTrue(any(r['verb']=='harness' and r['status']=='FAIL' for r in report['results'])) + + +if __name__ == '__main__':unittest.main() diff --git a/tests/test_http_server.py b/tests/test_http_server.py new file mode 100644 index 0000000..df52d58 --- /dev/null +++ b/tests/test_http_server.py @@ -0,0 +1,280 @@ +"""Actual loopback HTTP tests; Fusion event delivery is the only simulated boundary.""" +import concurrent.futures +import http.client +import importlib.util +import json +from pathlib import Path +import socket +import sys +import threading +import time +import types +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] + + +def load_server(): + package = types.ModuleType("transport_test_addin") + package.__path__ = [str(ROOT / "addin/AdomBridge")] + commands = types.ModuleType(package.__name__ + ".commands") + commands.__path__ = [] + cloud = types.ModuleType(commands.__name__ + ".cloud_documents") + cloud.get_walk_progress = lambda: None + mech = types.ModuleType(commands.__name__ + ".mechanical") + mech.operation_status = lambda app, args: {"success": True, "operationId": args.get("operationId")} + mech.capabilities = lambda app, args: {"success": True, "operations": ["inspect"]} + mech.admit_for_dispatch = lambda args, request_id: (args, None, None) + adsk = types.ModuleType("adsk") + adsk.core = types.ModuleType("adsk.core") + adsk.core.Application = object + modules = {m.__name__: m for m in (package, commands, cloud, mech, adsk, adsk.core)} + spec = importlib.util.spec_from_file_location(package.__name__ + ".http_server", ROOT / "addin/AdomBridge/http_server.py") + module = importlib.util.module_from_spec(spec) + with patch.dict(sys.modules, modules): + spec.loader.exec_module(module) + return module, modules + + +class HttpTransport(unittest.TestCase): + def setUp(self): + self.m, modules = load_server() + self.modules = patch.dict(sys.modules, modules) + self.modules.start() + self.received = [] + self.callback = lambda request: self.m.set_result(request["request_id"], {"success": True, "echo": request["args"]}) + + def fire(event, data): + request = json.loads(data) + self.received.append(request) + return self.callback(request) + + self.m._app = types.SimpleNamespace(fireCustomEvent=fire) + self.m._custom_event_id = "test-event" + self.server = self.m.ThreadingHTTPServer(("127.0.0.1", 0), self.m.AdomBridgeHandler) + self.server._bridge_context = self.m.ListenerContext(self.m._app, self.m._custom_event_id) + self.server.daemon_threads = True + self.errors = [] + self.server.handle_error = lambda request, address: self.errors.append(sys.exc_info()[1]) + self.thread = threading.Thread(target=self.server.serve_forever, kwargs={"poll_interval": 0.01}) + self.thread.start() + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.thread.join(2) + self.modules.stop() + self.assertFalse(self.thread.is_alive()) + self.assertEqual(self.errors, [], "request handler raised instead of returning a structured result") + self.assertEqual(self.m._result_events, {}) + self.assertEqual(self.m._result_data, {}) + self.assertEqual(self.m._pending_request_ids, set()) + self.assertFalse(self.m._main_thread_lock.locked()) + + def request(self, data=None, *, raw=None, headers=None, method="POST", path="/command"): + connection = http.client.HTTPConnection(*self.server.server_address, timeout=2) + body = raw if raw is not None else json.dumps(data).encode() + try: + connection.request(method, path, body, headers or {}) + response = connection.getresponse() + return response.status, json.loads(response.read()) + finally: + connection.close() + + def test_fast_event_cannot_outrun_waiter(self): + status, result = self.request({"command": "inspect", "args": {"text": "螺栓 Ø8 — mm"}}) + self.assertEqual(status, 200) + self.assertEqual(result, {"success": True, "echo": {"text": "螺栓 Ø8 — mm"}}) + + def test_simultaneous_requests_keep_results_separate(self): + with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(lambda i: self.request({"command": "inspect", "args": {"index": i}}), range(24))) + self.assertEqual([v[1]["echo"]["index"] for v in results], list(range(24))) + self.assertEqual(len({r["request_id"] for r in self.received}), 24) + + def test_late_callback_is_dropped_after_timeout(self): + self.callback = lambda request: None + status, result = self.request({"command": "inspect", "args": {}, "timeout": 0.01}) + self.assertFalse(result["success"]) + request_id = self.received[0]["request_id"] + self.assertFalse(self.m.is_request_alive(request_id)) + self.m.set_result(request_id, {"success": True}) + self.assertEqual(self.m._result_data, {}) + + def test_fire_exception_cleans_waiter_and_busy_state(self): + def fail(request): + raise RuntimeError("event delivery failed") + self.callback = fail + status, result = self.request({"command": "inspect", "args": {}}) + self.assertEqual(status, 500) + self.assertFalse(result["success"]) + self.assertIsNone(self.m._get_busy()) + + def test_status_and_operation_status_bypass_busy_cad_queue(self): + started = threading.Event() + complete = threading.Event() + def slow(request): + started.set() + self.assertTrue(complete.wait(1)) + self.m.set_result(request["request_id"], {"success": True}) + self.callback = slow + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + pending = pool.submit(self.request, {"command": "mechanical", "args": {}}) + self.assertTrue(started.wait(1)) + try: + self.assertTrue(self.request(method="GET", path="/status")[1]["busy"]) + result = self.request({"command": "mech_operation_status", "args": {"operationId": "owned"}})[1] + self.assertEqual(result["operationId"], "owned") + self.assertEqual(len(self.received), 1) + finally: + complete.set() + self.assertTrue(pending.result()[1]["success"]) + + def test_malformed_json_is_a_structured_error(self): + status, result = self.request(raw=b"{broken") + self.assertEqual(status, 400) + self.assertFalse(result["success"]) + self.assertEqual(self.received, []) + + def test_nonobject_command_and_args_are_rejected_before_dispatch(self): + for value in ([], None, "text", {"command": []}, {"command": "inspect", "args": []}): + with self.subTest(value=value): + status, result = self.request(value) + self.assertEqual(status, 400) + self.assertEqual(result["errorCode"], "invalid_input") + self.assertEqual(self.received, []) + + def test_invalid_timeouts_are_rejected_before_dispatch(self): + for timeout in (True, -1, 0, "10", float("nan"), float("inf"), 1e30): + with self.subTest(timeout=timeout): + status, result = self.request({"command": "inspect", "args": {}, "timeout": timeout}) + self.assertEqual(status, 400) + self.assertEqual(result["errorCode"], "invalid_input") + self.assertEqual(self.received, []) + + def test_duplicate_callback_cannot_replace_first_result(self): + def duplicate(request): + self.m.set_result(request["request_id"], {"success": False, "error": "original failure"}) + self.m.set_result(request["request_id"], {"success": True}) + self.callback = duplicate + result = self.request({"command": "inspect", "args": {}})[1] + self.assertEqual(result, {"success": False, "error": "original failure"}) + + def test_body_size_limit_precedes_read_or_dispatch(self): + status, result = self.request(raw=b"", headers={"Content-Length": str(64 * 1024 * 1024)}) + self.assertEqual(status, 413) + self.assertEqual(result["errorCode"], "request_too_large") + self.assertEqual(self.received, []) + + def test_invalid_content_length_is_structured_error(self): + for value in ("bad", "-1"): + with self.subTest(value=value): + status, result = self.request(raw=b"", headers={"Content-Length": value}) + self.assertEqual(status, 400) + self.assertEqual(result["errorCode"], "invalid_input") + + def test_watchdog_cannot_release_or_erase_live_owner(self): + self.m._main_thread_lock.acquire() + self.m._set_busy("get_app_state", "owner", 300) + self.m._busy_state["started_monotonic"] -= 400 + self.m._pending_request_ids.add("owner") + event = threading.Event() + self.m._result_events["owner"] = event + try: + with patch.object(self.m, "_watchdog_note") as note: + self.m._watchdog_check() + note.assert_called_once() + self.assertTrue(self.m._main_thread_lock.locked()) + self.assertEqual(self.m._get_busy()["request_id"], "owner") + self.assertEqual(self.m._pending_request_ids, {"owner"}) + self.assertIs(self.m._result_events["owner"], event) + self.m._clear_busy("different-request") + self.assertEqual(self.m._get_busy()["request_id"], "owner") + finally: + self.m._main_thread_lock.release() + self.m._clear_busy("owner") + self.m._pending_request_ids.clear() + self.m._result_events.clear() + + def test_watchdog_uses_effective_caller_deadline(self): + self.m._set_busy("get_app_state", "owner", 300) + self.m._busy_state["started_monotonic"] -= 60 + with patch.object(self.m, "_watchdog_note") as note: + self.m._watchdog_check() + note.assert_not_called() + self.m._clear_busy("owner") + + def test_partial_body_times_out_without_dispatch(self): + self.m.REQUEST_READ_TIMEOUT = 0.02 + with socket.create_connection(self.server.server_address, timeout=1) as sock: + sock.sendall(b"POST /command HTTP/1.0\r\nContent-Length: 100\r\n\r\n{") + response = http.client.HTTPResponse(sock) + response.begin() + self.assertEqual(response.status, 408) + self.assertEqual(json.loads(response.read())["errorCode"], "request_read_timeout") + self.assertEqual(self.received, []) + + def test_conflicting_framing_and_invalid_unicode_are_rejected(self): + for headers in (b"Content-Length: 2\r\nContent-Length: 2\r\n", b"Transfer-Encoding: chunked\r\n"): + with self.subTest(headers=headers), socket.create_connection(self.server.server_address, timeout=1) as sock: + sock.sendall(b"POST /command HTTP/1.0\r\n" + headers + b"\r\n{}") + response = http.client.HTTPResponse(sock) + response.begin() + self.assertEqual(response.status, 400) + self.assertEqual(json.loads(response.read())["errorCode"], "invalid_input") + self.assertEqual(self.request(raw=b"\xff")[0], 400) + self.assertEqual(self.received, []) + + def test_trickling_body_cannot_extend_absolute_deadline(self): + self.m.REQUEST_READ_TIMEOUT = 0.08 + body = b'{"command":"inspect","args":{}}' + with socket.create_connection(self.server.server_address, timeout=1) as sock: + sock.sendall(b"POST /command HTTP/1.0\r\nContent-Length: " + str(len(body)).encode() + b"\r\n\r\n") + for byte in body[:8]: + try: + sock.sendall(bytes([byte])) + except OSError: + break + time.sleep(0.02) + response = http.client.HTTPResponse(sock) + response.begin() + self.assertEqual(response.status, 408) + self.assertEqual(json.loads(response.read())["errorCode"], "request_read_timeout") + self.assertEqual(self.received, []) + + def test_trickling_header_cannot_extend_absolute_deadline(self): + self.m.REQUEST_READ_TIMEOUT = 0.06 + with socket.create_connection(self.server.server_address, timeout=1) as sock: + sock.sendall(b"POST /command HTTP/1.0\r\nX-Slow: ") + for _ in range(8): + try: + sock.sendall(b"x") + except OSError: + break + time.sleep(0.02) + self.assertEqual(sock.recv(1024), b"") + self.assertEqual(self.received, []) + + def test_slow_valid_body_does_not_truncate_large_response(self): + self.m.REQUEST_READ_TIMEOUT = 0.3 + expected_size = 8 * 1024 * 1024 + self.callback = lambda request: self.m.set_result(request["request_id"], {"success": True, "payload": "x" * expected_size}) + body = b'{"command":"inspect"}' + with socket.create_connection(self.server.server_address, timeout=2) as sock: + sock.sendall(b"POST /command HTTP/1.0\r\nContent-Length: " + str(len(body)).encode() + b"\r\n\r\n" + body[:1]) + time.sleep(0.18) + sock.sendall(body[1:-1]) + time.sleep(0.01) + sock.sendall(body[-1:]) + time.sleep(0.22) # exceed the leftover read timeout before draining + response = http.client.HTTPResponse(sock) + response.begin() + self.assertEqual(response.status, 200) + result = json.loads(response.read()) + self.assertTrue(result["success"]) + self.assertEqual(len(result["payload"]), expected_size) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_live_feed_drain.py b/tests/test_live_feed_drain.py new file mode 100644 index 0000000..77d1672 --- /dev/null +++ b/tests/test_live_feed_drain.py @@ -0,0 +1,188 @@ +"""Exercise capture evidence integrity and shared-feed races without a host.""" +import base64 +import contextlib +import importlib.util +import io +import json +from pathlib import Path +import struct +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from fusion_harness import CallResult, observe + +SPEC = importlib.util.spec_from_file_location('feed_drain', Path(__file__).resolve().parents[1] / 'tools/live_feed_drain.py') +feed = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(feed) + + +class FeedEvidenceTests(unittest.TestCase): + def campaign(self, *, png=None, replaced=False, cost=0, inject_code=0, + inject_cost=0, producer_running=False, transport_timeout=False, + truncated=False, missing_popup=False, fresh_start=False): + try: + from PIL import Image + except ImportError: + self.skipTest('Live capture tests require Pillow; see requirements-test.txt') + if png is None: + stream = io.BytesIO() + Image.new('RGB', (4, 3), (120, 130, 140)).save(stream, format='PNG') + png = stream.getvalue() + calls, shots, elapsed, epoch = [], [], [0.0], [100] + status_calls, batches = [0], [0] + + class Client: + def __init__(self, *args, **kwargs): + pass + + def call(self, verb, args, **kwargs): + calls.append((verb, args, kwargs)) + if verb == 'read_file': + elapsed[0] += cost + raw = {'success': True, 'content': base64.b64encode(png).decode()} + elif args['action'] == 'status': + status_calls[0] += 1 + raw = {'success': True, 'running': True if status_calls[0] < 3 else producer_running, + 'startedAt': epoch[0], 'tick': 1 if status_calls[0] < 3 else 3} + if fresh_start and status_calls[0] == 1: + raw = {'success': True, 'running': False, 'startedAt': 0, 'tick': 0} + if transport_timeout and status_calls[0] >= 4: + raw = {'success': False, 'errorCode': 'timeout', 'error': 'CLI timeout; operation outcome is unknown'} + elif args['action'] == 'start' and fresh_start: + elapsed[0] += 1.0 + raw = {'success': True, 'started': True} + elif args['action'] == 'frames': + batches[0] += 1 + if replaced: + epoch[0] += 1 + rows = [{'kind': 'main', 'tick': tick, 'path': 'C:/frame.png', 'popups': int(missing_popup)} for tick in range(1, 4)] if batches[0] == 1 else [] + raw = {'success': True, 'healthy': True, 'running': producer_running, 'tick': 3, + 'frames': rows, 'count': len(rows) + int(truncated)} + else: + raise AssertionError('Unexpected shared-feed mutation') + return CallResult(raw, observe(raw), [], False, 0) + + def inject(*args, **kwargs): + shots.append((args, kwargs)) + elapsed[0] += inject_cost + return SimpleNamespace(returncode=inject_code, stdout='', stderr='') + + with tempfile.TemporaryDirectory() as tmp: + directory = Path(tmp) / 'evidence' + with patch.object(feed, 'BridgeClient', Client), patch.object(feed.subprocess, 'run', inject), \ + patch.object(feed.time, 'monotonic', lambda: elapsed[0]), \ + patch.object(feed.time, 'sleep', lambda seconds: elapsed.__setitem__(0, elapsed[0]+seconds)), \ + contextlib.redirect_stdout(io.StringIO()): + code = feed.main(['--target', 'test', '--channel', 'test', '--max-sec', '1', '--drain-sec', '1', '--evidence', str(directory)]) + report = json.loads((directory / 'report.json').read_text()) + self.assertFalse(any(args.get('action') == 'stop' for _, args, _ in calls)) + self.assertEqual(sum(args.get('action') == 'start' for _, args, _ in calls), int(fresh_start)) + return code, report, calls, shots + + def test_valid_pixels_transfer_without_claiming_visual_pass(self): + code, report, _, shots = self.campaign() + self.assertEqual(code, 0) + self.assertEqual(report['status'], 'CAPTURED_REVIEW_REQUIRED') + self.assertTrue(report['captureComplete']) + self.assertEqual(report['visualVerdict'], 'NOT_REVIEWED') + self.assertEqual(len(shots), 3) + self.assertTrue(all(x['width'] == 4 and x['height'] == 3 and len(x['sha256']) == 64 for x in report['frames'])) + + def test_header_without_pixels_cannot_transfer(self): + png = b'\x89PNG\r\n\x1a\n' + struct.pack('>I', 13) + b'IHDR' + struct.pack('>II', 10, 10) + bytes(9) + code, report, _, shots = self.campaign(png=png) + self.assertEqual(code, 1) + self.assertEqual(report['status'], 'FAIL') + self.assertEqual(shots, []) + + def test_replacement_during_fetch_discards_foreign_batch(self): + code, report, calls, shots = self.campaign(replaced=True) + self.assertEqual(code, 1) + self.assertIn('batch discarded', ' '.join(report['errors'])) + self.assertFalse(any(verb == 'read_file' for verb, _, _ in calls)) + self.assertEqual(shots, []) + + def test_expired_read_does_not_inject_or_start_next_frame(self): + code, report, calls, shots = self.campaign(cost=2) + self.assertEqual(code, 1) + self.assertEqual(len([v for v, _, _ in calls if v == 'read_file']), 1) + self.assertEqual(shots, []) + self.assertTrue(all(1 <= kw['timeout'] <= 2 for _, _, kw in calls)) + self.assertEqual(report['status'], 'PARTIAL_CAPTURE') + self.assertFalse(report['captureComplete']) + + def test_failed_injection_is_not_counted(self): + code, report, _, _ = self.campaign(inject_code=1) + self.assertEqual(code, 1) + self.assertEqual(report['transferred'], 0) + + def test_drain_tail_allows_native_end_delivery_after_capture_window(self): + code, report, _, shots = self.campaign(cost=.1, inject_cost=.3) + self.assertEqual(code, 0) + self.assertTrue(report['captureComplete']) + self.assertEqual(len(shots), 3) + + def test_owned_start_keeps_native_duration_and_full_delivery_allowance(self): + code, report, calls, shots = self.campaign(fresh_start=True, cost=.1, inject_cost=.3) + starts = [args for _, args, _ in calls if args.get('action') == 'start'] + self.assertEqual(starts, [{'action': 'start', 'intervalSec': 1.0, 'maxSec': 1.0}]) + # Start consumed one second, then all three deliveries consumed 1.2s. + # The first read still gets the full two-second delivery allowance. + first_read = next(options for verb, _, options in calls if verb == 'read_file') + self.assertEqual(first_read['timeout'], 2.0) + self.assertEqual(code, 0) + self.assertTrue(report['ownsStart']) + self.assertEqual(report['nativeDurationSec'], 1.0) + self.assertEqual(report['drainTailSec'], 1.0) + self.assertTrue(report['feedEnded']) + self.assertTrue(report['captureComplete']) + self.assertFalse(report['stopIssued']) + self.assertEqual(report['transferred'], 3) + self.assertEqual(len(shots), 3) + self.assertEqual([f['status'] for f in report['frames']], ['TRANSFERRED'] * 3) + + def test_unusable_tail_budget_never_starts_injection_and_retains_backlog(self): + code, report, _, shots = self.campaign(cost=1.95) + self.assertEqual(code, 1) + self.assertEqual(shots, []) + self.assertEqual(report['status'], 'PARTIAL_CAPTURE') + self.assertEqual(report['errors'], []) + self.assertEqual([f['status'] for f in report['frames']], ['NOT_TRANSFERRED'] * 3) + self.assertIn('sha256', report['frames'][0]) + + def test_successful_injection_is_not_reclassified_when_next_work_has_no_budget(self): + code, report, _, shots = self.campaign(inject_cost=1.5) + self.assertEqual(code, 1) + self.assertEqual(report['transferred'], 1) + self.assertEqual(report['frames'][0]['status'], 'TRANSFERRED') + self.assertEqual(len(shots), 1) + self.assertEqual(report['status'], 'PARTIAL_CAPTURE') + + def test_running_feed_at_deadline_is_explicitly_partial(self): + code, report, _, _ = self.campaign(producer_running=True) + self.assertEqual(code, 1) + self.assertFalse(report['captureComplete']) + self.assertFalse(report['feedEnded']) + self.assertEqual(report['status'], 'PARTIAL_CAPTURE') + + def test_actual_status_timeout_remains_failure_after_successful_transfers(self): + code, report, _, _ = self.campaign(producer_running=True, transport_timeout=True) + self.assertEqual(code, 1) + self.assertEqual(report['status'], 'FAIL') + self.assertEqual(report['transferred'], 3) + self.assertIn('infrastructure failure', ' '.join(report['errors'])) + + def test_native_truncation_or_missing_popup_never_claims_complete(self): + for options in ({'truncated': True}, {'missing_popup': True}): + with self.subTest(options=options): + code, report, _, _ = self.campaign(**options) + self.assertEqual(code, 1) + self.assertEqual(report['transferred'], 3) + self.assertFalse(report['captureComplete']) + self.assertEqual(report['status'], 'PARTIAL_CAPTURE') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_mechanical.py b/tests/test_mechanical.py new file mode 100644 index 0000000..316d82d --- /dev/null +++ b/tests/test_mechanical.py @@ -0,0 +1,242 @@ +"""Offline safety regressions; live kernel behavior has a separate host gate.""" +import importlib +from email.message import Message +import importlib.util +import io +import json +from pathlib import Path +import sys +import tempfile +from types import ModuleType, SimpleNamespace as NS +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +adsk = ModuleType('adsk'); adsk.core = ModuleType('adsk.core'); adsk.fusion = ModuleType('adsk.fusion') +adsk.core.Application = object +adsk.fusion.Design = NS(cast=lambda value: value) +adsk.fusion.CalculationAccuracy = NS(LowCalculationAccuracy=0, MediumCalculationAccuracy=1, HighCalculationAccuracy=2, VeryHighCalculationAccuracy=3) +adsk.fusion.FeatureHealthStates = NS(HealthyFeatureHealthState=0) +adsk.fusion.JointDirections = NS(XAxisJointDirection=0, YAxisJointDirection=1, ZAxisJointDirection=2, CustomJointDirection=3) +for module in (adsk, adsk.core, adsk.fusion): sys.modules[module.__name__] = module +pkg = ModuleType('mechanical_test_commands'); pkg.__path__ = [str(ROOT/'addin/AdomBridge/commands')]; sys.modules[pkg.__name__] = pkg +m = importlib.import_module('mechanical_test_commands.mechanical') +c = importlib.import_module('mechanical_test_commands.mechanical.core') +x = importlib.import_module('mechanical_test_commands.mechanical.exporting') +ops = importlib.import_module('mechanical_test_commands.mechanical.operations') +pp = importlib.import_module('mechanical_test_commands.physical_properties') +bom = importlib.import_module('mechanical_test_commands.assembly_bom') + +class Collection: + def __init__(self, values=()): self.values=list(values) + @property + def count(self): return len(self.values) + def item(self,i): return self.values[i] + def itemByName(self,name): return next((v for v in self.values if v.name==name),None) + +class Entity: + def __init__(self, **kwargs): self.__dict__.update(isValid=True, **kwargs) + + +def component(name, volume=0): + props=NS(volume=volume,area=volume*6,mass=volume*0.002,centerOfMass=NS(x=1,y=2,z=3)) + body=Entity(getPhysicalProperties=lambda a:props) + return Entity(name=name,revisionId='r1',bRepBodies=Collection([body] if volume else []),sketches=Collection(),occurrences=Collection(),allOccurrences=Collection(),partNumber='',description='',material=None) + +def isolated_history(testcase): + journal = importlib.import_module('mechanical_test_commands.mechanical.journal') + folder = tempfile.TemporaryDirectory(); testcase.addCleanup(folder.cleanup) + for change in (patch.object(m, '_history', m.OperationHistory(m.SESSION_ID, 'offline-test', journal.Journal(Path(folder.name) / 'history'))), + patch.object(m, 'observed_document', return_value={'documentId':'owned-offline-document'})): + change.start(); testcase.addCleanup(change.stop) + + +class Safety(unittest.TestCase): + def setUp(self): + c._entities.clear(); c._documents.clear(); isolated_history(self) + + def test_document_creation_id_and_name_cannot_select_copy(self): + a=Entity(name='Same',creationId='copied');b=Entity(name='Same',creationId='copied') + app=NS(activeDocument=a,activeProduct=NS(parentDocument=a)) + first=c.active(app)[2];app.activeDocument=b;app.activeProduct=NS(parentDocument=b) + self.assertNotEqual(c.active(app)[2], first) + with self.assertRaises(c.MechanicalError) as error: c.target(app,{'target':{'documentId':first,'revision':'anything'}},True) + self.assertEqual(error.exception.code,'wrong_document') + + def test_child_revision_changes_target_even_when_root_unchanged(self): + root=component('root');child=component('child') + design=NS(allComponents=Collection([root,child]),rootComponent=NS(allOccurrences=Collection()),allParameters=Collection(),timeline=NS(count=0,markerPosition=0)) + before=c.revision(design);child.revisionId='r2';self.assertNotEqual(c.revision(design),before) + + def test_occurrence_suppression_comes_from_timeline_not_occurrence(self): + root=component('root');child=component('child') + occurrence=Entity(component=child,transform2=NS(asArray=lambda:[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]), + timelineObject=NS(isSuppressed=False),isGrounded=False) + design=NS(allComponents=Collection([root,child]),rootComponent=NS(allOccurrences=Collection([occurrence])), + allParameters=Collection(),timeline=NS(count=0,markerPosition=0)) + before=c.revision(design); occurrence.timelineObject.isSuppressed=True + self.assertNotEqual(c.revision(design),before) + occurrence.timelineObject=None; self.assertIsNone(c.occurrence_suppression(occurrence)) + + def test_stale_and_ambiguous_tokens_refuse(self): + t={'documentId':'d','revision':'r'};entity=Entity(objectType='adsk::fusion::BRepFace') + design=NS(findEntityByToken=lambda token:[entity,entity]) + reference={**t,'token':'face','objectType':entity.objectType} + with self.assertRaises(c.MechanicalError) as error:c.resolve(design,t,reference) + self.assertEqual(error.exception.code,'ambiguous_reference') + reference['revision']='old' + with self.assertRaises(c.MechanicalError) as error:c.resolve(design,t,reference) + self.assertEqual(error.exception.code,'stale_reference') + + def test_compute_false_and_warning_are_not_healthy(self): + design=NS(computeAll=lambda:False,timeline=Collection()) + self.assertFalse(c.health(design,True)['healthy']) + design.timeline=Collection([NS(healthState=1,name='Bad extrude',errorOrWarningMessage='lost face')]) + design.computeAll=lambda:True + self.assertFalse(c.health(design,True)['healthy']) + + def test_repeat_id_returns_original_after_timeout_and_conflict_refuses(self): + writes=[] + request={'op':'parameter_set','sessionId':m.SESSION_ID,'operationId':'one','target':{},'name':'x','expression':'1 mm'} + with patch.dict(m.OPERATIONS,parameter_set=lambda app,args:writes.append(args['expression']) or {'done':True}): + first=m.handle_mechanical(None,request) + retry=m.handle_mechanical(None,dict(request)) + conflict=m.handle_mechanical(None,{**request,'expression':'2 mm'}) + self.assertTrue(first['success']);self.assertTrue(retry['replayed']);self.assertEqual(writes,['1 mm']) + self.assertEqual(conflict['errorCode'],'operation_conflict') + self.assertEqual(m.operation_status(None,request)['result'],first) + + def test_transport_metadata_is_excluded_from_payload_and_retry_fingerprint(self): + writes = [] + request = {'op':'parameter_set','sessionId':m.SESSION_ID,'operationId':'transport', + 'target':{},'name':'x','expression':'1 mm'} + with patch.dict(m.OPERATIONS, parameter_set=lambda app,args:writes.append(args) or {'done':True}): + first = m.handle_mechanical(None, {**request,'caller':{'sessionId':'relay'},'_callerBudgetMs':60000}) + retry = m.handle_mechanical(None, {**request,'caller':{'sessionId':'relay'},'_callerBudgetMs':5000}) + typo = m.handle_mechanical(None, {**request,'unexpectedTransportField':True}) + self.assertTrue(first['success']); self.assertTrue(retry['replayed']) + self.assertEqual(writes, [request]); self.assertEqual(typo['errorCode'],'invalid_input') + + def test_old_session_and_invalid_boolean_fail_before_handler(self): + request={'op':'health','target':{},'recompute':'false'} + self.assertEqual(m.handle_mechanical(None,request)['errorCode'],'invalid_input') + request={'op':'new_design','sessionId':'old','operationId':'one'} + self.assertEqual(m.handle_mechanical(None,request)['errorCode'],'session_mismatch') + + def test_failed_parameter_edit_restores_original_with_readback(self): + p=Entity(name='depth',expression='10 mm',unit='mm') + design=NS(allParameters=Collection([p]),unitsManager=NS(isValidExpression=lambda *a:True)) + def check(*args): + if p.expression=='-1 mm':raise c.MechanicalError('unhealthy_model','bad feature') + return {'healthy':True} + with patch.object(ops,'target',return_value=(None,design,{})),patch.object(ops,'require_healthy',side_effect=check),patch.object(ops,'recovery_snapshot',side_effect=lambda d:{'expression':p.expression}),patch.object(ops,'current_target',return_value={}): + with self.assertRaises(c.MechanicalError) as error:ops.parameter_set(None,{'name':'depth','expression':'-1 mm'}) + self.assertEqual(p.expression,'10 mm');self.assertTrue(error.exception.details['recovery']['verified']) + + def test_mass_includes_root_and_does_not_merge_same_names(self): + comps=[component('same',1),component('same',2)] + result=pp.handle_physical_properties(NS(activeProduct=NS(allComponents=Collection(comps))),{}) + self.assertTrue(result['success']);self.assertEqual(result['count'],2) + self.assertEqual(sorted(r['volume_cm3'] for r in result['properties'].values()),[1,2]) + + def test_bom_counts_root_children_and_repeated_identity(self): + root=component('root',1);a=component('same',2);b=component('same',3) + child=lambda comp,path,children=():Entity(component=comp,fullPathName=path,childOccurrences=Collection(children)) + root.occurrences=Collection([child(a,'a',[child(b,'a+b')]),child(a,'a2')]) + result=bom.handle_assembly_bom(NS(activeProduct=NS(rootComponent=root,parentDocument=NS(name='test'))),{'treatAsUnit':[]}) + self.assertTrue(result['success']);self.assertEqual(result['partCount'],3) + self.assertEqual(sorted(r['quantity'] for r in result['parts']),[1,1,2]) + self.assertEqual(result['treatAsUnit'],[]) + +class Exports(unittest.TestCase): + def test_unlink_failure_after_hardlink_reports_published(self): + with tempfile.TemporaryDirectory() as folder: + destination = Path(folder) / 'out.step'; staged = Path(folder) / 'stage.step' + staged.write_bytes(b'published model') + with patch.object(Path, 'unlink', side_effect=OSError('staging file locked')): + result = x.publish_staged(staged, destination, False) + self.assertTrue(result['published']); self.assertIn('cleanupWarning', result) + self.assertEqual(destination.read_bytes(), b'published model') + + def test_cleanup_failure_after_publication_is_success_with_warning(self): + real_tempdir = tempfile.TemporaryDirectory + class CleanupFailure: + def __init__(self, *args, **kwargs): self.inner = real_tempdir(*args, **kwargs) + def __enter__(self): return self.inner.__enter__() + def __exit__(self, *args): + self.inner.__exit__(*args) + raise OSError('sharing violation during cleanup') + with real_tempdir() as folder: + destination = Path(folder) / 'out.step'; destination.write_bytes(b'original') + def execute(path): Path(path).write_bytes(b'new model'); return True + design = NS(exportManager=NS(createSTEPExportOptions=lambda path:path, execute=execute)) + legacy = importlib.import_module('mechanical_test_commands.export') + with patch.object(x.tempfile, 'TemporaryDirectory', CleanupFailure): + result = legacy.handle_export_step(NS(activeProduct=design), {'outputPath':str(destination)}) + self.assertTrue(result['success']); self.assertTrue(result['data']['published']) + self.assertIn('cleanupWarning', result['data']); self.assertNotIn('destinationPreserved', result) + self.assertEqual(destination.read_bytes(), b'new model') + + def test_3mf_disables_printer_launch_and_multiple_outputs(self): + with tempfile.TemporaryDirectory() as folder: + destination = Path(folder) / 'out.3mf' + options = NS(sendToPrintUtility=True, isOneFilePerBody=True) + def factory(root, path): options.path=path; return options + def execute(value): + self.assertFalse(value.sendToPrintUtility); self.assertFalse(value.isOneFilePerBody) + Path(value.path).write_bytes(b'3mf model'); return True + design = NS(rootComponent=None, exportManager=NS(createC3MFExportOptions=factory, execute=execute)) + self.assertTrue(x.export_design(design, {'format':'3mf','outputPath':str(destination)})['published']) + + def test_missing_capability_preserves_existing_bytes(self): + with tempfile.TemporaryDirectory() as folder: + destination=Path(folder)/'out.fbx';destination.write_bytes(b'original') + with self.assertRaises(c.MechanicalError):x.export_design(NS(exportManager=NS()),{'format':'fbx','outputPath':str(destination),'overwrite':True}) + self.assertEqual(destination.read_bytes(),b'original') + + def exercise(self,mode): + with tempfile.TemporaryDirectory() as folder: + destination=Path(folder)/'out.step';destination.write_bytes(b'original') + def execute(path): + if mode=='exception': raise RuntimeError('kernel failed') + if mode!='empty':Path(path).write_bytes(b'new geometry') + return mode!='false' + manager=NS(createSTEPExportOptions=lambda path:path,execute=execute) + if mode=='good': + result=x.export_design(NS(exportManager=manager),{'format':'step','outputPath':str(destination),'overwrite':True}) + self.assertTrue(result['published']);self.assertEqual(destination.read_bytes(),b'new geometry') + else: + with self.assertRaises(Exception):x.export_design(NS(exportManager=manager),{'format':'step','outputPath':str(destination),'overwrite':True}) + self.assertEqual(destination.read_bytes(),b'original') + self.assertEqual(list(Path(folder).iterdir()),[destination]) + def test_false_preserves(self):self.exercise('false') + def test_exception_preserves(self):self.exercise('exception') + def test_empty_preserves(self):self.exercise('empty') + def test_success_publishes(self):self.exercise('good') + def test_exclusive_publication_cannot_clobber_racing_writer(self): + with tempfile.TemporaryDirectory() as folder: + destination=Path(folder)/'out.step';staged=Path(folder)/'stage.step' + destination.write_bytes(b'racing writer');staged.write_bytes(b'ours') + with self.assertRaises(FileExistsError):x.publish_staged(staged,destination,False) + self.assertEqual(destination.read_bytes(),b'racing writer') + +class Transport(unittest.TestCase): + def test_fast_event_is_registered_before_fire(self): + base=ModuleType('mechanical_transport');base.__path__=[str(ROOT/'addin/AdomBridge')];sys.modules[base.__name__]=base + cmds=ModuleType(base.__name__+'.commands');cmds.__path__=[];sys.modules[cmds.__name__]=cmds + cloud=ModuleType(cmds.__name__+'.cloud_documents');cloud.get_walk_progress=lambda:None;sys.modules[cloud.__name__]=cloud + spec=importlib.util.spec_from_file_location(base.__name__+'.http_server',ROOT/'addin/AdomBridge/http_server.py') + server=importlib.util.module_from_spec(spec);spec.loader.exec_module(server) + observed=[] + def fire(event,payload): + rid=json.loads(payload)['request_id'];observed.append(server.is_request_alive(rid)) + if observed[-1]:server.set_result(rid,{'success':True}) + server._app=NS(fireCustomEvent=fire);server._custom_event_id='test' + body=json.dumps({'command':'get_app_state','timeout':0.001}).encode() + handler=object.__new__(server.AdomBridgeHandler);handler.path='/command';handler.headers=Message();handler.headers['Content-Length']=str(len(body)) + handler._listener_context=server.ListenerContext(server._app,server._custom_event_id) + handler.connection=NS(settimeout=lambda seconds:None) + handler.rfile=io.BytesIO(body);handler._respond=lambda status,data:observed.append(data) + handler.do_POST();self.assertEqual(observed,[True,{'success':True}]) + +if __name__=='__main__':unittest.main() diff --git a/tests/test_mechanical_assemblies.py b/tests/test_mechanical_assemblies.py new file mode 100644 index 0000000..7b7082f --- /dev/null +++ b/tests/test_mechanical_assemblies.py @@ -0,0 +1,414 @@ +"""Assembly invariants and external publication boundaries without a CAD kernel.""" +import importlib +import json +from pathlib import Path +import tempfile +from types import SimpleNamespace as NS +import unittest +from unittest.mock import patch + +from test_mechanical import m, c, Entity, Collection + +assembly=importlib.import_module('mechanical_test_commands.mechanical.assemblies') +state=importlib.import_module('mechanical_test_commands.mechanical.assembly_state') +bom=importlib.import_module('mechanical_test_commands.mechanical.bill_of_materials') +purchased=importlib.import_module('mechanical_test_commands.mechanical.purchased') + +class Attributes(Collection): + def add(self,group,name,value): + existing=next((x for x in self.values if x.groupName==group and x.name==name),None) + if existing:existing.value=value;return existing + result=NS(groupName=group,name=name,value=value);self.values.append(result);return result + +def body(mass,volume): + return Entity(physicalProperties=NS(mass=mass,volume=volume),getPhysicalProperties=lambda accuracy:NS(mass=mass,volume=volume),material=NS(id='steel',name='Steel')) + +def comp(name,bodies=()): + return Entity(name=name,partNumber='',description='',attributes=Attributes(),material=NS(id='steel',name='Steel'), + bRepBodies=Collection(bodies),occurrences=Collection(),allOccurrences=Collection(), + joints=Collection(),asBuiltJoints=Collection(),rigidGroups=Collection()) + +def occ(component,path,children=()): + return Entity(component=component,fullPathName=path,childOccurrences=Collection(children),bRepBodies=component.bRepBodies, + timelineObject=NS(isSuppressed=False),attributes=Attributes(),isGrounded=False,isGroundToParent=False) + +def fake_ref(entity,value,occurrence=None):return {**value,'token':c.identity(entity),'objectType':'fixture'} + +class AssemblyBOM(unittest.TestCase): + def setUp(self): + c._entities.clear();self.value={'documentId':'d','revision':'r'} + self.root=comp('root',[body(1,1)]) + self.fastener=comp('Shared name',[body(.1,.2)]) + self.distinct=comp('Shared name',[body(.3,.4)]) + self.kit=comp('Kit',[body(.2,.5)]) + assembly.apply_metadata(self.fastener,{'supplier':'McMaster-Carr','supplierPartNumber':'3597N17','asPurchased':True}) + self.fastener.getPhysicalProperties=lambda accuracy:NS(mass=.1,volume=.2) + self.kit.getPhysicalProperties=lambda accuracy:NS(mass=.3,volume=.7) + self.kitA=occ(self.kit,'Kit:1',[occ(self.fastener,'Kit:1+Bolt:1')]) + self.kitB=occ(self.kit,'Kit:2',[occ(self.fastener,'Kit:2+Bolt:1')]) + self.root.occurrences=Collection([self.kitA,self.kitB,occ(self.distinct,'Other:1')]) + self.design=NS(rootComponent=self.root,isConfiguredDesign=False,isConfiguration=False,configurationRowId="") + self.ref_patch=patch.object(bom,'ref',side_effect=fake_ref);self.ref_patch.start() + def tearDown(self):self.ref_patch.stop() + def generate(self,**args):return bom.generate(self.design,self.value,args) + + def test_nested_repeated_definitions_and_root_mass_count_once(self): + result=self.generate();parts={r['componentId']:r for r in result['parts']} + self.assertEqual(parts[c.identity(self.fastener)]['quantity'],2) + self.assertEqual(parts[c.identity(self.distinct)]['quantity'],1) + self.assertEqual(parts[c.identity(self.kit)]['quantity'],2) + self.assertEqual(result['partCount'],4) + self.assertAlmostEqual(result['physical']['mass'],1.9) + self.assertAlmostEqual(result['physical']['volume'],2800) + self.assertEqual(result['physical']['bodyInstances'],6) + self.assertEqual(parts[c.identity(self.fastener)]['supplierPartNumber'],'3597N17') + + def test_bom_declares_and_uses_high_accuracy_for_all_physical_rows(self): + used=[] + for component in (self.root,self.fastener,self.distinct,self.kit): + for item in component.bRepBodies.values: + props=item.physicalProperties + item.getPhysicalProperties=lambda accuracy,props=props:used.append(accuracy) or props + result=self.generate() + self.assertEqual(result['calculationAccuracy'],'high') + self.assertTrue(used);self.assertEqual(set(used),{assembly.adsk.fusion.CalculationAccuracy.HighCalculationAccuracy}) + + def test_purchased_collapse_and_exclusion_do_not_change_physical_mass(self): + assembly.apply_metadata(self.kit,{'asPurchased':True}) + collapsed=self.generate();self.assertEqual(collapsed['partCount'],3) + exploded=self.generate(explodePurchased=True);self.assertEqual(exploded['partCount'],4) + self.assertEqual(collapsed['physical'],exploded['physical']) + assembly.apply_metadata(self.kit,{'excludeFromBom':True}) + excluded=self.generate();self.assertEqual(excluded['partCount'],2) + self.assertEqual(len(excluded['excluded']),2) + self.assertEqual(collapsed['physical'],excluded['physical']) + + def test_physical_walk_detects_cycle_hidden_by_purchased_collapse(self): + assembly.apply_metadata(self.kit,{'asPurchased':True}) + self.kitA.childOccurrences=Collection([occ(self.kit,'Kit:1+Cycle:1')]) + with self.assertRaises(c.MechanicalError) as error:self.generate() + self.assertEqual(error.exception.code,'cyclic_assembly') + + def test_flat_and_structured_csv_retain_provenance_and_parent_paths(self): + with tempfile.TemporaryDirectory() as directory,patch.object(bom,'target',return_value=(None,self.design,self.value)): + for mode in ('flat','structured'): + path=Path(directory)/(mode+'.csv') + result=bom.bom_export(None,{'filename':str(path),'mode':mode}) + self.assertTrue(result['published']);text=path.read_text();self.assertIn('3597N17',text) + self.assertIn('parentPath' if mode=='structured' else 'unitMass',text) + with self.assertRaises(c.MechanicalError):bom.bom_export(None,{'filename':str(path)}) + + def test_bom_cleanup_failure_after_publication_stays_successful(self): + original=tempfile.TemporaryDirectory + class FaultyDirectory: + def __init__(self,*args,**kwargs):self.inner=original(*args,**kwargs) + def __enter__(self):return self.inner.__enter__() + def __exit__(self,*args):self.inner.__exit__(*args);raise PermissionError('postpublication sharing lock') + with original() as directory,patch.object(bom,'target',return_value=(None,self.design,self.value)),patch.object(bom.tempfile,'TemporaryDirectory',FaultyDirectory): + path=Path(directory)/'bom.csv';path.write_text('original') + result=bom.bom_export(None,{'filename':str(path),'overwrite':True}) + self.assertTrue(result['published']);self.assertIn('cleanupWarning',result);self.assertNotEqual(path.read_text(),'original') + +class MetadataAndImports(unittest.TestCase): + def test_witness_changes_for_supplier_grounding_and_material(self): + component=comp('Part');first=state.component_state(component) + assembly.apply_metadata(component,{'supplierPartNumber':'3597N17','asPurchased':True}) + second=state.component_state(component);self.assertNotEqual(first,second) + component.material=NS(id='al',name='Aluminum');self.assertNotEqual(second,state.component_state(component)) + occurrence=occ(component,'Part:1');before=state.occurrence_state(occurrence) + occurrence.isGroundToParent=True;self.assertNotEqual(before,state.occurrence_state(occurrence)) + + def test_same_name_wrong_material_id_cannot_pass_assignment_readback(self): + current=NS(id='steel-light',name='Steel');requested=NS(id='steel-heavy',name='Steel') + class Body: + assemblyContext=None + @property + def material(self):return current + @material.setter + def material(self,value):pass + design=NS(materials=NS(itemById=lambda key:requested));value={'documentId':'d','revision':'r'} + def execute(app,args,fn):return fn(design,value,args)(value) + with patch.object(purchased.transactions,'execute',side_effect=execute),patch.object(purchased,'resolve',return_value=Body()): + with self.assertRaises(c.MechanicalError) as error:purchased.material_assign(None,{'entity':{},'materialId':requested.id}) + self.assertEqual(error.exception.code,'material_failed') + + def test_nested_body_uses_same_occurrence_context_resolution(self): + component=comp('Part') + native=Entity(objectType='adsk::fusion::Occurrence',entityToken='native-occ',fullPathName='Part:1',component=component) + proxy=Entity(objectType=native.objectType,entityToken='proxy-occ',fullPathName='Kit:2+Part:1',component=component,nativeObject=native) + body=Entity(objectType='adsk::fusion::BRepBody',entityToken='body',parentComponent=component,createForAssemblyContext=lambda context:Entity(assemblyContext=context)) + design=NS(rootComponent=NS(allOccurrences=Collection([proxy])),findEntityByToken=lambda token:[body] if token=='body' else [native]) + value={'documentId':'d','revision':'r'} + self.assertIs(c.resolve(design,value,c.ref(body,value,proxy)).assemblyContext,proxy) + + def test_supplier_import_hash_mismatch_and_credentials_refuse_before_model(self): + with tempfile.TemporaryDirectory() as directory: + path=Path(directory)/'part.step';path.write_bytes(b'ISO-10303-21; fixture') + args={'filename':str(path),'provenance':{'supplier':'McMaster-Carr','supplierPartNumber':'3597N17','sourceUrl':'https://www.mcmaster.com/3597N17/'}} + self.assertEqual(purchased.preflight_file(args)[1],purchased.file_hash(path)) + with self.assertRaises(c.MechanicalError) as error:purchased.preflight_file({**args,'sha256':'0'*64}) + self.assertEqual(error.exception.code,'source_hash_mismatch') + args['provenance']['sourceUrl']='https://user:secret@www.mcmaster.com/3597N17/' + with self.assertRaises(c.MechanicalError):purchased.preflight_file(args) + + def test_occurrence_path_disambiguates_shared_nested_definition(self): + native=Entity(objectType='adsk::fusion::Occurrence',fullPathName='Bolt:1') + first=Entity(objectType=native.objectType,fullPathName='Kit:1+Bolt:1',nativeObject=native) + second=Entity(objectType=native.objectType,fullPathName='Kit:2+Bolt:1',nativeObject=native) + design=NS(findEntityByToken=lambda token:[native],rootComponent=NS(allOccurrences=Collection([first,second]))) + value={'documentId':'d','revision':'r'} + reference={**value,'token':'native-bolt','objectType':native.objectType,'occurrencePath':second.fullPathName} + self.assertIs(c.resolve(design,value,reference),second) + reference['occurrencePath']='Missing:1+Bolt:1' + with self.assertRaises(c.MechanicalError):c.resolve(design,value,reference) + +class ImportedTopology(unittest.TestCase): + def test_degenerate_edge_without_curve_keeps_vertex_witness(self): + readback=importlib.import_module('mechanical_test_commands.mechanical.readback') + vertex=NS(geometry=NS(x=1.5875,y=0,z=0)) + edge=NS(isDegenerate=True,geometry=None,length=0,startVertex=vertex,endVertex=vertex) + body=NS(edges=Collection([edge]),faces=Collection()) + with patch.object(readback,'body_data',side_effect=lambda body:{'volume':10}): + before=readback.topology_witness(body);vertex.geometry.x=2 + after=readback.topology_witness(body) + self.assertEqual(before['edgeGeometry'][0]['curve']['vertices'][0],[15.875,0,0]) + self.assertNotEqual(before,after) + edge.isDegenerate=False + with self.assertRaises(c.MechanicalError):readback.edge_geometry_data(edge) + edge.isDegenerate=True;edge.startVertex=None;edge.endVertex=None + with self.assertRaises(c.MechanicalError):readback.edge_geometry_data(edge) + with self.assertRaises(AttributeError):readback.geometry_data(None) + + def test_imported_feature_unavailable_index_retains_state_and_order(self): + readback=importlib.import_module('mechanical_test_commands.mechanical.readback') + class Timeline: + isValid=True;isSuppressed=False + @property + def index(self):raise RuntimeError('2 : InternalValidationError : res >= 0') + feature=Entity(name='Import',objectType='adsk::fusion::BaseFeature',timelineObject=Timeline()) + before=readback.feature_data(feature,order=2) + self.assertIsNone(before['index']);self.assertEqual(before['componentFeatureOrder'],2) + self.assertFalse(before['suppressed']) + feature.name='Changed';self.assertNotEqual(before,readback.feature_data(feature,order=2)) + feature.timelineObject.isValid=False + with self.assertRaises(c.MechanicalError):readback.feature_data(feature,order=2) + class Unexpected(Timeline): + @property + def index(self):raise RuntimeError('unexpected read failure') + feature.timelineObject=Unexpected() + with self.assertRaises(RuntimeError):readback.feature_data(feature,order=2) + +class AssemblySelection(unittest.TestCase): + def test_independent_copy_applies_requested_pose_and_rejects_recompute_reset(self): + matrix=lambda x:NS(asArray=lambda:[1,0,0,x,0,1,0,0,0,0,1,0,0,0,0,1]) + expected=matrix(8);old=matrix(2);occurrence=Entity(transform2=old) + source=Entity(allOccurrences=Collection());created=[];captured=[] + root=Entity(occurrences=NS(addNewComponentCopy=lambda component,pose:created.append((component,pose)) or occurrence)) + design=NS(rootComponent=root,snapshots=NS(hasPendingSnapshot=True,add=lambda:captured.append(occurrence.transform2.asArray()) or object())) + with patch.object(assembly,'hybrid'),patch.object(assembly,'parent_component',return_value=root),patch.object(assembly,'resolve',return_value=source),patch.object(assembly,'transform',return_value=expected),patch.object(assembly,'occurrence_data',side_effect=lambda o,v:{'transform':o.transform2.asArray()}): + result=assembly.occurrence_insert(design,{}, {'component':{},'independentCopy':True,'transform':{'position':[80,0,0]}}) + self.assertEqual(created,[(source,expected)]);self.assertEqual(captured,[expected.asArray()]) + self.assertTrue(result({})['positionCaptured']) + occurrence.transform2=old + with self.assertRaises(c.MechanicalError) as error:result({}) + self.assertEqual(error.exception.code,'placement_refused') + + def test_initial_placement_refused_set_and_capture_failure_are_not_success(self): + matrix=lambda x:NS(asArray=lambda:[1,0,0,x,0,1,0,0,0,0,1,0,0,0,0,1]) + expected=matrix(8);old=matrix(2) + class Refused: + @property + def transform2(self):return old + @transform2.setter + def transform2(self,value):pass + captured=[];root=Entity();design=NS(rootComponent=root,snapshots=NS(hasPendingSnapshot=True,add=lambda:captured.append(True))) + with self.assertRaises(c.MechanicalError):assembly.place_created_occurrence(design,root,Refused(),expected) + self.assertEqual(captured,[]) + with self.assertRaises(c.MechanicalError) as error:assembly.place_created_occurrence(design,root,NS(),expected) + self.assertEqual(error.exception.code,'placement_failed') + with self.assertRaises(c.MechanicalError):assembly.require_transform(NS(asArray=lambda:[1]),expected,'bad matrix') + + def test_component_duplicate_wrappers_resolve_by_api_equality_only(self): + class Wrapper(Entity): + def __eq__(self,other):return isinstance(other,Wrapper) and self.actual is other.actual + actual=object();one=Wrapper(actual=actual,objectType='adsk::fusion::Component',name='Shared') + two=Wrapper(actual=actual,objectType=one.objectType,name='Shared') + value={'documentId':'d','revision':'r'};reference={**value,'token':'component','objectType':one.objectType} + design=NS(findEntityByToken=lambda token:[one,two]) + self.assertIs(c.resolve(design,value,reference,('Component',)),one) + two.actual=object() + with self.assertRaises(c.MechanicalError) as error:c.resolve(design,value,reference) + self.assertEqual(error.exception.code,'ambiguous_reference') + two.actual=actual;two.isValid=False + with self.assertRaises(c.MechanicalError):c.resolve(design,value,reference) + two.isValid=True;one.objectType=two.objectType='adsk::fusion::Occurrence';reference['objectType']=one.objectType + with self.assertRaises(c.MechanicalError):c.resolve(design,value,reference) + + def test_explicit_root_reference_roundtrips_without_token_lookup(self): + root=Entity(objectType='adsk::fusion::Component',entityToken='unresolvable-root') + design=NS(rootComponent=root,findEntityByToken=lambda token:[]);root.parentDesign=design + value={'documentId':'d','revision':'r'};reference=c.ref(root,value) + self.assertEqual(reference['rootComponentId'],c.identity(root)) + self.assertIs(c.resolve(design,value,reference,('Component',)),root) + for changed in ({'documentId':'another'},{'revision':'old'},{'rootComponentId':'another'}, + {'objectType':'adsk::fusion::BRepBody'},{'occurrenceToken':'child'}): + with self.subTest(changed=changed),self.assertRaises(c.MechanicalError):c.resolve(design,value,{**reference,**changed}) + with self.assertRaises(c.MechanicalError):c.resolve(design,value,reference,('BRepBody',)) + child=Entity(objectType=root.objectType,entityToken='child',parentDesign=design) + self.assertNotIn('rootComponentId',c.ref(child,value)) + other_root=Entity(objectType=root.objectType,entityToken=root.entityToken) + with self.assertRaises(c.MechanicalError):c.resolve(NS(rootComponent=other_root),value,reference) + + def test_nested_creation_returns_every_placed_context_without_local_tokens(self): + parent=comp('Parent');child=comp('Child');child.objectType='adsk::fusion::Component';child.entityToken='child-component' + class LocalOccurrence(Entity): + @property + def entityToken(self):raise RuntimeError('Tokens require top-level root parent') + local=LocalOccurrence(component=child,objectType='adsk::fusion::Occurrence') + matrix=NS(asArray=lambda:[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]) + expected=NS(asArray=lambda:[1,0,0,8,0,1,0,2,0,0,1,0,0,0,0,1]);local.transform2=expected + parent_a=Entity(component=parent,fullPathName='Parent:1',transform2=NS(asArray=lambda:[0,-1,0,10,1,0,0,5,0,0,1,0,0,0,0,1])) + parent_b=Entity(component=parent,fullPathName='Parent:2',transform2=NS(asArray=lambda:[1,0,0,-1,0,1,0,4,0,0,1,0,0,0,0,1])) + first=Entity(objectType=local.objectType,component=child,nativeObject=local,entityToken='first',fullPathName='Parent:1+Child:1',transform2=NS(asArray=lambda:[0,-1,0,8,1,0,0,13,0,0,1,0,0,0,0,1]),assemblyContext=parent_a,isGrounded=False,isGroundToParent=False) + second=Entity(objectType=local.objectType,component=child,nativeObject=local,entityToken='second',fullPathName='Parent:2+Child:1',transform2=NS(asArray=lambda:[1,0,0,7,0,1,0,6,0,0,1,0,0,0,0,1]),assemblyContext=parent_b,isGrounded=False,isGroundToParent=False) + design=NS(rootComponent=NS(allOccurrences=Collection([parent_a,parent_b,first,second]))) + child.parentDesign=design + result=assembly.created_occurrence_result(design,parent,local,{'documentId':'d','revision':'r'},expected) + self.assertEqual([o['ref']['token'] for o in result['affectedOccurrences']],['first','second']) + self.assertEqual(result['referenceSpace'],'root_assembly') + self.assertEqual(result['occurrence']['ref']['occurrencePath'],'Parent:1+Child:1') + saved=second.transform2;second.transform2=matrix + with self.assertRaises(c.MechanicalError):assembly.created_occurrence_result(design,parent,local,{'documentId':'d','revision':'r'},expected) + second.transform2=saved + local.transform2=matrix + # Captured root-context poses can be correct while the local base pose + # remains unchanged. The local object also rejects transform2 setters. + self.assertEqual(assembly.created_occurrence_result(design,parent,local,{'documentId':'d','revision':'r'},expected)['placementScope'],'all_current_parent_instances') + class Matrix: + def setWithArray(self,values):self.values=list(values);return True + def asArray(self):return self.values + first.transform2=second.transform2=matrix;captured=[] + design.snapshots=NS(hasPendingSnapshot=True,add=lambda:captured.append((first.transform2.asArray(),second.transform2.asArray())) or object()) + with patch.object(assembly.adsk.core,'Matrix3D',NS(create=Matrix),create=True): + self.assertTrue(assembly.place_created_occurrence(design,parent,local,expected)) + self.assertEqual(len(captured),1) + self.assertEqual([captured[0][0][i] for i in (3,7,11)],[8,13,0]) + self.assertEqual([captured[0][1][i] for i in (3,7,11)],[7,6,0]) + self.assertIs(local.transform2,matrix) + + def test_definition_geometry_without_instance_context_refuses(self): + root=comp('root');child=comp('child');root.allOccurrences=Collection() + entity=Entity(objectType='adsk::fusion::BRepBody',parentComponent=child,assemblyContext=None) + with self.assertRaises(c.MechanicalError):assembly.require_root_context(NS(rootComponent=root),entity) + + def test_interference_body_budget_is_shared_across_occurrence_selections(self): + bodies=Collection([Entity(objectType='adsk::fusion::BRepBody') for _ in range(11000)]) + first=Entity(objectType='adsk::fusion::Occurrence',fullPathName='Part:1',bRepBodies=bodies,childOccurrences=Collection()) + second=Entity(objectType=first.objectType,fullPathName='Part:2',bRepBodies=bodies,childOccurrences=Collection()) + design=NS(rootComponent=NS(allOccurrences=Collection([first,second]))) + with patch.object(assembly,'target',return_value=(None,design,{})),patch.object(assembly,'identity',side_effect=id),patch.object(assembly,'resolve',side_effect=[first,second]): + with self.assertRaises(c.MechanicalError) as error:assembly.interference(None,{'entities':[{},{}]}) + self.assertEqual(error.exception.code,'inspection_budget') + + def test_interference_parent_and_contained_body_refuse_before_analysis(self): + root=comp('root');child=comp('child');native=Entity(objectType='adsk::fusion::BRepBody') + occurrence=Entity(objectType='adsk::fusion::Occurrence',fullPathName='Child:1',childOccurrences=Collection()) + proxy=Entity(objectType=native.objectType,nativeObject=native,assemblyContext=occurrence,parentComponent=child) + occurrence.bRepBodies=Collection([proxy]);root.allOccurrences=Collection([occurrence]);design=NS(rootComponent=root) + with patch.object(assembly,'target',return_value=(None,design,{})),patch.object(assembly,'resolve',side_effect=[occurrence,proxy]): + with self.assertRaises(c.MechanicalError) as error:assembly.interference(None,{'entities':[{},{}]}) + self.assertEqual(error.exception.code,'overlapping_selection') + +class JointLimits(unittest.TestCase): + def limits(self):return NS(isMinimumValueEnabled=False,isMaximumValueEnabled=False,isRestValueEnabled=False,minimumValue=0.,maximumValue=0.,restValue=0.) + def test_joint_and_group_rename_suppression_must_read_back(self): + for field,requested,actual in [('name','Requested','Old'),('suppressed',True,False)]: + with patch.object(assembly,'joint_data',return_value={field:actual}): + with self.assertRaises(c.MechanicalError):assembly.joint_result(None,{}, {field:requested}) + with patch.object(assembly,'rigid_group_data',return_value={field:actual}): + with self.assertRaises(c.MechanicalError):assembly.rigid_group_result(None,{}, {field:requested}) + + def test_implicit_limit_enable_is_verified_and_explicit_false_is_preserved(self): + for field in ('minimum','maximum','rest'): + result={'rotationLimits':{field:10,field+'Enabled':False}} + with patch.object(assembly,'joint_data',return_value=result): + with self.assertRaises(c.MechanicalError):assembly.joint_result(None,{}, {'rotationLimits':{field:10}}) + self.assertEqual(assembly.joint_result(None,{}, {'rotationLimits':{field:10,field+'Enabled':False}})['joint'],result) + + def test_joint_drive_captures_once_after_all_axes_without_changing_rest(self): + rotation=self.limits();slide=self.limits();rotation.isRestValueEnabled=True;rotation.restValue=.1 + motion=NS(rotationLimits=rotation,slideLimits=slide,rotationValue=0.,slideValue=0.) + captured=[] + design=NS(snapshots=NS(hasPendingSnapshot=True,add=lambda:captured.append((motion.rotationValue,motion.slideValue)) or object())) + with patch.object(assembly,'joint_motion',return_value=motion): + self.assertTrue(assembly.configure_joint(design,NS(),{'rotationValue':15,'slideValue':3})) + self.assertFalse(assembly.configure_joint(design,NS(),{'rotationLimits':{'minimum':-30,'maximum':45}})) + self.assertEqual(len(captured),1);self.assertAlmostEqual(captured[0][0],3.141592653589793/12);self.assertEqual(captured[0][1],.3) + self.assertTrue(rotation.isRestValueEnabled);self.assertEqual(rotation.restValue,.1) + + def test_joint_drive_capture_failure_does_not_report_success(self): + motion=NS(slideLimits=self.limits(),slideValue=0.) + design=NS(snapshots=NS(hasPendingSnapshot=True,add=lambda:None)) + with patch.object(assembly,'joint_motion',return_value=motion): + with self.assertRaises(c.MechanicalError) as error:assembly.configure_joint(design,NS(),{'slideValue':3}) + self.assertEqual(error.exception.code,'joint_capture_failed') + + def test_joint_suppression_captures_pending_state_in_both_directions(self): + joint=NS(isSuppressed=False);captured=[] + design=NS(snapshots=NS(hasPendingSnapshot=True,add=lambda:captured.append(joint.isSuppressed) or object())) + with patch.object(assembly,'joint_motion',return_value=NS()): + self.assertTrue(assembly.configure_joint(design,joint,{'suppressed':True})) + self.assertTrue(assembly.configure_joint(design,joint,{'suppressed':False})) + design.snapshots.hasPendingSnapshot=False + self.assertFalse(assembly.configure_joint(design,joint,{'suppressed':False})) + self.assertEqual(captured,[True,False]) + + def test_rigid_group_suppression_captures_pending_state_and_checks_readback(self): + group=NS(isSuppressed=False);captured=[] + design=NS(snapshots=NS(hasPendingSnapshot=True,add=lambda:captured.append(group.isSuppressed) or object())) + with patch.object(assembly,'resolve',return_value=group),patch.object(assembly,'rigid_group_data',side_effect=lambda *_:{'suppressed':group.isSuppressed}): + for suppressed in (True,False): + result=assembly.rigid_group_edit(design,{}, {'group':{},'suppressed':suppressed})({}) + self.assertTrue(result['positionCaptured']);self.assertEqual(result['rigidGroup']['suppressed'],suppressed) + result=assembly.rigid_group_edit(design,{}, {'group':{},'suppressed':True}) + group.isSuppressed=False + with self.assertRaises(c.MechanicalError):result({}) + design.snapshots.add=lambda:None + with self.assertRaises(c.MechanicalError) as error:assembly.rigid_group_edit(design,{}, {'group':{},'suppressed':True}) + self.assertEqual(error.exception.code,'rigid_group_capture_failed') + self.assertEqual(captured,[True,False,True]) + + def test_motion_base_wrapper_is_cast_and_limits_change_witness(self): + base=NS(objectType='adsk::fusion::SliderJointMotion') + typed=NS(objectType=base.objectType,slideLimits=self.limits(),slideValue=0.,slideDirection=2) + joint=NS(name='Slide',jointMotion=base,objectType='adsk::fusion::AsBuiltJoint',isSuppressed=False,occurrenceOne=None,occurrenceTwo=None,transform=None) + with patch.object(state.adsk.fusion,'SliderJointMotion',NS(cast=lambda value:typed),create=True): + before=state.joint_data(joint) + typed.slideLimits.maximumValue=2.5;typed.slideLimits.isMaximumValueEnabled=True + after=state.joint_data(joint) + self.assertNotEqual(before,after);self.assertEqual(after['slideLimits']['maximum'],25) + self.assertIsNone(after['transform']) + + def test_degree_and_mm_limits_convert_without_enabling_unrequested_axes(self): + motion=NS(rotationLimits=self.limits(),slideLimits=self.limits()) + assembly.set_limits(motion,'rotationLimits',{'minimum':-90,'maximum':90,'rest':0},180/3.141592653589793) + self.assertAlmostEqual(motion.rotationLimits.minimumValue,-3.141592653589793/2) + self.assertTrue(motion.rotationLimits.isRestValueEnabled) + self.assertFalse(motion.slideLimits.isMaximumValueEnabled) + assembly.set_limits(motion,'slideLimits',{'minimum':0,'maximum':25},10) + self.assertEqual(motion.slideLimits.maximumValue,2.5) + def test_inverted_or_outside_rest_limits_refuse_before_changes(self): + motion=NS(slideLimits=self.limits()) + for values in ({'minimum':3,'maximum':2},{'minimum':0,'maximum':10,'rest':11}): + with self.assertRaises(c.MechanicalError):assembly.set_limits(motion,'slideLimits',values,10) + self.assertFalse(motion.slideLimits.isMinimumValueEnabled) + def test_schema_requires_joint_geometry_and_rejects_wrong_motion_fields(self): + r={'documentId':'d','revision':'r','objectType':'adsk::fusion::Occurrence'} + base={'op':'joint_create','sessionId':m.SESSION_ID,'operationId':'x','target':{},'occurrenceOne':r,'occurrenceTwo':r} + m.validate({**base,'type':'rigid'},m.SCHEMA['joint_create']) + with self.assertRaises(c.MechanicalError):m.validate({**base,'type':'rigid','slideValue':5},m.SCHEMA['joint_create']) + with self.assertRaises(c.MechanicalError):m.validate({**base,'type':'slider'},m.SCHEMA['joint_create']) + for kind in ('revolute','slider','cylindrical','pin_slot','planar','ball'): + m.validate({**base,'type':kind,'geometry':{'entity':r}},m.SCHEMA['joint_create']) + +if __name__=='__main__':unittest.main() diff --git a/tests/test_mechanical_configurations.py b/tests/test_mechanical_configurations.py new file mode 100644 index 0000000..d81ff18 --- /dev/null +++ b/tests/test_mechanical_configurations.py @@ -0,0 +1,372 @@ +"""Configuration graph, stale-target and recovery boundaries without a host.""" +import csv +import importlib +import math +from pathlib import Path +import tempfile +from types import SimpleNamespace as NS +import unittest +from unittest.mock import patch + +from test_mechanical import adsk, c, m, Entity, Collection, component + +config = importlib.import_module('mechanical_test_commands.mechanical.configurations') +bom = importlib.import_module('mechanical_test_commands.mechanical.bill_of_materials') +tx = importlib.import_module('mechanical_test_commands.mechanical.transactions') + + +class Configurations(unittest.TestCase): + def setUp(self): + c._entities.clear(); c._documents.clear(); config._sources.clear() + values = NS(NumericParameterValueType=0, TextParameterValueType=1) + self.enums = patch.object(adsk.fusion, 'ParameterValueTypes', values, create=True) + self.enums.start(); self.addCleanup(self.enums.stop) + self.casts = set() + + def native(self, kind, **kwargs): + namespace = adsk.core if kind in ('PropertyGroup', 'StringProperty') else adsk.fusion + if (namespace.__name__, kind) not in self.casts: + change = patch.object(namespace, kind, NS(cast=lambda entity: entity), create=True) + change.start(); self.addCleanup(change.stop); self.casts.add((namespace.__name__, kind)) + return Entity(objectType='adsk::' + ('core' if namespace is adsk.core else 'fusion') + '::' + kind, **kwargs) + + def fixture(self, kind='ConfigurationParameterColumn', text=False): + self.root = self.native('Component', name='Bracket', partNumber='PN-001', description='fixture', entityToken='root', + revisionId='same', bRepBodies=Collection(), sketches=Collection(), features=Collection(), + occurrences=Collection(), allOccurrences=Collection(), material=None) + self.timeline = Collection(); self.timeline.markerPosition = 0 + self.design = NS(isValid=True, isConfiguredDesign=True,isConfiguration=False,configurationRowId="", rootComponent=self.root, allComponents=Collection([self.root]), + allParameters=Collection(), timeline=self.timeline, + unitsManager=NS(isValidExpression=lambda expression, unit: expression.strip() == '1 ' + unit, + convert=lambda value, source, unit: value * 10 if unit == 'mm' else value)) + self.root.parentDesign = self.design + self.tokens = {'root': [self.root]} + self.design.findEntityByToken = lambda token: self.tokens.get(token, []) + self.table = self.native('ConfigurationTopTable', id='T', name='Sizes', hasCustomColumns=False, + testRow=None, customThemeTables=Collection()) + for field in ('appearanceTable', 'materialTable', 'plasticRuleTable', 'sheetMetalRuleTable'): + setattr(self.table, field, self.native('ConfigurationThemeTable', id=field, rows=Collection(), columns=Collection())) + self.rows = [self.native('ConfigurationRow', id='R'+str(index), name=name, index=index, + parentTable=self.table, parentRow=None, childRows=[], isTestRow=False) + for index, name in enumerate(('Base', 'Wide'))] + self.table.rows = Collection(self.rows); self.table.activeRow = self.rows[0] + self.column = self.native(kind, id='C', index=0, title='Width', rowCount=2, parentTable=self.table) + self.cells = [self.native(kind.replace('Column', 'Cell'), parentRow=row, parentColumn=self.column) + for row in self.rows] + if kind == 'ConfigurationParameterColumn': + parameter = self.native('UserParameter', name='Width', unit='mm', expression='20 mm', entityToken='width', design=self.design) + self.design.allParameters = Collection([parameter]); self.tokens['width'] = [parameter] + self.column.parameter = parameter + for index, cell in enumerate(self.cells): + cell.expression = "'blue'" if text else str(20 + 10 * index) + ' mm' + cell.valueType = 1 if text else 0 + if text: cell.textValue = 'blue' + else: cell.value = 2 + index + elif kind in ('ConfigurationSuppressColumn', 'ConfigurationVisibilityColumn'): + source = self.native('Occurrence', component=self.root, entityToken='occ', assemblyContext=None, + nativeObject=None, fullPathName='Part:1', isConfiguration=False) + self.tokens['occ'] = [source] + setattr(self.column, 'feature' if kind == 'ConfigurationSuppressColumn' else 'entity', source) + for cell in self.cells: + cell.isSuppressed = False; cell.isEffectivelySuppressed = True; cell.isVisible = False + elif kind == 'ConfigurationPropertyColumn': + group = self.native('PropertyGroup', id='component-properties', parent=self.root) + self.column.parentProperty = self.native('StringProperty', id='part-number', name='Localized title', + parent=group, isReadOnly=False) + for index, cell in enumerate(self.cells): cell.value = 'PN-' + str(index) + self.column.getCellByRowId = lambda row_id: self.cells[next(i for i, r in enumerate(self.rows) if r.id == row_id)] + self.table.columns = Collection([self.column]); self.design.configurationTopTable = self.table + self.doc = Entity(name='Untitled'); self.design.parentDocument = self.doc + self.app = NS(activeDocument=self.doc, activeProduct=self.design) + return self.design + + def test_unconfigured_preserves_normal_read_and_never_requests_table(self): + design = NS(isConfiguredDesign=False,isConfiguration=False,configurationRowId="", rootComponent=NS(allOccurrences=Collection())) + result = config.snapshot(design, strict=True) + self.assertFalse(result['configured']); self.assertTrue(result['complete']); self.assertIsNone(result['table']) + self.assertFalse(result['configurationWritesEnabled']) + + def test_generated_configuration_identity_is_bound_and_all_strict_paths_refuse(self): + self.fixture(); self.design.isConfiguredDesign = False + self.design.isConfiguration = True; self.design.configurationRowId = 'Generated-A' + graph = config.snapshot(self.design) + self.assertFalse(graph['complete']); self.assertFalse(graph['configured']) + self.assertTrue(graph['isConfiguration']); self.assertEqual(graph['configurationRowId'], 'Generated-A') + self.assertEqual(config.provenance(graph)['rowId'], 'Generated-A') + first = c.revision(self.design); self.design.configurationRowId = 'Generated-B' + self.assertNotEqual(first, c.revision(self.design)) + value = c.current_target(self.app) + for action in (lambda:config.snapshot(self.design, strict=True), + lambda:c.target(self.app, {'target':value}, True), + lambda:c.target(self.app, {'target':value}, True, model_write=False), + lambda:c.recovery_snapshot(self.design), + lambda:bom.generate(self.design, value, {})): + with self.assertRaises(c.MechanicalError) as failure: action() + self.assertEqual(failure.exception.code, 'configuration_scope_unsupported') + + def test_missing_or_contradictory_single_configuration_identity_cannot_be_complete(self): + for missing in ('isConfiguration', 'configurationRowId'): + self.fixture(); delattr(self.design, missing) + self.assertFalse(config.snapshot(self.design)['complete']) + with self.assertRaises(c.MechanicalError):config.snapshot(self.design, strict=True) + for flag, row_id in ((False, 'unexpected'), (True, ''), (False, None)): + self.fixture(); self.design.isConfiguration = flag; self.design.configurationRowId = row_id + self.assertFalse(config.snapshot(self.design)['complete']) + + def test_numeric_and_text_cells_use_separate_native_getters_and_units(self): + self.fixture(); data = config.snapshot(self.design, strict=True)['table']['cells'][0] + self.assertEqual((data['value'], data['unit'], data['internalValue']), (20, 'mm', 2)) + self.fixture(text=True); data = config.snapshot(self.design, strict=True)['table']['cells'][0] + self.assertEqual(data['textValue'], 'blue'); self.assertNotIn('internalValue', data) + + def test_conversion_error_sentinel_cannot_become_a_valid_value(self): + self.fixture(); self.design.unitsManager.convert = lambda *args: -1 + result = config.snapshot(self.design) + self.assertFalse(result['complete']) + self.assertIn('basis failed', result['readErrors'][0]['message']) + with self.assertRaises(c.MechanicalError): config.snapshot(self.design, strict=True) + + def test_signed_values_including_minus_one_survive_verified_linear_units(self): + for unit, factor in (('mm', 10), ('deg', 180 / math.pi), ('kg', 1), ('mm2', 100), ('mm3', 1000), ('', 1)): + with self.subTest(unit=unit): + self.fixture(); self.column.parameter.unit = unit + self.design.unitsManager.isValidExpression = lambda expression, target: expression.strip() == ('1 ' + target).strip() + calls = [] + self.design.unitsManager.convert = lambda value, source, target: calls.append((value, source, target)) or factor + self.cells[0].value = -1 / factor + self.cells[1].value = -2.5 / factor + result = config.snapshot(self.design, strict=True)['table']['cells'] + self.assertAlmostEqual(result[0]['value'], -1) + self.assertAlmostEqual(result[1]['value'], -2.5) + self.assertTrue(all(call[0] == 1 for call in calls)) + + def test_unsupported_units_refuse_instead_of_accepting_fallback_conversion(self): + self.fixture(); self.column.parameter.unit = 'K' + with patch.object(self.design.unitsManager, 'convert') as conversion: + result = config.snapshot(self.design) + self.assertFalse(result['complete']); conversion.assert_not_called() + self.assertEqual(result['readErrors'][0]['code'], 'configuration_scope_unsupported') + + def test_bare_number_in_default_seconds_is_not_proof_of_unitless(self): + self.fixture(); self.column.parameter.unit = 's' + self.design.unitsManager.isValidExpression = lambda expression, unit: expression.strip() == '1' + with patch.object(self.design.unitsManager, 'convert', return_value=1) as conversion: + result = config.snapshot(self.design) + self.assertFalse(result['complete']); conversion.assert_not_called() + self.assertEqual(result['readErrors'][0]['code'], 'configuration_scope_unsupported') + + def test_inactive_cell_change_invalidates_revision_and_recovery_witness(self): + self.fixture(); before = c.revision(self.design); witness = c.recovery_snapshot(self.design) + value = c.current_target(self.app) + self.cells[1].expression = '40 mm'; self.cells[1].value = 4 + self.assertNotEqual(before, c.revision(self.design)) + self.assertFalse(tx.equivalent(witness, c.recovery_snapshot(self.design))) + with self.assertRaises(c.MechanicalError) as failure: c.target(self.app, {'target': value}, True, model_write=False) + self.assertEqual(failure.exception.code, 'stale_revision') + self.cells[1].expression = '30 mm'; self.cells[1].value = 3 + self.assertTrue(tx.equivalent(witness, c.recovery_snapshot(self.design))) + + def test_active_row_and_graph_links_are_witnessed_even_with_equal_values(self): + self.fixture(); before = config.snapshot(self.design, strict=True) + self.table.activeRow = self.rows[1] + self.assertNotEqual(before, config.snapshot(self.design, strict=True)) + self.table.activeRow = self.rows[0] + replacement = self.native('UserParameter', name='Width', unit='mm', expression='20 mm', entityToken='replacement', design=self.design) + self.design.allParameters.values.append(replacement); self.tokens['replacement'] = [replacement] + self.column.parameter = replacement + self.assertNotEqual(before, config.snapshot(self.design, strict=True)) + + def test_invalidated_source_wrapper_rebinds_by_fresh_api_equality(self): + self.fixture(); before = config.snapshot(self.design, strict=True) + old = self.column.parameter + fresh = self.native('UserParameter', name='Width', unit='mm', expression='20 mm', entityToken='fresh-token', design=self.design) + old.isValid = False; self.tokens['width'] = [fresh]; self.tokens['fresh-token'] = [fresh] + self.column.parameter = fresh; self.design.allParameters = Collection([fresh]) + self.assertEqual(before, config.snapshot(self.design, strict=True)) + distinct = self.native('UserParameter', name='Width', unit='mm', entityToken='other') + self.tokens['width'] = [fresh, distinct] + with self.assertRaises(c.MechanicalError): config.snapshot(self.design, strict=True) + + def test_same_property_id_on_distinct_owner_does_not_merge(self): + self.fixture('ConfigurationPropertyColumn'); before = config.snapshot(self.design, strict=True) + other = self.native('Component', name='Bracket', entityToken='other') + self.tokens['other'] = [other]; self.design.allComponents.values.append(other) + self.column.parentProperty.parent.parent = other + self.assertNotEqual(before, config.snapshot(self.design, strict=True)) + + def test_child_model_parameter_owner_is_native_component_and_user_parameter_is_design_scoped(self): + self.fixture() + user = config.snapshot(self.design, strict=True)['table']['columns'][0]['source'] + self.assertEqual((user['ownerId'], user['ownerScope']), (c.identity(self.design), 'design')) + child = self.native('Component', name='Child', entityToken='child') + self.design.allComponents.values.append(child) + model = self.native('ModelParameter', name='d1', unit='mm', expression='20 mm', entityToken='model', component=child) + self.design.allParameters.values.append(model); self.tokens['model'] = [model]; self.column.parameter = model + source = config.snapshot(self.design, strict=True)['table']['columns'][0]['source'] + self.assertEqual((source['ownerId'], source['ownerScope']), (c.identity(child), 'component')) + self.assertNotEqual(source['ownerId'], c.identity(self.root)) + model.component = self.native('Component', name='Child', entityToken='foreign') + self.assertFalse(config.snapshot(self.design)['complete']) + + def test_missing_preview_getters_keep_stable_cells_but_never_authorize_writes(self): + self.fixture(); del self.table.testRow; del self.rows[0].parentRow; del self.rows[1].childRows; del self.rows[1].isTestRow + result = config.snapshot(self.design) + self.assertFalse(result['complete']) + self.assertEqual(len(result['table']['rows']), 2) + self.assertEqual(len(result['table']['columns']), 1) + self.assertEqual([cell['value'] for cell in result['table']['cells']], [20, 30]) + self.assertEqual({error['field'] for error in result['readErrors']}, + {'top.testRow', 'row.R0.parentRow', 'row.R1.childRows', 'row.R1.isTestRow'}) + with self.assertRaises(c.MechanicalError): config.snapshot(self.design, strict=True) + + def add_reserved_row(self): + row = self.native('ConfigurationRow', id='native-test-row', name='Test', index=0, + parentTable=self.table, parentRow=None, childRows=[], isTestRow=True) + self.table.testRow = row + for index, ordinary in enumerate(self.rows): ordinary.index = index + 1 + cell = self.native(self.column.objectType.split('::')[-1].replace('Column','Cell'), + parentRow=row, parentColumn=self.column) + if self.column.objectType.endswith('ParameterColumn'): + cell.expression='-10 mm';cell.valueType=0;cell.value=-1 + else: cell.value='TEST-PART' + previous=self.column.getCellByRowId + self.column.getCellByRowId=lambda row_id:cell if row_id==row.id else previous(row_id) + self.column.rowCount=len(self.rows)+1 + return row,cell + + def test_reserved_row_is_separate_and_complete_cell_graph_drives_stale_guard(self): + self.fixture();row,cell=self.add_reserved_row() + graph=config.snapshot(self.design,strict=True) + self.assertEqual(len(graph['table']['rows']),2) + self.assertEqual(graph['table']['testRow']['id'],row.id) + self.assertEqual(graph['table']['testRow']['scope'],'test') + self.assertEqual([(entry['rowId'],entry['scope']) for entry in graph['table']['cells']], + [('R0','ordinary'),('R1','ordinary'),(row.id,'test')]) + self.assertEqual(graph['table']['cells'][-1]['value'],-10) + value=c.current_target(self.app);before=c.recovery_snapshot(self.design) + with self.assertRaises(c.MechanicalError) as refusal:c.target(self.app,{'target':value},True) + self.assertEqual(refusal.exception.code,'configuration_mutation_unverified') + cell.expression='-20 mm';cell.value=-2 + self.assertFalse(tx.equivalent(before,c.recovery_snapshot(self.design))) + with self.assertRaises(c.MechanicalError) as stale:c.target(self.app,{'target':value},True,model_write=False) + self.assertEqual(stale.exception.code,'stale_revision') + + def test_offline_property_cells_and_unreadable_source_preserve_remaining_cells(self): + self.fixture('ConfigurationPropertyColumn');row,reserved=self.add_reserved_row() + class OfflineCell: + def __init__(self, original):self.__dict__.update(original.__dict__) + @property + def value(self):raise RuntimeError('Properties are unavailable in offline mode or unsaved component') + bad=OfflineCell(self.cells[0]); original=self.column.getCellByRowId + self.column.getCellByRowId=lambda row_id:bad if row_id=='R0' else original(row_id) + self.column.parentProperty.parent.parent=NS(objectType='adsk::fusion::Component',isValid=False) + graph=config.snapshot(self.design) + self.assertFalse(graph['complete']);self.assertEqual(len(graph['table']['cells']),3) + self.assertEqual(graph['table']['cells'][0]['value'],{'unavailable':True}) + self.assertEqual(graph['table']['cells'][1]['value'],'PN-1') + self.assertEqual(graph['table']['cells'][2]['value'],'TEST-PART') + self.assertEqual({error['field'] for error in graph['readErrors']},{'column.C.property','cell.R0.C.value'}) + with self.assertRaises(c.MechanicalError):config.snapshot(self.design,strict=True) + + def test_reserved_row_overlap_flags_hierarchy_and_count_mismatch_remain_incomplete(self): + for field,value in (('isTestRow',False),('parentRow','parent'),('childRows','children')): + self.fixture();row,_=self.add_reserved_row() + setattr(row,field,self.rows[0] if value=='parent' else self.rows if value=='children' else value) + self.assertFalse(config.snapshot(self.design)['complete']) + self.fixture();self.table.testRow=self.rows[0] + self.assertFalse(config.snapshot(self.design)['complete']) + self.fixture();self.add_reserved_row();self.column.rowCount=4 + graph=config.snapshot(self.design);self.assertFalse(graph['complete']) + self.assertEqual(len(graph['table']['cells']),3) + self.assertEqual(graph['readErrors'][0]['field'],'column.C.rowCount') + + def test_missing_reserved_cell_address_retains_all_other_cells_and_blocks_witness(self): + self.fixture();row,_=self.add_reserved_row();reader=self.column.getCellByRowId + self.column.getCellByRowId=lambda row_id:None if row_id==row.id else reader(row_id) + graph=config.snapshot(self.design);self.assertFalse(graph['complete']) + self.assertEqual([cell['value'] for cell in graph['table']['cells'][:2]],[20,30]) + self.assertTrue(graph['table']['cells'][-1]['unavailable']) + with self.assertRaises(c.MechanicalError):config.snapshot(self.design,strict=True) + + def test_incomplete_scopes_refuse_before_transaction_or_compute(self): + edits = [lambda: setattr(self.table, 'hasCustomColumns', True), + lambda: setattr(self.rows[1], 'parentRow', self.rows[0]), + lambda: self.table.customThemeTables.values.append(self.table.appearanceTable), + lambda: setattr(self.column, 'objectType', 'adsk::fusion::ConfigurationFeatureAspectColumn'), + lambda: self.column.__dict__.pop('parameter')] + for edit in edits: + with self.subTest(edit=edit): + self.fixture(); value = c.current_target(self.app); edit() + result = config.snapshot(self.design) + self.assertFalse(result['complete']) + with patch.object(tx, '_verified', True), patch.object(self.app, 'executeTextCommand', create=True) as command: + with self.assertRaises(c.MechanicalError) as failure: + tx.execute(self.app, {'target': value}, lambda *_: self.fail('mutation executed')) + self.assertEqual(failure.exception.code, 'configuration_scope_unsupported'); command.assert_not_called() + with self.assertRaises(c.MechanicalError): c.recovery_snapshot(self.design) + + def test_missing_configured_flag_is_unknown_and_refuses_strict_witness(self): + result = config.snapshot(NS()) + self.assertIsNone(result['configured']); self.assertFalse(result['complete']) + with self.assertRaises(c.MechanicalError): config.snapshot(NS(), strict=True) + + def test_complete_configured_design_still_refuses_indirect_model_writes_before_native_gate(self): + self.fixture(); value = c.current_target(self.app) + with self.assertRaises(c.MechanicalError) as failure: c.target(self.app, {'target': value}, True) + self.assertEqual(failure.exception.code, 'configuration_mutation_unverified') + self.assertEqual(c.target(self.app, {'target': value}, True, model_write=False)[2], value) + + def test_external_configuration_blocks_standard_design_mutations(self): + self.fixture(); self.design.isConfiguredDesign = False + occurrence = self.native('Occurrence', fullPathName='External:1', isConfiguration=True, + configurationRow=self.rows[1], configuredDataFile=Entity(id='source-file', versionNumber=7)) + self.root.allOccurrences = Collection([occurrence]) + result = config.snapshot(self.design) + self.assertFalse(result['complete']); self.assertFalse(result['configured']) + self.assertEqual(result['configuredOccurrences'][0]['sourceVersion'], 7) + with self.assertRaises(c.MechanicalError): config.snapshot(self.design, strict=True) + + def test_bad_cell_address_and_duplicate_ids_and_budget_never_claim_complete(self): + for edit in (lambda: setattr(self.cells[0], 'parentRow', self.rows[1]), + lambda: setattr(self.rows[1], 'id', self.rows[0].id), + lambda: setattr(self.cells[0], 'value', float('nan'))): + self.fixture(); edit(); self.assertFalse(config.snapshot(self.design)['complete']) + self.fixture() + with patch.object(config, 'MAX_CELLS', 1): + with patch.object(self.column, 'getCellByRowId') as reader: + self.assertFalse(config.snapshot(self.design)['complete']); reader.assert_not_called() + + def test_explicit_effective_suppression_and_visibility_remain_distinct(self): + self.fixture('ConfigurationSuppressColumn') + cell = config.snapshot(self.design, strict=True)['table']['cells'][0] + self.assertFalse(cell['isSuppressed']); self.assertTrue(cell['isEffectivelySuppressed']) + self.fixture('ConfigurationVisibilityColumn') + self.root.bRepBodies = component('body', 1).bRepBodies + value = {'documentId': 'D', 'revision': 'R'} + with patch.object(bom, 'ref', return_value={}): result = bom.generate(self.design, value, {}) + self.assertEqual(result['totalPurchasingInstances'], 1) + self.assertEqual(result['configuration']['rowId'], 'R0') + + def test_both_csv_modes_stamp_target_and_active_configuration_without_activation(self): + self.fixture('ConfigurationPropertyColumn'); self.table.activeRow = self.rows[1] + self.root.bRepBodies = component('body', 1).bRepBodies + value = {'documentId': 'D', 'revision': 'revision'} + with tempfile.TemporaryDirectory() as directory, patch.object(bom, 'target', return_value=(self.doc, self.design, value)), patch.object(bom, 'ref', return_value={}): + for mode in ('flat', 'structured'): + path = Path(directory) / (mode + '.csv') + result = bom.bom_export(None, {'filename': str(path), 'mode': mode}) + with path.open() as stream: row = next(csv.DictReader(stream)) + self.assertEqual([row[key] for key in ('documentId', 'revision', 'configurationTableId', 'configurationRowId', 'configurationRowName')], + ['D', 'revision', 'T', 'R1', 'Wide']) + self.assertEqual(result['configuration']['rowId'], 'R1') + self.assertIs(self.table.activeRow, self.rows[1]) + + def test_discovery_exposes_only_read_operation_and_rejects_configuration_writes(self): + self.assertIn('configuration_inspect', m.OPERATIONS) + self.assertNotIn('configuration_inspect', m.WRITES) + self.assertEqual([name for name in m.SCHEMA if name.startswith('configuration_')], ['configuration_inspect']) + for op in ('configuration_enable', 'configuration_activate', 'configuration_cells_update'): + self.assertEqual(m.handle_mechanical(None, {'op': op})['errorCode'], 'unsupported_operation') + + +if __name__ == '__main__': unittest.main() diff --git a/tests/test_mechanical_everyday.py b/tests/test_mechanical_everyday.py new file mode 100644 index 0000000..300b4ad --- /dev/null +++ b/tests/test_mechanical_everyday.py @@ -0,0 +1,270 @@ +"""CP5 boundary proofs without claiming native solver/measurement acceptance.""" +import importlib +import math +from types import SimpleNamespace as NS +import unittest +from unittest.mock import patch + +from test_mechanical import m, c, Collection, Entity + +g = importlib.import_module('mechanical_test_commands.mechanical.geometry') +e = importlib.import_module('mechanical_test_commands.mechanical.sketch_edits') +r = importlib.import_module('mechanical_test_commands.mechanical.readback') + + +class Point: + objectType = 'adsk::core::Point3D' + def __init__(self, x, y=0, z=0): self.x, self.y, self.z = x, y, z + def distanceTo(self, other): return math.dist((self.x,self.y,self.z), (other.x,other.y,other.z)) + def vectorTo(self, other): return Point(other.x-self.x,other.y-self.y,other.z-self.z) + + +class Boundaries(unittest.TestCase): + value = {'documentId':'d','revision':'r'} + + def assert_code(self, code, fn): + with self.assertRaises(c.MechanicalError) as error: fn() + self.assertEqual(error.exception.code, code) + + def test_measurement_rejects_definition_and_unrooted_context(self): + root, child = Entity(), Entity() + design = NS(rootComponent=root); root.allOccurrences=Collection() + face=Entity(objectType='adsk::fusion::BRepFace',body=NS(parentComponent=child)) + self.assert_code('occurrence_required',lambda:g.root_entity(design,face)) + face.assemblyContext=Entity();self.assert_code('root_context_required',lambda:g.root_entity(design,face)) + root.allOccurrences=Collection([face.assemblyContext]);self.assertIs(g.root_entity(design,face),face) + + def test_distance_units_and_witness_mismatch(self): + result=Entity(value=0.5,positionOne=Point(0),positionTwo=Point(0.3,0.4)) + manager=NS(measureMinimumDistance=lambda *_:result) + distance,witness=g.minimum(manager,None,None) + self.assertEqual(distance,5);self.assertEqual(witness,[[0,0,0],[3,4,0]]) + result.value=0.7;self.assert_code('measurement_failed',lambda:g.minimum(manager,None,None)) + result.value=float('nan');self.assert_code('invalid_input',lambda:g.minimum(manager,None,None)) + + def test_angle_apex_and_degenerate_witness_validation(self): + root=Entity();design=NS(rootComponent=root) + points=[Entity(objectType='adsk::fusion::BRepVertex',body=NS(parentComponent=root)) for _ in range(3)] + measured=Entity(value=math.pi/2,positionOne=Point(1),positionTwo=Point(0),positionThree=Point(0,1)) + app=NS(measureManager=NS(measureAngle=lambda *_:measured)) + with patch.object(g,'target',return_value=(None,design,self.value)),patch.object(g,'resolve',side_effect=lambda d,t,x,a:x),patch.object(g,'ref',return_value={}): + self.assertEqual(g.measure(app,{'type':'angle','entities':points})['value'],90) + self.assert_code('invalid_input',lambda:g.measure(app,{'type':'angle','entities':points[:2]})) + measured.positionThree=Point(0) + self.assert_code('invalid_direction',lambda:g.measure(app,{'type':'angle','entities':points})) + + def test_parallel_native_zero_witness_uses_geometry_without_inventing_points(self): + def plane(normal): + return Entity(objectType='adsk::fusion::BRepFace', + geometry=NS(objectType='adsk::core::Plane', normal=Point(*normal))) + planes=[plane((0,0,1)),plane((0,0,-1))] + zeros=[[0,0,0],[0,0,0],[0,0,0]] + with patch.object(g,'concrete_geometry',side_effect=lambda x:x): + proof=g.angle_witness(planes,0,zeros) + self.assertEqual(proof['method'],'native_geometry_directions') + self.assertTrue(proof['nativePointWitnessDegenerate']) + self.assertEqual(zeros,[[0,0,0],[0,0,0],[0,0,0]]) + self.assert_code('measurement_failed',lambda:g.angle_witness(planes,90,zeros)) + planes[1]=plane((1,0,0)) + self.assert_code('measurement_failed',lambda:g.angle_witness(planes,0,zeros)) + theta=math.radians(1e-4) + planes[1]=plane((math.sin(theta),0,math.cos(theta))) + self.assert_code('measurement_failed',lambda:g.angle_witness(planes,0,zeros)) + + def test_parallel_line_plane_fallback_uses_world_sketch_geometry(self): + line=Entity(objectType='adsk::fusion::SketchLine', + worldGeometry=NS(objectType='adsk::core::Line3D',startPoint=Point(0),endPoint=Point(1))) + plane=Entity(objectType='adsk::fusion::ConstructionPlane', + geometry=NS(objectType='adsk::core::Plane',normal=Point(0,1))) + with patch.object(g,'concrete_geometry',side_effect=lambda x:x): + self.assertEqual(g.angle_witness([line,plane],0,[[0,0,0]]*3)['directions'][0]['vector'],[1,0,0]) + plane.geometry.normal=Point(1) + self.assert_code('measurement_failed',lambda:g.angle_witness([line,plane],0,[[0,0,0]]*3)) + + def test_component_near_copies_trimmed_brep_and_binds_reversed_native_witness(self): + root,child=Entity(),Entity() + edge=Entity(body=NS(parentComponent=child)); copied=Entity() + result=Entity(value=math.sqrt(0.5),positionOne=Point(1,1,0),positionTwo=Point(1,0.5,0.5)) + observed=[] + manager=NS(measureMinimumDistance=lambda first,second:(observed.append(second) or result)) + copier=NS(copy=lambda entity:copied if entity is edge else None) + with patch.object(g.adsk.fusion,'TemporaryBRepManager',NS(get=lambda:copier),create=True),patch.object(g,'point',return_value=Point(1,0.5,0.5)): + distance,nearest=g.near_geometry(manager,edge,'edges',[10,5,5],None,NS(rootComponent=root)) + self.assertEqual(observed,[copied]);self.assertAlmostEqual(distance,math.sqrt(50)) + self.assertEqual(nearest,[10,10,0]) + result.positionTwo=Point(1,0.5,-0.5) + self.assert_code('measurement_failed',lambda:g.near_geometry(manager,edge,'edges',[10,5,5],None,NS(rootComponent=root))) + + def test_component_vertex_near_uses_native_point_without_brep_copy(self): + vertex=Entity(body=NS(parentComponent=Entity()),geometry=Point(1,1,0)) + result=Entity(value=1,positionOne=Point(1,0,0),positionTwo=vertex.geometry) + seen=[] + manager=NS(measureMinimumDistance=lambda first,second:(seen.append(second) or result)) + with patch.object(g,'point',return_value=Point(1,0,0)): + self.assertEqual(g.near_geometry(manager,vertex,'vertices',[10,0,0],None,NS(rootComponent=Entity())),(10,[10,10,0])) + self.assertIs(seen[0],vertex.geometry) + + def fixture(self): + root=Entity();child=Entity() + matrix=[0,-1,0,10,1,0,0,5,0,0,1,0,0,0,0,1] + context=Entity(fullPathName='Rotated:1',transform2=NS(asArray=lambda:matrix),component=child) + root.allOccurrences=Collection([context]) + face=lambda normal:Entity(objectType='adsk::fusion::BRepFace',geometry=NS(objectType='adsk::core::Plane'),area=2, + evaluator=NS(getNormalAtPoint=lambda _: (True,normal)),pointOnFace=Point(0)) + faces=[face(Point(1)),face(Point(-1))] + body=Entity(parentComponent=child,faces=Collection(faces));proxy=Entity(objectType='adsk::fusion::BRepBody',nativeObject=body,assemblyContext=context) + for f in faces:f.body=body + return NS(rootComponent=root),proxy,faces,context + + def query(self,design,body,args): + with patch.object(g,'target',return_value=(None,design,self.value)),patch.object(g,'resolve',return_value=body),patch.object(g,'ref',side_effect=lambda entity,value,occurrence=None:{'context':getattr(occurrence,'fullPathName',None)}): + return g.geometry_query(NS(),{'body':{},'kind':'faces','space':'root',**args}) + + def test_query_uses_rotated_root_normals_and_keeps_instance_refs(self): + design,body,faces,context=self.fixture() + result=self.query(design,body,{'filters':{'direction':{'vector':[0,1,0]},'area':{'min':200,'max':200}}}) + self.assertEqual(result['count'],1);self.assertEqual(result['matches'][0]['direction'],[0,1,0]) + self.assertEqual(result['matches'][0]['ref']['context'],'Rotated:1') + result=self.query(design,body,{'space':'component','filters':{'direction':{'vector':[1,0,0]}}}) + self.assertEqual(result['matches'][0]['direction'],[1,0,0]);self.assertEqual(result['matches'][0]['ref']['context'],'Rotated:1') + + def test_query_ambiguity_empty_budget_and_inapplicable_filter_fail(self): + design,body,_,_=self.fixture() + self.assert_code('ambiguous_selection',lambda:self.query(design,body,{})) + self.assert_code('selection_empty',lambda:self.query(design,body,{'filters':{'area':{'min':201}}})) + self.assertEqual(self.query(design,body,{'selection':'many','filters':{'area':{'min':201}}})['matches'],[]) + self.assert_code('query_budget',lambda:self.query(design,body,{'selection':'many','maxResults':1})) + self.assert_code('invalid_input',lambda:self.query(design,body,{'filters':{'length':{'min':0}}})) + self.assert_code('invalid_direction',lambda:self.query(design,body,{'filters':{'direction':{'vector':[0,0,0]}}})) + + def line(self,start,end): + return Entity(is2D=True,startSketchPoint=NS(geometry=Point(*start)),endSketchPoint=NS(geometry=Point(*end))) + + def test_offset_flow_order_is_preserved_and_disconnection_refused(self): + a=self.line((0,0),(1,0));b=self.line((1,1),(1,0));d=self.line((1,1),(2,1)) + curves=[a,b,d];e.ordered_chain(curves);self.assertEqual(curves,[a,b,d]) + self.assert_code('ambiguous_chain',lambda:e.ordered_chain([a,d,b])) + self.assert_code('duplicate_entity',lambda:e.ordered_chain([a,a])) + b.is2D=False;self.assert_code('unsupported_geometry',lambda:e.ordered_chain([a,b])) + + def edit_fixture(self,action,changed,returned,deleted=False,**args): + sketch=Entity(isDerived=False,isComputeDeferred=False) + curve=Entity(parentSketch=sketch) + def edit(*_):curve.isValid=not deleted;return Collection(returned) + setattr(curve,'breakCurve' if action=='break' else action,edit) + with patch.object(e,'get_sketch',return_value=sketch),patch.object(e,'editable_curve',return_value=curve),patch.object(e,'point',return_value=Point(0)),patch.object(e,'sketch_data',side_effect=[{'curves':[{'geometry':1}]},{'curves':[{'geometry':2 if changed else 1}]},{}]),patch.object(e,'curve_data',return_value={}): + fn=e.sketch_curve_edit(None,self.value,{'action':action,'curve':{},'pick':[0,0],**args}) + return fn(self.value) + + def test_empty_trim_requires_explicit_delete_and_empty_break_is_failure(self): + self.assert_code('curve_edit_empty',lambda:self.edit_fixture('trim',True,[],True)) + result=self.edit_fixture('trim',True,[],True,allowDelete=True) + self.assertTrue(result['originalDeleted']);self.assertEqual(result['curves'],[]) + self.assert_code('curve_edit_empty',lambda:self.edit_fixture('break',False,[])) + self.assert_code('invalid_input',lambda:self.edit_fixture('extend',False,[],allowDelete=True)) + + def test_returned_curve_cannot_hide_no_persistent_change(self): + self.assert_code('curve_edit_unchanged',lambda:self.edit_fixture('trim',False,[Entity()])) + + def test_unrelated_sketch_change_cannot_hide_selected_curve_noop(self): + self.assert_code('curve_edit_unchanged',lambda:self.edit_fixture('trim',True,[Entity()])) + + def test_in_place_extend_returns_fresh_original_only_after_persistent_change(self): + sketch=Entity(isDerived=False,isComputeDeferred=False) + curve=Entity(parentSketch=sketch) + curve.extend=lambda *_:Collection([]) + for changed in (True,False): + with patch.object(e,'get_sketch',return_value=sketch),patch.object(e,'editable_curve',return_value=curve),patch.object(e,'point',return_value=Point(0)),patch.object(e,'sketch_data',side_effect=[{'curves':[{'geometry':1}]},{'curves':[{'geometry':2}]},{}]),patch.object(e,'curve_data',side_effect=[{'geometry':1},{'geometry':2 if changed else 1},{'geometry':2,'ref':{'revision':'fresh'}}]): + result=e.sketch_curve_edit(None,self.value,{'action':'extend','curve':{},'pick':[0,0]}) + if changed: + value=result({'revision':'fresh'}) + self.assertFalse(value['originalDeleted']) + self.assertEqual(value['curves'],[{'geometry':2,'ref':{'revision':'fresh'}}]) + else: + self.assert_code('curve_edit_unchanged',lambda:result({'revision':'fresh'})) + + def test_flag_changes_and_collection_reordering_do_not_prove_curve_edit(self): + before={'curves':[{'geometry':1,'fixed':False},{'geometry':2,'construction':False}]} + after={'curves':[{'geometry':2,'construction':True},{'geometry':1,'fixed':True}],'constraints':[{'type':'new'}]} + self.assertEqual(e.curve_shapes(before),e.curve_shapes(after)) + + def test_offset_signed_request_and_postcompute_reset_refused(self): + sketch=Entity(isDerived=False,isComputeDeferred=False);curve=Entity(is2D=True) + child=Entity();parameter=NS();constraint=Entity(dimension=NS(parameter=parameter),childCurves=[child],parentCurves=[curve],distance=-0.2,isTopologyMatched=True) + inputs=[] + sketch.geometricConstraints=NS(createOffsetInput=lambda curves,value:inputs.append((curves,value.stringValue)) or NS(),addOffset2=lambda _:constraint) + design=NS(unitsManager=NS(evaluateExpression=lambda expr,unit:-0.2)) + with patch.object(e,'get_sketch',return_value=sketch),patch.object(e,'editable_curve',return_value=curve),patch.object(e,'quantity',return_value=NS(stringValue='-2 mm')),patch.object(e,'parameter_data',return_value={'expression':'-2 mm'}),patch.object(e,'ref',return_value={}),patch.object(e,'curve_data',return_value={}),patch.object(e,'sketch_data',return_value={}): + fn=e.sketch_offset(design,self.value,{'sketch':{},'curves':[{}],'distance':'-2 mm'}) + self.assertEqual(inputs,[([curve],'-2 mm')]);self.assertEqual(fn(self.value)['offset']['distance'],-2) + constraint.distance=0.2 + self.assert_code('offset_failed',lambda:fn(self.value)) + + def test_construction_axis_cross_component_creation_context_required(self): + root=Entity();child=Entity();source=Entity();context=Entity(component=child,objectType='adsk::fusion::Occurrence') + root.allOccurrences=Collection([context]);design=NS(rootComponent=root) + points=[Entity(objectType='adsk::fusion::BRepVertex',body=NS(parentComponent=root)) for _ in range(2)] + inp=NS(setByTwoPoints=lambda *args:True);child.constructionAxes=NS(createInput=lambda:inp,add=lambda _:Entity()) + with patch.object(g,'component',return_value=child),patch.object(g,'resolve',side_effect=lambda d,v,item,allowed:context if item=='context' else points[item]),patch.object(g,'named',side_effect=lambda x,a:x): + args={'type':'two_points','entities':[0,1]} + self.assert_code('creation_occurrence_required',lambda:g.construction_axis(design,self.value,args)) + g.construction_axis(design,self.value,{**args,'creationOccurrence':'context'}) + self.assertIs(inp.creationOccurrence,context) + context.component=source + self.assert_code('wrong_component',lambda:g.construction_axis(design,self.value,{**args,'creationOccurrence':'context'})) + + def test_spline_recovery_witness_observes_shape_beyond_endpoints(self): + controls=[Point(0),Point(1,1),Point(2)] + spline=NS(objectType='adsk::core::NurbsCurve3D',getData=lambda:(True,controls,2,[0,0,0,1,1,1],False,[],False)) + before=r.geometry_data(spline);controls[1].y=2 + self.assertNotEqual(before,r.geometry_data(spline)) + spline.getData=lambda:(False,[],0,[],False,[],False) + self.assert_code('unavailable_geometry',lambda:r.geometry_data(spline)) + + def graph_fixture(self): + point=Entity(objectType='adsk::fusion::SketchPoint',geometry=Point(0),isFixed=False) + a=Entity(objectType='adsk::fusion::SketchLine',shape='horizontal') + b=Entity(objectType='adsk::fusion::SketchLine',shape='vertical') + constraint=Entity(objectType='adsk::fusion::CoincidentConstraint',point=point,entity=a) + dimension=Entity(objectType='adsk::fusion::SketchLinearDimension',entityOne=point,entityTwo=a, + orientation=0,isDriving=True,value=0,parameter=NS(name='d1',expression='0 mm')) + sketch=Entity(name='S',isFullyConstrained=False,sketchCurves=Collection([a,b]),sketchPoints=Collection([point]), + geometricConstraints=Collection([constraint]),sketchDimensions=Collection([dimension])) + return sketch,constraint,dimension,a,b + + def test_constraint_retarget_at_intersection_changes_recovery_graph(self): + sketch,constraint,_,a,b=self.graph_fixture() + with patch.object(r,'curve_data',side_effect=lambda curve,*_: {'shape':curve.shape}): + before=r.sketch_data(sketch);constraint.entity=b + self.assertFalse(e.equivalent(before,r.sketch_data(sketch))) + constraint.entity=a + self.assertTrue(e.equivalent(before,r.sketch_data(sketch))) + + def test_dimension_retarget_and_orientation_change_are_witnessed(self): + sketch,_,dimension,a,b=self.graph_fixture() + with patch.object(r,'curve_data',side_effect=lambda curve,*_: {'shape':curve.shape}): + before=r.sketch_data(sketch);dimension.entityTwo=b + self.assertFalse(e.equivalent(before,r.sketch_data(sketch))) + dimension.entityTwo=a;dimension.orientation=1 + self.assertFalse(e.equivalent(before,r.sketch_data(sketch))) + dimension.orientation=0 + self.assertTrue(e.equivalent(before,r.sketch_data(sketch))) + + def test_unreadable_or_foreign_graph_fails_before_witness_can_claim_recovery(self): + sketch,constraint,_,a,_=self.graph_fixture() + with patch.object(r,'curve_data',side_effect=lambda curve,*_: {'shape':curve.shape}): + constraint.entity=Entity(objectType='adsk::fusion::SketchLine',shape='same drawing elsewhere') + self.assert_code('unsupported_constraint_graph',lambda:r.sketch_data(sketch)) + constraint.entity=a;constraint.objectType='adsk::fusion::FutureConstraint' + self.assert_code('unsupported_constraint_graph',lambda:r.sketch_data(sketch)) + + def test_new_schemas_require_frame_and_forbid_boolean_quantities(self): + reference={**self.value,'objectType':'adsk::fusion::BRepBody'} + request={'op':'geometry_query','target':self.value,'body':reference,'kind':'faces'} + self.assert_code('invalid_input',lambda:m.validate(request,m.SCHEMA['geometry_query'])) + m.validate({**request,'space':'root','filters':{'area':{'min':10}}},m.SCHEMA['geometry_query']) + request={'op':'sketch_offset','target':self.value,'sessionId':'s','operationId':'id','sketch':reference,'curves':[reference],'distance':True} + self.assert_code('invalid_input',lambda:m.validate(request,m.SCHEMA['sketch_offset'])) + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_mechanical_regular_joints.py b/tests/test_mechanical_regular_joints.py new file mode 100644 index 0000000..46947f3 --- /dev/null +++ b/tests/test_mechanical_regular_joints.py @@ -0,0 +1,505 @@ +"""CP6 graph, timeline, units and solver postcondition boundary regressions.""" +import importlib +import math +from contextlib import ExitStack, contextmanager, nullcontext +from types import SimpleNamespace as NS +import unittest +from unittest.mock import patch + +from test_mechanical import m, c, Collection, Entity, adsk + +j = importlib.import_module('mechanical_test_commands.mechanical.regular_joints') +s = importlib.import_module('mechanical_test_commands.mechanical.joint_state') +l = importlib.import_module('mechanical_test_commands.mechanical.motion_links') +a = importlib.import_module('mechanical_test_commands.mechanical.assembly_state') + + +def matrix(x=0, y=0): + return NS(asArray=lambda:[1,0,0,x,0,1,0,y,0,0,1,0,0,0,0,1]) + + +def point(x=0,y=0,z=0): return NS(x=x,y=y,z=z) + + +class Boundaries(unittest.TestCase): + value={'documentId':'d','revision':'r'} + + def setUp(self): + c._entities.clear() + s._source_entities.clear() + self.keys=NS(CenterKeyPoint=0,StartKeyPoint=1,MiddleKeyPoint=2,EndKeyPoint=3) + + def code(self, expected, callback): + with self.assertRaises(c.MechanicalError) as raised: callback() + self.assertEqual(raised.exception.code, expected) + + def geometry(self, source): + return Entity(objectType='adsk::fusion::JointGeometry',geometryType=6,keyPointType=0, + entityOne=source,entityTwo=None,origin=point(),primaryAxisVector=point(0,0,1), + secondaryAxisVector=point(1),thirdAxisVector=point(0,1),planeOne=None,planeTwo=None) + + def graph_scope(self): + return patch.object(adsk.fusion,'JointGeometryTypes',NS(JointTangentBRepFaceGeometry=10,JointTangentBRepFaceEdgeGeometry=11),create=True) + + def test_timeline_restored_after_setter_exception_and_failed_roll(self): + timeline=NS(markerPosition=9) + def roll(_): timeline.markerPosition=4; return True + entity=Entity(timelineObject=Entity(rollTo=roll)) + with self.assertRaisesRegex(RuntimeError,'setter'): + with j.before_timeline(NS(timeline=timeline),entity): + self.assertEqual(timeline.markerPosition,4) + raise RuntimeError('setter') + self.assertEqual(timeline.markerPosition,9) + def refused(_): timeline.markerPosition=3; return False + entity.timelineObject.rollTo=refused + def run(): + with j.before_timeline(NS(timeline=timeline),entity):self.fail('entered after refused roll') + self.code('timeline_roll_failed',run);self.assertEqual(timeline.markerPosition,9) + + def test_timeline_restore_refusal_is_reported(self): + class Timeline: + current=9 + @property + def markerPosition(self):return self.current + @markerPosition.setter + def markerPosition(self,value):pass + timeline=Timeline() + def roll(_):timeline.current=4;return True + def run(): + with j.before_timeline(NS(timeline=timeline),Entity(timelineObject=Entity(rollTo=roll))):pass + self.code('timeline_restore_failed',run) + + def test_source_identity_survives_invalidated_wrapper_without_token_string_identity(self): + current={} + design=NS(findEntityByToken=lambda token:current.get(token,[])) + component=Entity(objectType='adsk::fusion::Component',parentDesign=design) + old=Entity(objectType='adsk::fusion::BRepFace',entityToken='old',body=NS(parentComponent=component)) + current['old']=[old] + before=s.entity_link(old) + old.isValid=False + fresh=Entity(objectType=old.objectType,entityToken='new',body=NS(parentComponent=component)) + current['old']=[fresh];current['new']=[fresh] + self.assertEqual(before,s.entity_link(fresh)) + distinct=Entity(objectType=old.objectType,entityToken='other',body=NS(parentComponent=component)) + current['other']=[distinct] + self.assertNotEqual(before,s.entity_link(distinct)) + current['old']=[fresh,distinct] + self.code('ambiguous_reference',lambda:s.entity_link(fresh)) + + def test_geometry_keypoints_and_edge_ownership_fail_before_factory(self): + calls=[] + factory=NS(createByPlanarFace=lambda *args:calls.append(args),createByCurve=lambda *args:calls.append(args)) + edge=Entity(objectType='adsk::fusion::BRepEdge',geometry=NS(objectType='adsk::core::Circle3D')) + face=Entity(objectType='adsk::fusion::BRepFace',geometry=NS(objectType='adsk::core::Plane'),edges=Collection()) + with patch.object(adsk.fusion,'JointKeyPointTypes',self.keys,create=True),patch.object(adsk.fusion,'JointGeometry',factory,create=True): + self.code('invalid_joint_geometry',lambda:j.geometry_from_entity(face,'start')) + self.code('invalid_joint_geometry',lambda:j.geometry_from_entity(face,'center',edge)) + self.code('invalid_joint_geometry',lambda:j.geometry_from_entity(edge,'start')) + self.assertEqual(calls,[]) + + def test_native_definition_selector_refuses_foreign_and_proxy(self): + component=Entity();other=Entity() + entity=Entity(objectType='adsk::fusion::BRepFace',body=NS(parentComponent=other)) + with patch.object(j,'resolve',return_value=entity),patch.object(j,'geometry_from_entity') as create: + self.code('wrong_component',lambda:j.selector(NS(),self.value,{'entity':{}},component)) + entity.body.parentComponent=component;entity.assemblyContext=Entity() + self.code('wrong_component',lambda:j.selector(NS(),self.value,{'entity':{}},component)) + create.assert_not_called() + + def test_pair_accepts_repeated_definition_instances_but_not_same_or_ancestor(self): + component=Entity() + one=Entity(objectType='adsk::fusion::Occurrence',fullPathName='A:1',component=component,isGrounded=False) + two=Entity(objectType='adsk::fusion::Occurrence',fullPathName='A:2',component=component,isGrounded=True) + design=NS(rootComponent=NS(allOccurrences=Collection([one,two]))) + g1=self.geometry(Entity(objectType="adsk::fusion::BRepFace",assemblyContext=one));g2=self.geometry(Entity(objectType="adsk::fusion::BRepFace",assemblyContext=two)) + self.assertEqual(j.check_pair(design,g1,g2),[one,two]) + self.code('invalid_joint',lambda:j.check_pair(design,g1,g1)) + two.fullPathName='A:1+Child:1';self.code('invalid_joint',lambda:j.check_pair(design,g1,g2)) + two.fullPathName='A:2';one.isGrounded=True;self.code('grounded_conflict',lambda:j.check_pair(design,g1,g2)) + + def test_pose_readback_uses_root_mm_and_refuses_grounded_movement(self): + occurrence=Entity(fullPathName='Parent:1+Child:1',transform2=matrix(1,2),isGrounded=False,isGroundToParent=False) + design=NS(rootComponent=NS(allOccurrences=Collection([occurrence]))) + before=j.poses(design);occurrence.transform2=matrix(8,13) + row=j.pose_result(design,before)['changedOccurrences'][0] + self.assertEqual([row['after'][3],row['after'][7]],[80,130]) + occurrence.isGrounded=True;before=j.poses(design);occurrence.transform2=matrix(9,13) + self.code('grounded_conflict',lambda:j.pose_result(design,before)) + + def test_native_joint_frames_are_not_transformed_again_or_called_physical_orientation(self): + component=Entity();component.parentDesign=NS(rootComponent=component) + class Joint: + parentComponent=component + geometryOneTransform=matrix(.5,2.5) + geometryTwoTransform=matrix(.5,2.5) + @property + def geometryOrOriginOne(self):raise AssertionError('read transient geometry') + @property + def occurrenceOne(self):raise AssertionError('multiplied occurrence again') + result=j.attachment_frames(Joint()) + self.assertEqual([result['one'][3],result['one'][7]],[5,25]) + self.assertEqual(result['originSpace'],'root') + self.assertEqual(result['space'],'native_joint_api') + self.assertIn('distinct',result['axisConvention']) + + def test_initial_alignment_refuses_equal_distance_wrong_axis_and_no_solve(self): + one=[1,0,0,0,0,1,0,0,0,0,1,2,0,0,0,1] + two=[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1] + with patch.object(j,'attachment_frames',return_value={'one':one,'two':two}): + self.assertTrue(j.initial_alignment(None,2,0,'rigid')['offsetMagnitudeVerified']) + self.code('joint_alignment_failed',lambda:j.initial_alignment(None,0,0,'rigid')) + one[11]=0;one[3]=2 + self.code('joint_alignment_failed',lambda:j.initial_alignment(None,2,0,'rigid')) + + def test_fresh_attachment_verification_does_not_read_retained_input_or_switch_source_context(self): + comp=Entity();one=Entity(token='one');two=Entity(token='two') + old=Entity(objectType='adsk::fusion::BRepFace',body=NS(parentComponent=comp),token='face') + class ExpiringGeometry(Entity): + def __getattribute__(self,name): + if name in ('entityOne','entityTwo','origin') and self.__dict__.get('expired'): + raise RuntimeError('InternalValidationError: face (isValid is still true)') + return super().__getattribute__(name) + geometry=ExpiringGeometry(**{key:value for key,value in self.geometry(old).__dict__.items() if key!='isValid'}) + fresh=Entity(objectType=old.objectType,body=NS(parentComponent=comp),token='face') + live={'face':fresh,'one':one,'two':two} + with self.graph_scope(),patch.object(s,'source_identity',side_effect=c.identity),patch.object(j,'ref',side_effect=lambda e,v:{'token':e.token}),patch.object(j,'resolve',side_effect=lambda d,v,r,*args:live[r['token']]): + saved=j.capture_attachment(geometry,self.value,one) + old.isValid=False;geometry.expired=True + self.assertTrue(geometry.isValid) + actual=self.geometry(fresh) + j.verify_attachment(NS(),self.value,actual,saved,one) + self.code('joint_attachment_refused',lambda:j.verify_attachment(NS(),self.value,actual,saved,two)) + actual.entityOne=Entity(objectType=old.objectType,body=NS(parentComponent=comp)) + self.code('joint_attachment_refused',lambda:j.verify_attachment(NS(),self.value,actual,saved,one)) + actual.entityOne=fresh;actual.keyPointType=2 + self.code('joint_attachment_refused',lambda:j.verify_attachment(NS(),self.value,actual,saved,one)) + + def test_source_graph_witness_stays_equal_after_compute_but_detects_retargeting(self): + current={} + design=NS(findEntityByToken=lambda token:current.get(token,[])) + comp=Entity(parentDesign=design) + old=Entity(objectType='adsk::fusion::BRepFace',entityToken='old',body=NS(parentComponent=comp)) + fresh=Entity(objectType=old.objectType,entityToken='fresh',body=NS(parentComponent=comp)) + other=Entity(objectType=old.objectType,entityToken='other',body=NS(parentComponent=comp)) + current.update(old=[old],fresh=[fresh],other=[other]) + with self.graph_scope(): + before=s.geometry_state(self.geometry(old)) + old.isValid=False;current['old']=[fresh] + self.assertEqual(before,s.geometry_state(self.geometry(fresh))) + self.assertNotEqual(before,s.geometry_state(self.geometry(other))) + + def test_native_origin_geometry_proxy_accepts_exact_definition_only(self): + comp=Entity();other=Entity() + source=Entity(objectType='adsk::fusion::BRepFace',body=NS(parentComponent=comp),token='face') + instance=Entity(component=comp) + proxy=Entity(objectType=source.objectType,nativeObject=source,assemblyContext=instance) + with self.graph_scope(),patch.object(s,'source_identity',side_effect=c.identity),patch.object(j,'ref',return_value={'token':'face'}),patch.object(j,'resolve',return_value=source): + saved=j.capture_attachment(self.geometry(source),self.value) + actual=self.geometry(proxy) + j.verify_attachment(NS(),self.value,actual,saved,definition=True) + self.code('joint_attachment_refused',lambda:j.verify_attachment(NS(),self.value,actual,saved)) + instance.component=other + self.code('joint_attachment_refused',lambda:j.verify_attachment(NS(),self.value,actual,saved,definition=True)) + + def test_frame_validation_rejects_nan_scale_and_left_handed_frame(self): + values=list(matrix().asArray());j.validate_frame(values) + values[0]=float('nan');self.code('joint_frame_unavailable',lambda:j.validate_frame(values)) + values[0]=2;self.code('joint_frame_unavailable',lambda:j.validate_frame(values)) + values[0]=-1;self.code('joint_frame_unavailable',lambda:j.validate_frame(values)) + + def test_inferred_motion_is_explicitly_refused(self): + self.code('unsupported_inferred_joint',lambda:a.joint_motion(NS(objectType="adsk::fusion::Joint",jointMotion=None))) + + def test_parameter_readback_is_not_expression_or_display_name_equality(self): + expected={'offset':(None,12),'angle':(None,30)} + actual={'offset':{'value':12,'expression':'1.2 cm'},'angle':{'value':30,'expression':'30 deg'}} + j.verify_parameters(actual,expected) + actual['offset']['value']=1.2 + self.code('joint_parameter_refused',lambda:j.verify_parameters(actual,expected)) + + def test_regular_state_includes_attachment_offsets_and_lock(self): + source=Entity(objectType='adsk::fusion::BRepFace') + parameter=lambda x: NS(value=x,expression=str(x)) + joint=Entity(geometryOrOriginOne=self.geometry(source),geometryOrOriginTwo=self.geometry(source), + geometryOneTransform=matrix(),geometryTwoTransform=matrix(),angle=parameter(math.pi/6), + offset=parameter(1.2),offsetX=parameter(0),offsetY=parameter(0),isFlipped=False,isLocked=False,parentComponent=Entity()) + with self.graph_scope(): + initial=s.regular_state(joint);self.assertAlmostEqual(initial['angle']['value'],30) + self.assertEqual(initial['offset']['value'],12) + joint.offsetX.value=0.1;self.assertNotEqual(initial,s.regular_state(joint)) + joint.offsetX.value=0;joint.isLocked=True;self.assertNotEqual(initial,s.regular_state(joint)) + + def test_integrated_joint_data_has_consistent_matrix_units_and_keeps_asbuilt_contract(self): + source=Entity(objectType='adsk::fusion::BRepFace',entityToken='source') + parameter=lambda x:NS(value=x,expression=str(x)) + joint=Entity(objectType='adsk::fusion::Joint',entityToken='joint',name='Regular',isSuppressed=False, + occurrenceOne=None,occurrenceTwo=None,transform=matrix(1.2), + geometryOrOriginOne=self.geometry(source),geometryOrOriginTwo=self.geometry(source), + geometryOneTransform=matrix(1.2),geometryTwoTransform=matrix(1.2),angle=parameter(0), + offset=parameter(0),offsetX=parameter(0),offsetY=parameter(0),isFlipped=False,isLocked=False,parentComponent=Entity()) + with self.graph_scope(),patch.object(a,'joint_motion',return_value=NS(objectType='adsk::fusion::RigidJointMotion')),patch.object(a,'ref',return_value={}): + result=a.joint_data(joint,self.value) + self.assertEqual(result['units']['transformTranslation'],'mm') + self.assertEqual(result['transformTranslationUnit'],'mm') + self.assertEqual([result[key][3] for key in ('transform','geometryOneTransform','geometryTwoTransform')],[12,12,12]) + joint.objectType='adsk::fusion::AsBuiltJoint' + result=a.joint_data(joint,self.value) + self.assertEqual(result['units']['transformTranslation'],'cm');self.assertEqual(result['transform'][3],1.2) + + @contextmanager + def native_lock_fixture(self, family="Revolute", locked=True): + """Model the measured native setter/timeline restrictions, not the helper.""" + calls = []; root = Entity(); timeline = NS(markerPosition=8, count=8) + snapshots = NS(hasPendingSnapshot=False) + def capture(): + calls.append(("capture", timeline.markerPosition)) + snapshots.hasPendingSnapshot = False + return object() + snapshots.add = capture + class NativeJoint(Entity): + def __init__(self): + super().__init__(objectType="adsk::fusion::Joint", parentComponent=root, + occurrenceOne=None, occurrenceTwo=None, isFlipped=False, + geometryOrOriginOne=Entity(), geometryOrOriginTwo=Entity()) + self.family, self.locked = family, locked + def roll(_): timeline.markerPosition = 4; return True + self.timelineObject = Entity(rollTo=roll) + @property + def jointMotion(self): return NS(objectType="adsk::fusion::" + self.family + "JointMotion") + @property + def isLocked(self): return self.locked + @isLocked.setter + def isLocked(self, desired): + calls.append(("lock", self.family, desired, timeline.markerPosition)) + if self.family == "Rigid": raise RuntimeError("Rigid joint does not support lock operation") + if timeline.markerPosition != timeline.count: raise RuntimeError("Lock setter requires evaluated joint") + self.locked = desired; snapshots.hasPendingSnapshot = True + def setAsRigidJointMotion(self): + calls.append(("redefine", "Rigid", self.locked, timeline.markerPosition)) + if timeline.markerPosition != 4: raise RuntimeError("Redefinition requires roll-before") + self.family, self.locked = "Rigid", False + return True + def setAsRevoluteJointMotion(self, axis): + calls.append(("redefine", "Revolute", self.locked, timeline.markerPosition)) + self.family = "Revolute" + return True + joint = NativeJoint() + class NativeInput: + def setAsRigidJointMotion(self): self.family = "Rigid"; return True + def setAsRevoluteJointMotion(self, axis): self.family = "Revolute"; return True + def add(inp): + calls.append(("add", inp.family)); joint.family, joint.locked = inp.family, False + return joint + root.joints = NS(createInput=lambda *args:NativeInput(), add=add) + design = NS(rootComponent=root, timeline=timeline, snapshots=snapshots) + def expected(design, args, names): + return {key:(NS(stringValue=str(args[key])),float(args[key])) for key in names if key in args} + def data(*_): + return {"joint":{"motionType":joint.jointMotion.objectType,"axes":{"rotationAxis":2}, + "angle":{"value":0},"offset":{"value":0},"locked":joint.isLocked}} + with ExitStack() as stack: + for name, kwargs in { + "selector":{"return_value":Entity()}, "resolve":{"return_value":joint}, + "check_pair":{"return_value":[None,None]}, "capture_attachment":{"return_value":{}}, + "expected_parameters":{"side_effect":expected}, "poses":{"return_value":[]}, + "regular_state":{"return_value":{}}, "require_healthy":{"return_value":None}, + "initial_alignment":{"return_value":{}}, "configure_joint":{"return_value":None}, + "joint_result":{"side_effect":data}, "verify_attachment":{"return_value":None}, + "pose_result":{"return_value":{}}, "attachment_frames":{"return_value":{}} + }.items(): stack.enter_context(patch.object(j, name, **kwargs)) + yield design, joint, calls + + def test_rigid_create_false_never_calls_native_lock_setter_and_checks_final_flag(self): + with self.native_lock_fixture() as (design, joint, calls): + result = j.regular_joint_create(design, self.value, + {"type":"rigid", "geometryOne":{}, "geometryTwo":{}, "locked":False}) + self.assertFalse(result(self.value)["joint"]["locked"]) + self.assertEqual(calls, [("add", "Rigid")]) + joint.locked = True + self.code("joint_edit_refused", lambda:result(self.value)) + + def test_rigid_true_refuses_create_and_edit_before_any_native_mutation(self): + with patch.object(j, "selector") as selected: + self.code("unsupported_joint_lock", lambda:j.regular_joint_create(None, self.value, + {"type":"rigid", "geometryOne":{}, "geometryTwo":{}, "locked":True})) + selected.assert_not_called() + with self.native_lock_fixture() as (design, joint, calls): + self.code("unsupported_joint_lock", lambda:j.regular_joint_edit(design, self.value, + {"joint":{}, "type":"rigid", "locked":True})) + joint.family = "Rigid" + self.code("unsupported_joint_lock", lambda:j.regular_joint_edit(design, self.value, + {"joint":{}, "locked":True})) + self.assertEqual(calls, []); self.assertEqual(design.timeline.markerPosition, 8) + + def test_locked_moving_to_rigid_redefinition_has_no_unlock_setter_before_or_after_roll(self): + for explicit in (False, True): + with self.subTest(explicitFalse=explicit), self.native_lock_fixture() as (design, joint, calls): + args = {"joint":{}, "type":"rigid", **({"locked":False} if explicit else {})} + result = j.regular_joint_edit(design, self.value, args) + self.assertEqual(calls, [("redefine", "Rigid", True, 4)]) + self.assertEqual(design.timeline.markerPosition, 8) + self.assertFalse(result(self.value)["joint"]["locked"]) + joint.locked = True + self.code("joint_edit_refused", lambda:result(self.value)) + + def test_existing_rigid_false_edit_skips_setter_but_still_checks_readback(self): + with self.native_lock_fixture("Rigid", False) as (design, joint, calls): + result = j.regular_joint_edit(design, self.value, {"joint":{}, "locked":False}) + self.assertFalse(result(self.value)["joint"]["locked"]); self.assertEqual(calls, []) + joint.locked = True + self.code("joint_edit_refused", lambda:result(self.value)) + + def test_rigid_edit_requires_an_explicit_change_before_adding_implicit_lock(self): + with self.native_lock_fixture("Rigid", False) as (design, joint, calls): + self.code("invalid_input", lambda:j.regular_joint_edit(design, self.value, {"joint":{}})) + self.assertEqual(calls, []) + + def test_moving_create_uses_native_lock_setter_and_checks_persisted_flag(self): + with self.native_lock_fixture() as (design, joint, calls): + result = j.regular_joint_create(design, self.value, + {"type":"revolute", "geometryOne":{}, "geometryTwo":{}, "locked":True}) + self.assertEqual(calls, [("add", "Revolute"), ("lock", "Revolute", True, 8), ("capture", 8)]) + self.assertTrue(result(self.value)["joint"]["locked"]) + joint.locked = False + self.code("joint_edit_refused", lambda:result(self.value)) + + def test_rigid_to_moving_applies_lock_at_restored_end_then_captures(self): + with self.native_lock_fixture("Rigid", False) as (design, joint, calls): + result = j.regular_joint_edit(design, self.value, {"joint":{}, "type":"revolute", "locked":True}) + self.assertEqual(calls, [("redefine", "Revolute", False, 4), ("lock", "Revolute", True, 8), ("capture", 8)]) + self.assertTrue(result(self.value)["joint"]["locked"]) + joint.locked = False + self.code("joint_edit_refused", lambda:result(self.value)) + + def test_link_motion_rejects_rigid_or_wrong_axis_before_api(self): + with patch.object(l,'joint_motion',return_value=NS(objectType='adsk::fusion::RevoluteJointMotion')): + self.code('wrong_joint_type',lambda:l.motion_enum(None,'slide')) + with patch.object(l,'joint_motion',return_value=NS(objectType='adsk::fusion::RigidJointMotion')): + self.code('wrong_joint_type',lambda:l.motion_enum(None,'rotation')) + + def test_requested_axis_readback_for_each_native_motion_family(self): + for kind,key in [('revolute','rotationAxis'),('slider','slideDirection'),('cylindrical','rotationAxis'),('planar','normalDirection')]: + j.verify_motion_axes({key:0},{'type':kind,'axis':'x'}) + self.code('joint_axis_refused',lambda:j.verify_motion_axes({key:2},{'type':kind,'axis':'x'})) + self.code('joint_axis_refused',lambda:j.verify_motion_axes({'rotationAxis':2,'slideDirection':1},{'type':'pin_slot','slideAxis':'x'})) + self.code('joint_axis_refused',lambda:j.verify_motion_axes({'pitchDirection':2,'yawDirection':1},{'type':'ball'})) + j.verify_motion_axes({'pitchDirection':2,'yawDirection':0},{'type':'ball'}) + + def test_custom_motion_axis_graph_detects_coincident_source_retargeting(self): + first=Entity(objectType='adsk::fusion::BRepEdge');second=Entity(objectType='adsk::fusion::BRepEdge') + motion=NS(objectType='adsk::fusion::RevoluteJointMotion',rotationAxis=3,customRotationAxisEntity=first) + before=s.motion_axes_state(motion) + motion.customRotationAxisEntity=second;self.assertNotEqual(before,s.motion_axes_state(motion)) + motion.customRotationAxisEntity=None;self.code('lost_joint_geometry',lambda:s.motion_axes_state(motion)) + class Standard: + objectType='adsk::fusion::RevoluteJointMotion';rotationAxis=2 + @property + def customRotationAxisEntity(self):raise AssertionError('invalid custom getter was read') + self.assertIsNone(s.motion_axes_state(Standard())['customRotationAxisEntity']) + + def test_base_wrappers_cast_before_reading_occurrence_context_or_geometry(self): + context=Entity();baseface=Entity(objectType='adsk::fusion::BRepFace') + typedface=Entity(objectType=baseface.objectType,assemblyContext=context) + basegeometry=Entity(objectType='adsk::fusion::JointGeometry');typedgeometry=self.geometry(baseface) + with patch.object(adsk.fusion,'BRepFace',NS(cast=lambda value:typedface),create=True),patch.object(adsk.fusion,'JointGeometry',NS(cast=lambda value:typedgeometry),create=True),self.graph_scope(): + self.assertIs(j.attachment_context(basegeometry),context) + self.assertEqual(s.geometry_state(basegeometry)['entityOne']['occurrenceId'],c.identity(context)) + with patch.object(adsk.fusion,'JointGeometry',NS(cast=lambda value:None),create=True): + self.code('unsupported_entity_wrapper',lambda:j.attachment_context(basegeometry)) + + def test_link_result_refuses_membership_ratio_or_flag_mismatch(self): + one=Entity(objectType='adsk::fusion::Joint');two=Entity(objectType='adsk::fusion::Joint') + link=NS(jointOne=one,jointTwo=two,healthState=0) + data={'valueOne':{'value':90},'valueTwo':{'value':180},'reversed':False} + with patch.object(l,'link_data',return_value=data): + l.result(link,self.value,{},one,two,{'valueOne':90}) + self.code('motion_link_refused',lambda:l.result(link,self.value,{},two,one,{})) + self.code('motion_link_refused',lambda:l.result(link,self.value,{},one,two,{'valueOne':45})) + self.code('motion_link_refused',lambda:l.result(link,self.value,{'reversed':True},one,two,{})) + + def test_link_drive_checks_partner_delta_and_preserves_original_ratio(self): + one=Entity(objectType='adsk::fusion::Joint');two=Entity(objectType='adsk::fusion::Joint') + link=Entity(name='Gear',jointOne=one,jointTwo=two,motionOne=0,motionTwo=0, + isSuppressed=False,isReversed=True,valueOne=NS(value=math.pi/2),valueTwo=NS(value=math.pi)) + state={'name':'Gear','reversed':True} + before=[(link,0,0,state)] + with patch.object(l,'link_data',return_value=state),patch.object(l,'motion_value',side_effect=lambda joint,enum:math.pi/18 if joint is one else -math.pi/9): + self.assertTrue(l.verify_coupling(before)[0]['ratioVerified']) + with patch.object(l,'link_data',return_value=state),patch.object(l,'motion_value',side_effect=lambda joint,enum:math.pi/18 if joint is one else 0): + self.code('motion_link_drive_refused',lambda:l.verify_coupling(before)) + with patch.object(l,'link_data',return_value={'name':'Gear','reversed':False}): + self.code('motion_link_refused',lambda:l.verify_coupling(before)) + + def test_single_joint_link_accepts_only_null_or_same_native_second_member(self): + first=Entity(objectType='adsk::fusion::Joint');foreign=Entity(objectType='adsk::fusion::Joint') + link=NS(jointOne=first,jointTwo=None,healthState=0) + with patch.object(l,'link_data',return_value={}): + l.result(link,self.value,{},first,None,{}) + link.jointTwo=first;l.result(link,self.value,{},first,None,{}) + link.jointTwo=foreign + self.code('motion_link_refused',lambda:l.result(link,self.value,{},first,None,{})) + link.jointTwo=None + self.code('motion_link_refused',lambda:l.result(link,self.value,{},first,foreign,{})) + + def test_link_readback_retains_native_second_member_and_explicit_same_joint_flag(self): + first=Entity(objectType='adsk::fusion::Joint');foreign=Entity(objectType='adsk::fusion::Joint') + link=Entity(objectType='adsk::fusion::MotionLink',name='Pitch',jointOne=first,jointTwo=None, + motionOne=3,motionTwo=4,isSuppressed=False,isReversed=False,healthState=0,errorOrWarningMessage='', + valueOne=NS(expression='360 deg',value=math.tau),valueTwo=NS(expression='2 mm',value=.2)) + with patch.object(s,'link_motion_name',side_effect=lambda joint,enum:'rotation' if enum==3 else 'slide'): + empty=s.link_data(link) + self.assertTrue(empty['sameJoint']);self.assertIsNone(empty['jointTwo']) + link.jointTwo=first; repeated=s.link_data(link) + self.assertTrue(repeated['sameJoint']);self.assertEqual(repeated['jointTwo'],repeated['jointOne']) + self.assertEqual(repeated['valueTwo']['value'],2) + link.jointTwo=foreign;self.assertFalse(s.link_data(link)['sameJoint']) + + def test_ratio_only_edit_cannot_reset_omitted_reversal_or_motion(self): + root=Entity();one=Entity(objectType='adsk::fusion::Joint');two=Entity(objectType='adsk::fusion::Joint') + sent=[] + link=Entity(objectType='adsk::fusion::MotionLink',parentComponent=root,jointOne=one,jointTwo=two,healthState=0, + setMotionData=lambda *args:sent.append(args) or True) + old={'motionOne':'rotation','motionTwo':'rotation','reversed':False, + 'valueOne':{'expression':'90 deg','value':90},'valueTwo':{'expression':'180 deg','value':180}} + for key,wrong in [('reversed',True),('motionOne','slide'),('motionTwo','pitch')]: + after={**old,key:wrong,'valueOne':{'expression':'45 deg','value':45}} + with patch.object(l,'resolve',return_value=link),patch.object(l,'link_data',side_effect=[old,after]),patch.object(l,'inputs',return_value=([0,0],[1,2],{'valueOne':45,'valueTwo':180})): + serialize=l.motion_link_edit(NS(rootComponent=root,timeline=NS(markerPosition=18,count=18)),{}, {'link':{},'valueOne':'45 deg'}) + self.assertFalse(sent[-1][-1]) + self.code('motion_link_refused',lambda:serialize({})) + + def test_motion_link_ratio_edit_keeps_item_evaluated_and_never_rolls_before_it(self): + root=Entity();one=Entity(objectType='adsk::fusion::Joint');two=Entity(objectType='adsk::fusion::Joint') + timeline=NS(markerPosition=18,count=18); calls=[] + link=Entity(objectType='adsk::fusion::MotionLink',parentComponent=root,jointOne=one,jointTwo=two, + timelineObject=NS(rollTo=lambda *_:self.fail('Motion link was rolled out of evaluation')), + setMotionData=lambda *args:calls.append((timeline.markerPosition,args)) or True) + old={'motionOne':'rotation','motionTwo':'rotation','reversed':True, + 'valueOne':{'expression':'90 deg','value':90},'valueTwo':{'expression':'180 deg','value':180}} + with patch.object(l,'resolve',return_value=link),patch.object(l,'link_data',return_value=old),patch.object(l,'inputs',return_value=([0,0],[1,2],{'valueOne':90,'valueTwo':90})): + l.motion_link_edit(NS(rootComponent=root,timeline=timeline),{}, {'link':{},'valueTwo':'90 deg'}) + self.assertEqual(calls,[(18,(0,1,0,2,True))]);self.assertEqual(timeline.markerPosition,18) + timeline.markerPosition=16 + self.code('timeline_not_at_end',lambda:l.motion_link_edit(NS(rootComponent=root,timeline=timeline),{}, {'link':{},'valueTwo':'90 deg'})) + self.assertEqual(len(calls),1) + + def test_origin_creation_verifies_explicitly_applied_default_flip(self): + inp=NS();geometry=Entity();origin=Entity() + comp=NS(jointOrigins=NS(createInput=lambda _:inp,add=lambda _:origin)) + with patch.object(j,'component',return_value=comp),patch.object(j,'selector',return_value=geometry),patch.object(j,'origin_axes',return_value={}),patch.object(j,'capture_attachment',return_value={}),patch.object(j,'expected_parameters',return_value={}),patch.object(j,'origin_data',return_value={'flipped':True}): + serialize=j.joint_origin_create(NS(),{}, {'geometry':{}}) + self.assertIs(inp.isFlipped,False) + self.code('joint_origin_refused',lambda:serialize({})) + + def test_new_schemas_register_and_require_real_geometry(self): + for name in (*j.OPERATIONS,*l.OPERATIONS): + self.assertIn(name,m.SCHEMA);self.assertIn(name,m.OPERATIONS);self.assertIn(name,m.WRITES) + valid={'op':'regular_joint_create','sessionId':'s','operationId':'o','target':self.value,'type':'rigid', + 'geometryOne':{'origin':{'documentId':'d','revision':'r','objectType':'adsk::fusion::JointOrigin'}}, + 'geometryTwo':{'entity':{'documentId':'d','revision':'r','objectType':'adsk::fusion::BRepFace'}}} + m.validate(valid,m.SCHEMA['regular_joint_create']) + valid['geometryOne']['faceIndex']=0 + self.code('invalid_input',lambda:m.validate(valid,m.SCHEMA['regular_joint_create'])) + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_mechanical_transactions.py b/tests/test_mechanical_transactions.py new file mode 100644 index 0000000..0132db5 --- /dev/null +++ b/tests/test_mechanical_transactions.py @@ -0,0 +1,197 @@ +"""Transaction and CP2 schema tests, independent of a Fusion geometry kernel.""" +import importlib +from email.message import Message +import importlib.util +import io +import json +import threading +import sys +from types import ModuleType, SimpleNamespace as NS +import unittest +from unittest.mock import patch + +# Reuse only the offline API loading shim, not implementation expectations. +from test_mechanical import m, c, ROOT, Collection, Entity, isolated_history + +tx=importlib.import_module('mechanical_test_commands.mechanical.transactions') + +class Transactions(unittest.TestCase): + def setUp(self): + tx._verified=True + self.doc=object(); self.state={'width':10.0,'volume':100.0,'features':['base']} + self.before=None + self.commands=[] + self.design=NS(timeline=NS(markerPosition=1,count=1)) + def execute(command): + self.commands.append(command) + if command.startswith('PTransaction.Start'): + self.before=json.loads(json.dumps(self.state)) + elif command=='PTransaction.Abort': + self.state.clear();self.state.update(self.before) + return '1' + self.app=NS(activeDocument=self.doc,executeTextCommand=execute) + self.patches=[patch.object(tx,'target',return_value=(self.doc,self.design,{'documentId':'doc','revision':'before'})), + patch.object(tx,'require_healthy',return_value={'healthy':True}), + patch.object(tx,'recovery_snapshot',side_effect=lambda design:json.loads(json.dumps(self.state))), + patch.object(tx,'current_target',return_value={'documentId':'doc','revision':'after'})] + for p in self.patches:p.start() + def tearDown(self): + for p in self.patches:p.stop() + tx._verified=False + + def test_partial_batch_is_aborted_and_witness_verified(self): + def mutation(*args): + self.state['width']=20;self.state['features'].append('bad fillet') + raise RuntimeError('feature kernel refused radius') + with self.assertRaises(c.MechanicalError) as error:tx.execute(self.app,{},mutation) + self.assertTrue(error.exception.details['recovery']['verified']) + self.assertEqual(self.state,{'width':10.0,'volume':100.0,'features':['base']}) + self.assertEqual(self.commands[-1],'PTransaction.Abort') + + def test_native_failure_diagnostics_survive_transaction_wrapper(self): + def mutation(*args):raise c.MechanicalError('ambiguous_reference','Token lookup failed',matchCount=0) + with self.assertRaises(c.MechanicalError) as error:tx.execute(self.app,{},mutation) + self.assertEqual(error.exception.details['causeDetails'],{'matchCount':0}) + self.assertEqual(error.exception.details['causeCode'],'ambiguous_reference') + + def test_unsupported_transaction_never_runs_mutation(self): + self.app.executeTextCommand=lambda command:'unknown command' + called=[] + with self.assertRaises(c.MechanicalError):tx.execute(self.app,{},lambda *a:called.append(1)) + self.assertEqual(called,[]) + + def test_failed_abort_cannot_claim_recovery(self): + original=self.app.executeTextCommand + self.app.executeTextCommand=lambda command: '0' if command.endswith('Abort') else original(command) + def mutation(*a):self.state['volume']=999;raise RuntimeError('bad') + with self.assertRaises(c.MechanicalError) as error:tx.execute(self.app,{},mutation) + self.assertFalse(error.exception.details['recovery']['verified']) + self.assertEqual(self.state['volume'],999) + + def test_changed_document_is_never_aborted(self): + def mutation(*a):self.app.activeDocument=object();return {} + with self.assertRaises(c.MechanicalError) as error:tx.execute(self.app,{},mutation) + self.assertFalse(error.exception.details['recovery']['verified']) + self.assertFalse(any(command.endswith('Abort') for command in self.commands)) + + def test_postcommit_inspection_failure_reports_committed(self): + with patch.object(tx,'current_target',side_effect=[{'documentId':'doc','revision':'provisional'},RuntimeError('read failed')]): + result=tx.execute(self.app,{},lambda *a:{'changed':True}) + self.assertTrue(result['transaction']['committed']);self.assertTrue(result['refreshRequired']) + self.assertEqual(self.commands[-1],'PTransaction.Commit') + + def test_result_serialization_failure_aborts_before_commit(self): + with self.assertRaises(c.MechanicalError):tx.execute(self.app,{},lambda *a:{'invalid':object()}) + self.assertEqual(self.commands[-1],'PTransaction.Abort') + + def test_success_retargets_every_nested_reference_after_commit(self): + result=tx.execute(self.app,{},lambda *a:{'entities':[{'documentId':'doc','revision':'before','token':'x'}]}) + self.assertEqual(result['entities'][0]['revision'],'after') + self.assertTrue(result['transaction']['committed']) + +class Schemas(unittest.TestCase): + ref={'documentId':'d','revision':'r','objectType':'adsk::fusion::BRepBody','token':'x'} + def request(self,op,**args):return dict(op=op,sessionId=m.SESSION_ID,operationId='id',target={'documentId':'d','revision':'r'},**args) + def test_move_vector_and_extrude_axis_variants_do_not_conflict(self): + m.validate(self.request('feature_create',type='move',bodies=[self.ref],axis=[0,0,1],angle=90),m.SCHEMA['feature_create']) + with self.assertRaises(c.MechanicalError):m.validate(self.request('feature_create',type='fillet',edges=[self.ref],radius=2,unexpected=True),m.SCHEMA['feature_create']) + def test_wrong_feature_fields_and_boolean_numbers_refuse(self): + with self.assertRaises(c.MechanicalError):m.validate(self.request('feature_create',type='fillet',profiles=[self.ref],radius=2),m.SCHEMA['feature_create']) + with self.assertRaises(c.MechanicalError):m.validate(self.request('sketch_geometry',sketch=self.ref,items=[{'type':'circle','center':[0,0],'radius':True}]),m.SCHEMA['sketch_geometry']) + def test_parameter_batch_variants_are_strict(self): + m.validate(self.request('parameter_batch',changes=[{'action':'set','name':'width','expression':'20 mm'},{'action':'delete','name':'unused'}]),m.SCHEMA['parameter_batch']) + with self.assertRaises(c.MechanicalError):m.validate(self.request('parameter_batch',changes=[{'action':'delete','name':'width','expression':'2 mm'}]),m.SCHEMA['parameter_batch']) + +class ReviewRegressions(unittest.TestCase): + def test_construction_reference_ownership_uses_component(self): + sketches=importlib.import_module('mechanical_test_commands.mechanical.sketches');comp=object() + for method in (sketches.plane,sketches.axis): + entity=Entity(component=comp,assemblyContext=None) + with patch.object(sketches,'resolve',return_value=entity): + self.assertIs(method(None,{},comp,{}),entity) + with self.assertRaises(c.MechanicalError):method(None,{},object(),{}) + + def test_inspected_profile_reference_roundtrips(self): + from test_mechanical import ops + r=importlib.import_module('mechanical_test_commands.mechanical.readback') + value={'documentId':'d','revision':'r'} + profile=Entity(objectType='adsk::fusion::Profile',areaProperties=lambda:NS(area=1,centroid=NS(x=0,y=0,z=0)),profileLoops=Collection([object()])) + sketch=Entity(objectType='adsk::fusion::Sketch',entityToken='sketch-token',profiles=Collection([profile]));profile.parentSketch=sketch + design=NS(findEntityByToken=lambda token:[sketch]) + original=r.profile_ref(sketch,profile,0,value) + with patch.object(ops,'target',return_value=(None,design,value)): + returned=ops.inspect_entity(None,{'entity':original})['entity']['ref'] + self.assertEqual(ops.inspect_entity(None,{'entity':returned})['entity']['ref'],original) + + def test_acknowledged_abort_does_not_hide_leaked_feature_name(self): + feature=Entity(name='Original',objectType='adsk::fusion::ExtrudeFeature',healthState=0,isSuppressed=False) + comp=Entity(name='Root',bRepBodies=Collection(),sketches=Collection(),features=Collection([feature])) + timeline=Collection([feature]);timeline.markerPosition=1 + design=NS(isConfiguredDesign=False,isConfiguration=False,configurationRowId="",allParameters=Collection(),allComponents=Collection([comp]),rootComponent=NS(allOccurrences=Collection()),timeline=timeline,computeAll=lambda:True) + doc=object();app=NS(activeDocument=doc,executeTextCommand=lambda text:'1');value={'documentId':'d','revision':'r'} + def mutation(*args):feature.name='Leaked';raise RuntimeError('injected failure') + with patch.object(tx,'_verified',True),patch.object(tx,'target',return_value=(doc,design,value)),patch.object(tx,'current_target',return_value=value): + with self.assertRaises(c.MechanicalError) as error:tx.execute(app,{},mutation) + self.assertFalse(error.exception.details['recovery']['verified']) + + def test_partial_second_direction_refuses_before_pattern_creation(self): + f=importlib.import_module('mechanical_test_commands.mechanical.features') + for extra in ({'axis2':'y'},{'spacing2':2},{'count2':2}): + with patch.object(f,'component',return_value=NS(features=NS())): + with self.assertRaises(c.MechanicalError) as error:f.feature_create(None,{},dict(type='rectangular_pattern',**extra)) + self.assertEqual(error.exception.code,'invalid_input') + + def test_rectangular_pattern_disables_implicit_second_direction(self): + f=importlib.import_module('mechanical_test_commands.mechanical.features') + inp=NS(quantityTwo=3,isSymmetricInDirectionOne=True,isSymmetricInDirectionTwo=True) + comp=NS(features=NS(rectangularPatternFeatures=NS(createInput=lambda *a:inp,add=lambda inp:Entity()))) + request={'type':'rectangular_pattern','bodies':[{}],'axis':'x','count':3,'spacing':5} + with patch.object(f,'component',return_value=comp),patch.object(f,'selected',return_value=[object()]),patch.object(f,'collection',side_effect=lambda e:e),patch.object(f,'axis',return_value=object()),patch.object(f.adsk.fusion,'PatternDistanceType',NS(SpacingPatternDistanceType=0),create=True),patch.object(f,'quantity',side_effect=lambda design,value,unit='mm',positive=False:(value,unit)): + f.feature_create(None,{},request) + self.assertEqual(inp.quantityTwo,(1,''));self.assertFalse(inp.isSymmetricInDirectionOne);self.assertFalse(inp.isSymmetricInDirectionTwo) + + def test_symmetric_extrusion_passes_requested_taper(self): + f=importlib.import_module('mechanical_test_commands.mechanical.features');calls=[] + inp=NS(setSymmetricExtent=lambda *args:calls.append(args) or True) + comp=NS(features=NS(extrudeFeatures=NS(createInput=lambda *a:inp,add=lambda inp:Entity()))) + request={'type':'extrude','profiles':[{}],'distance':10,'direction':'symmetric','taper':5} + with patch.object(f,'component',return_value=comp),patch.object(f,'selected',return_value=[object()]),patch.object(f,'collection',side_effect=lambda e:e),patch.object(f,'operation',return_value='new_body'),patch.object(f,'quantity',side_effect=lambda design,value,unit='mm',positive=False:(value,unit)): + f.feature_create(None,{},request) + self.assertEqual(calls,[((10,'mm'),True,(5,'deg'))]) + +class Ledger(unittest.TestCase): + def setUp(self): isolated_history(self) + + def test_running_operation_status_does_not_wait_for_mutation(self): + entered=threading.Event();release=threading.Event();results=[] + request={'op':'parameter_set','sessionId':m.SESSION_ID,'operationId':'busy-status','target':{},'name':'width','expression':'20 mm'} + def mutation(*a):entered.set();release.wait(2);return {'done':True} + with patch.dict(m.OPERATIONS,parameter_set=mutation): + thread=threading.Thread(target=lambda:results.append(m.handle_mechanical(None,request)));thread.start() + self.assertTrue(entered.wait(1)) + state=m.operation_status(None,request);self.assertEqual(state['state'],'running') + release.set();thread.join(2) + self.assertEqual(m.operation_status(None,request)['state'],'succeeded') + self.assertEqual(m.operation_status(None,request)['result'],results[0]) + + def test_http_status_bypasses_held_main_thread_lock(self): + base=ModuleType('mechanical_busy_transport');base.__path__=[str(ROOT/'addin/AdomBridge')];sys.modules[base.__name__]=base + commands=ModuleType(base.__name__+'.commands');commands.__path__=[];sys.modules[commands.__name__]=commands + cloud=ModuleType(commands.__name__+'.cloud_documents');cloud.get_walk_progress=lambda:None;sys.modules[cloud.__name__]=cloud + sys.modules[commands.__name__+'.mechanical']=m + spec=importlib.util.spec_from_file_location(base.__name__+'.http_server',ROOT/'addin/AdomBridge/http_server.py') + server=importlib.util.module_from_spec(spec);spec.loader.exec_module(server) + fired=[];server._app=NS(fireCustomEvent=lambda *args:fired.append(args)) + m._history.admit({'op':'parameter_set','sessionId':m.SESSION_ID,'operationId':'held-lock'}, 'held-event') + server._main_thread_lock.acquire() + try: + raw=json.dumps({'command':'mech_operation_status','args':{'sessionId':m.SESSION_ID,'operationId':'held-lock'},'timeout':0.001}).encode() + replies=[];handler=object.__new__(server.AdomBridgeHandler);handler.path='/command';handler.headers=Message();handler.headers['Content-Length']=str(len(raw)) + handler._listener_context=server.ListenerContext(server._app,'test-event') + handler.connection=NS(settimeout=lambda seconds:None) + handler.rfile=io.BytesIO(raw);handler._respond=lambda status,data:replies.append(data) + handler.do_POST() + finally:server._main_thread_lock.release() + self.assertEqual(fired,[]);self.assertEqual(replies[0]['state'],'running') + +if __name__=='__main__':unittest.main() diff --git a/tests/test_operation_dispatch.py b/tests/test_operation_dispatch.py new file mode 100644 index 0000000..ecfabf6 --- /dev/null +++ b/tests/test_operation_dispatch.py @@ -0,0 +1,298 @@ +"""Real socket + journal + CustomEvent handler interleavings; native CAD is stubbed.""" +import concurrent.futures +import importlib.util +import json +import os +import sys +import threading +from types import SimpleNamespace as NS +import unittest +from unittest.mock import patch + +import test_http_server as transport +from test_mechanical import ROOT, m, isolated_history +from fusion_harness import CallResult, evaluate, observe as observe_reply + +REAL_OBSERVER = m.observed_document + + +class DurableDispatch(unittest.TestCase): + request = transport.HttpTransport.request + + def setUp(self): + transport.HttpTransport.setUp(self) + isolated_history(self) + sys.modules['transport_test_addin.commands.mechanical'] = m + sys.modules['transport_test_addin.http_server'] = self.m + commands = sys.modules['transport_test_addin.commands'] + commands.dispatch_command = lambda *args:self.fail('Durable event bypassed admission-aware dispatcher') + core = sys.modules['adsk.core'] + core.CustomEventHandler = object; core.CustomEventArgs = object + core.Application = NS(log=lambda message:None) + spec = importlib.util.spec_from_file_location('transport_test_addin.AdomBridge', ROOT / 'addin/AdomBridge/AdomBridge.py') + self.event = importlib.util.module_from_spec(spec); spec.loader.exec_module(self.event) + self.event._app = object() + self.native = [] + def execute(app, args): + state = m._history.status(args) + self.assertEqual(state['phase'], 'executing') + self.native.append(args) + return {'done':True} + change = patch.dict(m.OPERATIONS, parameter_set=execute); change.start(); self.addCleanup(change.stop) + self.handler = self.event.CommandEventHandler() + self.callback = lambda request:self.handler.notify(NS(additionalInfo=json.dumps(request))) + self.args = {'op':'parameter_set','sessionId':m.SESSION_ID,'operationId':'owned-operation', + 'target':{'documentId':'owned','revision':'r1'},'name':'Width','expression':'10 mm'} + + def tearDown(self): transport.HttpTransport.tearDown(self) + + def call(self, args=None, timeout=1): + return self.request({'command':'mechanical','args':self.args if args is None else args,'timeout':timeout}) + + def test_admission_precedes_native_observation_and_fast_completion_is_durable(self): + def observe(app): + row = m._history.store().lookup(m.SESSION_ID, self.args['operationId']) + self.assertEqual(row['phase'], 'dispatched') + self.assertEqual(row['requestedTarget'], self.args['target']) + return {'documentId':'owned', 'creationId':'advisory'} + with patch.object(m, 'observed_document', side_effect=observe): + status, result = self.call() + self.assertEqual(status, 200); self.assertTrue(result['success']) + self.assertTrue(result['durability']['terminalRecorded']) + history = m.operation_status(None, self.args)['history'] + self.assertEqual(history['observedDocument']['documentId'], 'owned') + retry = self.call({**self.args,'caller':{'sessionId':'relay'},'_callerBudgetMs':123})[1] + self.assertTrue(retry['replayed']); self.assertEqual(len(self.native), 1) + original_history=m.operation_status(None,self.args) + changed={**self.args,'expression':'20 mm'} + refused=self.call(changed)[1] + self.assertEqual(refused['errorCode'], 'operation_conflict') + self.assertEqual(refused['outcome'],'not_executed') + self.assertFalse(refused['nativeExecutionStarted']) + self.assertEqual(evaluate(refused,{'errorCode':'operation_conflict'}),[]) + receipt=CallResult(refused,observe_reply(refused),[{'verb':'fusion_mechanical','requestJson':json.dumps(changed)}],False,0,True) + self.assertFalse(receipt.ambiguous) + self.assertEqual(m.operation_status(None,self.args),original_history) + self.assertEqual(len(self.native), 1) + + def test_failed_admission_never_fires_event_or_observes_document(self): + with (patch.object(m._history.store(), 'admit', side_effect=m.JournalError('operation_storage_failed','disk full')), + patch.object(m, 'observed_document') as observed): + result = self.call()[1] + self.assertEqual(result['errorCode'], 'operation_storage_failed') + observed.assert_not_called(); self.assertEqual(self.received, []); self.assertEqual(self.native, []) + + def test_initialization_flush_failure_never_publishes_or_dispatches(self): + store = m._history.store() + with (patch.object(os, 'fsync', side_effect=OSError(9, 'Bad file descriptor')), + patch.object(m, 'observed_document') as observed): + status, result = self.call() + self.assertEqual(status, 200) + self.assertEqual(result['errorCode'], 'operation_storage_failed') + # The facade cannot generalize an admission exception to prove that + # this operation identity never executed on an earlier request. + self.assertEqual(result['outcome'], 'unknown') + self.assertIsNone(result['nativeExecutionStarted']) + observed.assert_not_called(); self.assertEqual(self.received, []); self.assertEqual(self.native, []) + self.assertFalse(store.path.exists()) + self.assertEqual(list(store.directory.iterdir()), []) + self.assertEqual(m.operation_status(None, self.args)['errorCode'], 'operation_unknown') + self.assertEqual(self.received, []) + + def test_false_without_callback_expires_and_exception_uses_delivery_failure(self): + for mode in ('false', 'exception'): + def rejected(request): + if mode == 'exception': raise RuntimeError('delivery fault') + return False + self.callback = rejected + args = {**self.args,'operationId':mode} + status, result = self.call(args,timeout=.01) + self.assertEqual(status, 200); self.assertFalse(result['success']) + self.assertFalse(result['nativeExecutionStarted']) + self.assertEqual(m.operation_status(None,args)['phase'], 'not_executed') + self.assertEqual(result['errorCode'],'request_wait_timeout' if mode == 'false' else 'event_delivery_failed') + self.assertEqual(self.native, []) + + def test_fast_completed_handler_wins_over_late_false_delivery_return(self): + def deliver_then_false(request): + self.handler.notify(NS(additionalInfo=json.dumps(request))) + return False + self.callback = deliver_then_false + status, result = self.call() + self.assertEqual(status, 200); self.assertTrue(result['success']) + self.assertEqual(m.operation_status(None,self.args)['phase'], 'succeeded') + self.assertEqual(len(self.native), 1) + + def test_false_return_retains_waiter_for_a_callback_delivered_later(self): + waiting = threading.Event();workers = [] + original_wait = self.m._wait_for_result + def delayed(request): + def callback(): + if not waiting.wait(1):raise RuntimeError('HTTP waiter never reached') + self.handler.notify(NS(additionalInfo=json.dumps(request))) + worker = threading.Thread(target=callback);workers.append(worker);worker.start() + return False + def wait(*args,**kwargs): + waiting.set() + return original_wait(*args,**kwargs) + self.callback = delayed + with patch.object(self.m,'_wait_for_result',side_effect=wait): + status,result = self.call() + for worker in workers:worker.join(1) + self.assertTrue(all(not worker.is_alive() for worker in workers)) + self.assertEqual(status,200);self.assertTrue(result['success']) + self.assertTrue(result['durability']['terminalRecorded']) + self.assertEqual(m.operation_status(None,self.args)['phase'],'succeeded') + self.assertEqual(len(self.native),1) + + def test_timeout_terminalizes_never_delivered_event_and_late_event_cannot_start(self): + self.check_undelivered_acknowledgement(True) + + def test_false_without_callback_timeout_prevents_late_native_execution(self): + self.check_undelivered_acknowledgement(False) + + def check_undelivered_acknowledgement(self,acknowledgement): + self.callback = lambda request:acknowledgement + result = self.call(timeout=.01)[1] + self.assertFalse(result['success']); self.assertEqual(self.native, []) + state = m.operation_status(None,self.args) + self.assertEqual(state['phase'], 'not_executed') + self.assertEqual(state['result']['errorCode'], 'request_wait_timeout') + self.assertFalse(state['result']['nativeExecutionStarted']) + self.handler.notify(NS(additionalInfo=json.dumps(self.received[0]))) + self.assertEqual(m.operation_status(None,self.args), state) + self.assertEqual(self.native, []) + + def test_timeout_wins_after_alive_check_before_execution_claim(self): + entered, release = threading.Event(), threading.Event() + workers = [] + def observe(app): + entered.set() + if not release.wait(1.5):raise RuntimeError('test release timeout') + return {'documentId':'owned', 'creationId':'advisory'} + def deliver(request): + worker = threading.Thread(target=self.handler.notify, args=(NS(additionalInfo=json.dumps(request)),)) + workers.append(worker); worker.start() + self.assertTrue(entered.wait(1)) + return True + self.callback = deliver + with patch.object(m, 'observed_document', side_effect=observe): + try: + result = self.call(timeout=.01)[1] + self.assertFalse(result['nativeExecutionStarted']) + self.assertEqual(m.operation_status(None,self.args)['phase'], 'not_executed') + finally: + release.set() + for worker in workers:worker.join(1) + self.assertTrue(all(not worker.is_alive() for worker in workers)) + self.assertEqual(self.native, []) + + def test_timeout_preserves_claimed_execution_and_later_terminal_result(self): + self.check_timeout_execution_race(complete_before_timeout_cas=False) + + def test_terminal_completion_wins_between_waiter_removal_and_timeout_cas(self): + self.check_timeout_execution_race(complete_before_timeout_cas=True) + + def test_false_return_with_claimed_execution_times_out_unknown_then_status_completes(self): + self.check_timeout_execution_race(complete_before_timeout_cas=False,acknowledgement=False) + + def check_timeout_execution_race(self, *, complete_before_timeout_cas, acknowledgement=True): + entered, release = threading.Event(), threading.Event() + workers = [] + def execute(app,args): + self.native.append(args); entered.set() + if not release.wait(1.5):raise RuntimeError('test release timeout') + return {'done':True} + def deliver(request): + worker = threading.Thread(target=self.handler.notify, args=(NS(additionalInfo=json.dumps(request)),)) + workers.append(worker); worker.start() + self.assertTrue(entered.wait(1)) + return acknowledgement + not_executed = m.not_executed + def timeout_cas(descriptor, result): + self.assertFalse(self.m.is_request_alive(descriptor['dispatchId'])) + if complete_before_timeout_cas: + release.set(); workers[0].join(1) + self.assertFalse(workers[0].is_alive()) + return not_executed(descriptor, result) + self.callback = deliver + with (patch.dict(m.OPERATIONS, parameter_set=execute), + patch.object(m, 'not_executed', side_effect=timeout_cas)): + try: + result = self.call(timeout=.01)[1] + if complete_before_timeout_cas: + self.assertTrue(result['success']) + self.assertTrue(result['durability']['terminalRecorded']) + else: + self.assertEqual(result['errorCode'], 'operation_in_progress') + self.assertNotEqual(result.get('nativeExecutionStarted'), False) + self.assertEqual(m.operation_status(None,self.args)['phase'], 'executing') + finally: + release.set() + for worker in workers:worker.join(1) + self.assertTrue(all(not worker.is_alive() for worker in workers)) + self.assertEqual(len(self.native), 1) + self.assertEqual(m.operation_status(None,self.args)['phase'], 'succeeded') + self.assertTrue(self.call()[1]['replayed']) + self.assertEqual(len(self.native), 1) + + def test_busy_lock_failure_records_no_start_without_custom_event(self): + self.m._main_thread_lock.acquire() + try:result = self.call(timeout=.01)[1] + finally:self.m._main_thread_lock.release() + self.assertEqual(result['errorCode'], 'main_thread_busy') + self.assertEqual(m.operation_status(None,self.args)['phase'], 'not_executed') + self.assertEqual(self.received, []); self.assertEqual(self.native, []) + + def test_concurrent_duplicate_and_status_never_wait_for_native_lock(self): + entered, release = threading.Event(), threading.Event() + def execute(app,args): + self.native.append(args); entered.set() + if not release.wait(1.5):raise RuntimeError('test release timeout') + return {'done':True} + with patch.dict(m.OPERATIONS, parameter_set=execute), concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + first = pool.submit(self.call) + try: + self.assertTrue(entered.wait(1)) + duplicate = self.call()[1] + self.assertEqual(duplicate['errorCode'], 'operation_in_progress') + state = self.request({'command':'mech_operation_status','args':self.args})[1] + self.assertEqual(state['phase'], 'executing') + finally:release.set() + self.assertTrue(first.result()[1]['success']) + self.assertEqual(len(self.native), 1) + + def test_terminal_persistence_failure_returns_unknown_and_never_reexecutes(self): + transition = m._history.store().transition + def fail_terminal(*args, **kwargs): + if args[3] in ('succeeded','failed'):raise m.JournalError('operation_storage_failed','terminal commit fault') + return transition(*args, **kwargs) + with patch.object(m._history.store(), 'transition', side_effect=fail_terminal): + result = self.call()[1] + self.assertEqual(result['errorCode'], 'operation_reconciliation_required') + self.assertTrue(result['observedResult']['success']); self.assertFalse(result['rollbackClaimed']) + self.assertEqual(self.call()[1]['errorCode'], 'operation_reconciliation_required') + self.assertEqual(len(self.native), 1) + + def test_invalid_stale_dispatch_identity_is_refused_before_document_observation(self): + args, descriptor, reply = m.admit_for_dispatch(self.args, 'event-A') + self.assertIsNone(reply) + descriptor['sessionId'] = 'obsolete-session' + with patch.object(m, 'observed_document') as observe: + result = m.handle_mechanical(None, args, admission=descriptor) + self.assertFalse(result['success']); observe.assert_not_called(); self.assertEqual(self.native, []) + self.assertEqual(m.operation_status(None,args)['phase'], 'dispatched') + + def test_real_document_observer_refuses_worker_thread_before_api_access(self): + class App: + @property + def activeDocument(self):raise AssertionError('Fusion document accessed off main thread') + errors=[] + def run(): + try:REAL_OBSERVER(App()) + except Exception as error:errors.append(error) + worker=threading.Thread(target=run);worker.start();worker.join(1) + self.assertEqual(len(errors),1);self.assertEqual(errors[0].code,'wrong_thread') + + +if __name__ == '__main__': unittest.main() diff --git a/tests/test_operation_history.py b/tests/test_operation_history.py new file mode 100644 index 0000000..508e519 --- /dev/null +++ b/tests/test_operation_history.py @@ -0,0 +1,226 @@ +"""Process-local reconciliation evidence layered over actual temporary SQLite.""" +import copy +import importlib +from pathlib import Path +import sys +import tempfile +from types import ModuleType +import unittest +from unittest.mock import patch + +PACKAGE = ModuleType('history_test_mechanical') +PACKAGE.__path__ = [str(Path(__file__).resolve().parents[1] / 'addin/AdomBridge/commands/mechanical')] +sys.modules[PACKAGE.__name__] = PACKAGE +h = importlib.import_module(PACKAGE.__name__ + '.operation_history') +j = importlib.import_module(PACKAGE.__name__ + '.journal') + + +class DurableHistory(unittest.TestCase): + def setUp(self): + folder = tempfile.TemporaryDirectory(); self.addCleanup(folder.cleanup) + self.store = j.Journal(Path(folder.name) / 'private') + self.history = h.OperationHistory('session-A', 'dev.8', self.store) + self.request = {'op':'parameter_set','sessionId':'session-A','operationId':'one', + 'target':{'documentId':'exact','revision':'r1'},'name':'Width','expression':'10 mm'} + self.fault = j.JournalError('operation_storage_failed', 'injected disk/commit failure') + + def admit(self): + descriptor, error = self.history.admit(self.request, 'generation-A') + self.assertIsNone(error) + return descriptor + + def test_dispatch_commit_failure_status_retains_known_no_start_without_durable_terminal_claim(self): + with patch.object(self.store, 'transition', side_effect=self.fault): + descriptor, error = self.history.admit(self.request, 'generation-A') + self.assertIsNone(descriptor); self.assertFalse(error['nativeExecutionStarted']) + self.assertFalse(error['durability']['terminalRecorded']) + state = self.history.status(self.request) + self.assertEqual(state['phase'], 'admitted') + self.assertEqual(state['state'], 'reconciliation_required') + self.assertEqual(state['outcome'], 'not_executed') + self.assertTrue(state['reconciliationRequired']) + self.assertEqual(self.history.admit(self.request, 'duplicate')[1], error) + restarted = h.OperationHistory('session-B', 'dev.next', j.Journal(self.store.directory)) + old = restarted.status(self.request) + self.assertEqual(old['state'], 'interrupted'); self.assertEqual(old['outcome'], 'unknown') + self.assertIsNone(old['nativeExecutionStarted']) + + def test_duplicate_admission_storage_fault_preserves_running_or_observed_execution(self): + descriptor = self.admit(); self.history.start(descriptor, self.request, {'documentId':'exact'}) + with patch.object(self.store, 'admit', side_effect=self.fault): + running = self.history.admit(self.request, 'duplicate')[1] + self.assertEqual(running['outcome'], 'unknown'); self.assertIsNone(running['nativeExecutionStarted']) + observed = {'success':True, 'data':{'done':True}} + with patch.object(self.store, 'transition', side_effect=self.fault):self.history.finish(descriptor, observed) + with patch.object(self.store, 'admit', side_effect=self.fault): + repeated = self.history.admit(self.request, 'duplicate')[1] + self.assertEqual(repeated['observedResult'], observed); self.assertTrue(repeated['nativeExecutionStarted']) + with patch.object(self.store, 'lookup', side_effect=self.fault): + self.assertEqual(self.history.status(self.request)['observedResult'], observed) + with patch.object(self.store, 'admit', side_effect=self.fault): + self.history.admit({**self.request,'expression':'different'}, 'conflict') + self.assertEqual(self.history.status(self.request)['observedResult'], observed) + + def test_conflicting_submitted_digest_is_rejected_without_changing_original_execution(self): + descriptor = self.admit() + self.history.start(descriptor, self.request, {'documentId':'exact'}) + changed = {**self.request,'expression':'20 mm'} + for phase in ('executing','undurable','terminal'): + if phase == 'undurable': + with patch.object(self.store,'transition',side_effect=self.fault): + self.history.finish(descriptor,{'success':True,'data':{'done':True}}) + elif phase == 'terminal': + self.history.finish(descriptor,{'success':True,'data':{'done':True}}) + before = self.history.status(self.request) + cache = copy.deepcopy(self.history._unconfirmed) + admitted, reply = self.history.admit(changed,'conflicting-generation') + with self.subTest(phase=phase): + self.assertIsNone(admitted) + self.assertEqual(reply['errorCode'],'operation_conflict') + self.assertEqual(reply['outcome'],'not_executed') + self.assertFalse(reply['nativeExecutionStarted']) + self.assertEqual(reply['requestDigest'],j.fingerprint(changed)) + self.assertEqual(reply['conflict'],{'scope':'submitted_request','existingRequestDigest':j.fingerprint(self.request)}) + self.assertNotIn('durability',reply) + self.assertNotIn('observedResult',reply) + self.assertEqual(self.history.status(self.request),before) + self.assertEqual(self.history._unconfirmed,cache) + + def test_unproven_conflict_exception_does_not_assert_no_execution(self): + descriptor=self.admit();self.history.start(descriptor,self.request,{'documentId':'exact'}) + changed={**self.request,'expression':'20 mm'} + for error in (j.JournalError('operation_conflict','Unproven claim failure'), + j.AdmissionConflict('wrong-session','one',j.fingerprint(changed),j.fingerprint(self.request))): + with patch.object(self.store,'admit',side_effect=error): + reply=self.history.admit(changed,'conflict')[1] + self.assertEqual(reply['outcome'],'unknown') + self.assertIsNone(reply['nativeExecutionStarted']) + self.assertNotIn('conflict',reply) + + def test_lost_admission_acknowledgement_requires_reconciliation_without_redispatch(self): + admit = self.store.admit + def commit_then_fail(*args, **kwargs):admit(*args, **kwargs);raise self.fault + with patch.object(self.store, 'admit', side_effect=commit_then_fail): + descriptor, failed = self.history.admit(self.request, 'generation-A') + self.assertIsNone(descriptor); self.assertEqual(failed['outcome'], 'unknown') + state = self.history.status(self.request) + self.assertEqual(state['phase'], 'admitted'); self.assertEqual(state['state'], 'reconciliation_required') + self.assertTrue(state['reconciliationRequired']) + self.assertTrue(self.history.admit(self.request, 'duplicate')[1]['reconciliationRequired']) + # Even if process-local acknowledgement evidence is gone, an admission + # without a dispatch claim must not pretend a native worker is running. + same_session_reader = h.OperationHistory('session-A', 'dev.8', self.store) + self.assertEqual(same_session_reader.status(self.request)['state'], 'reconciliation_required') + + def test_random_outage_ids_never_allocate_unbounded_failure_metadata(self): + with patch.object(self.store, 'admit', side_effect=self.fault), patch.object(self.store, 'lookup', side_effect=self.fault): + for index in range(300): + reply = self.history.admit({**self.request,'operationId':'outage-'+str(index)}, 'event')[1] + self.assertEqual(reply['outcome'], 'unknown'); self.assertIsNone(reply['nativeExecutionStarted']) + self.assertEqual(self.history._known_requests, {}); self.assertEqual(self.history._unconfirmed, {}) + + def test_start_commit_failure_never_reports_running_forever(self): + descriptor = self.admit() + with patch.object(self.store, 'transition', side_effect=self.fault): + error = self.history.start(descriptor, self.request, {'documentId':'exact'}) + self.assertIsNone(error['nativeExecutionStarted']) + state = self.history.status(self.request) + self.assertEqual(state['phase'], 'dispatched') + self.assertEqual(state['state'], 'reconciliation_required') + self.assertEqual(state['outcome'], 'unknown') + + def test_delivery_failure_racing_started_handler_stays_unknown_if_persistence_fails(self): + descriptor = self.admit(); self.history.start(descriptor, self.request, {'documentId':'exact'}) + with patch.object(self.store, 'transition', side_effect=self.fault): + error = self.history.not_executed(descriptor, {'success':False,'errorCode':'event_delivery_failed'}) + self.assertEqual(error['outcome'], 'unknown'); self.assertIsNone(error['nativeExecutionStarted']) + state = self.history.status(self.request) + self.assertEqual(state['phase'], 'executing'); self.assertEqual(state['outcome'], 'unknown') + self.assertTrue(state['reconciliationRequired']) + self.history.finish(descriptor, {'success':True,'data':{'done':True}}) + self.assertEqual(self.history.status(self.request)['state'], 'succeeded') + self.assertIsNone(self.history.status(self.request)['persistenceFailure']) + + def test_terminal_persistence_failure_preserves_observed_native_result_without_rollback_claim(self): + descriptor = self.admit(); self.history.start(descriptor, self.request, {'documentId':'exact'}) + observed = {'success':True,'data':{'target':{'documentId':'exact','revision':'r2'}}} + with patch.object(self.store, 'transition', side_effect=self.fault): error = self.history.finish(descriptor, observed) + self.assertFalse(error['success']); self.assertEqual(error['errorCode'], 'operation_reconciliation_required') + self.assertFalse(error['rollbackClaimed']); self.assertEqual(error['observedResult'], observed) + state = self.history.status(self.request) + self.assertEqual(state['phase'], 'executing'); self.assertEqual(state['observedResult'], observed) + self.assertEqual(self.history.admit(self.request, 'duplicate')[1], error) + + def test_duplicate_start_or_delivery_fault_cannot_erase_observed_execution(self): + descriptor = self.admit(); self.history.start(descriptor, self.request, {'documentId':'exact'}) + observed = {'success':True,'data':{'target':{'documentId':'exact','revision':'r2'}}} + with patch.object(self.store, 'transition', side_effect=self.fault): + first = self.history.finish(descriptor, observed) + duplicate = self.history.start(descriptor, self.request, {'documentId':'exact'}) + delivery = self.history.not_executed(descriptor, {'success':False,'errorCode':'event_delivery_failed'}) + for result in (first, duplicate, delivery, self.history.status(self.request)['persistenceFailure']): + self.assertEqual(result['outcome'], 'unknown') + self.assertTrue(result['nativeExecutionStarted']) + self.assertEqual(result['observedResult'], observed) + self.assertEqual(self.history.status(self.request)['phase'], 'executing') + + def test_commit_succeeded_but_acknowledgement_lost_is_reconciled_from_disk(self): + descriptor = self.admit(); self.history.start(descriptor, self.request, {'documentId':'exact'}) + transition = self.store.transition + def commit_then_raise(*args, **kwargs): transition(*args, **kwargs); raise self.fault + with patch.object(self.store, 'transition', side_effect=commit_then_raise): + immediate = self.history.finish(descriptor, {'success':True,'data':{'done':True}}) + self.assertFalse(immediate['success']) + self.assertEqual(self.history.status(self.request)['state'], 'succeeded') + self.assertTrue(self.history.admit(self.request, 'duplicate')[1]['replayed']) + + def test_old_session_history_is_queryable_but_never_readmitted(self): + descriptor = self.admit(); self.history.start(descriptor, self.request, {'documentId':'exact'}) + self.history.finish(descriptor, {'success':False,'errorCode':'wrong_document'}) + next_session = h.OperationHistory('session-B', 'dev.next', j.Journal(self.store.directory)) + state = next_session.status(self.request) + self.assertTrue(state['historical']); self.assertEqual(state['state'], 'failed') + self.assertEqual(state['history']['sourceVersion'], 'dev.8') + with patch.object(next_session.store(), 'admit') as admit: + self.assertEqual(next_session.admit(self.request, 'new-event')[1]['errorCode'], 'session_mismatch') + admit.assert_not_called() + + def test_old_event_cannot_mark_previous_session_history_not_executed(self): + descriptor = self.admit() + next_session = h.OperationHistory('session-B', 'dev.next', self.store) + before = self.store.lookup('session-A', 'one') + reply = next_session.not_executed(descriptor, {'success':False, 'errorCode':'waiter_gone'}) + self.assertEqual(reply['errorCode'], 'session_mismatch') + self.assertEqual(self.store.lookup('session-A', 'one'), before) + + def test_lost_waiter_cannot_overwrite_started_or_terminal_work(self): + descriptor = self.admit(); self.history.start(descriptor, self.request, {'documentId':'exact'}) + refused = self.history.not_executed(descriptor, {'success':False,'errorCode':'waiter_gone'}) + self.assertEqual(refused['errorCode'], 'operation_in_progress') + original = self.history.finish(descriptor, {'success':True,'data':{'done':True}}) + later = self.history.not_executed(descriptor, {'success':False,'errorCode':'waiter_gone'}) + self.assertTrue(later['success']); self.assertEqual(later['data'], original['data']) + + def test_confirmed_skip_and_generation_conflict_have_no_native_execution_claim(self): + descriptor = self.admit() + skipped = self.history.not_executed(descriptor, {'success':False,'errorCode':'waiter_gone'}) + self.assertFalse(skipped['nativeExecutionStarted']); self.assertEqual(skipped['durability']['phase'], 'not_executed') + self.assertEqual(self.history.status(self.request)['state'], 'failed') + late = self.history.start(descriptor, self.request, {'documentId':'exact'}) + self.assertEqual(late['errorCode'], 'waiter_gone') + other = {**descriptor,'dispatchId':'obsolete'} + self.assertIsNotNone(self.history.start(other, self.request, {'documentId':'exact'})) + + def test_changed_canonical_request_is_refused_before_native_claim(self): + descriptor = self.admit() + self.assertEqual(self.history.start(descriptor, {**self.request,'expression':'99 mm'}, {})['errorCode'], 'operation_conflict') + self.assertEqual(self.store.lookup('session-A','one')['phase'], 'dispatched') + + def test_large_or_non_json_observation_cannot_break_failure_serialization(self): + for observed in ({'success':True,'data':'x'*70000}, {'success':True,'data':object()}): + error = self.history.failure(self.fault, {'sessionId':'session-A','operationId':'one'}, observed=observed) + j.canonical(error) + self.assertTrue(error['observation']['resultOmitted']); self.assertNotIn('observedResult', error) + + +if __name__ == '__main__': unittest.main() diff --git a/tests/test_operation_journal.py b/tests/test_operation_journal.py new file mode 100644 index 0000000..7fe2f88 --- /dev/null +++ b/tests/test_operation_journal.py @@ -0,0 +1,217 @@ +"""Durable record boundaries only; no native dispatch or host filesystem access.""" +import concurrent.futures +import importlib.util +import json +import os +from pathlib import Path +import sqlite3 +import subprocess +import sys +import tempfile +import threading +import unittest +from unittest.mock import patch + +SPEC = importlib.util.spec_from_file_location('operation_journal', Path(__file__).resolve().parents[1] / 'addin/AdomBridge/commands/mechanical/journal.py') +j = importlib.util.module_from_spec(SPEC); SPEC.loader.exec_module(j) + + +class DurableJournal(unittest.TestCase): + def setUp(self): + self.folder = tempfile.TemporaryDirectory(); self.addCleanup(self.folder.cleanup) + self.directory = Path(self.folder.name) / 'app-owned' + self.journal = j.Journal(self.directory) + self.request = {'op': 'parameter_set', 'sessionId': 'session-A', 'operationId': 'operation-A', + 'target': {'documentId': 'exact-document', 'revision': 'r1'}, 'name': 'Width', 'expression': '10 mm'} + + def admit(self): return self.journal.admit(self.request, '1.12.0-dev.8')['record'] + + def phase(self, name, **kwargs): + return self.journal.transition('session-A', 'operation-A', j.fingerprint(self.request), name, dispatch_id='generation-A', **kwargs) + + def test_lookup_missing_history_has_no_filesystem_side_effect(self): + self.assertIsNone(self.journal.lookup('missing', 'missing')) + self.assertFalse(self.directory.exists()) + + def test_initialization_flush_uses_writable_descriptor_before_publication(self): + real_open, real_fsync = Path.open, os.fsync + streams, flushed = {}, [] + def track_open(path, *args, **kwargs): + stream = real_open(path, *args, **kwargs) + if path.parent == self.directory and path.name.startswith('.operations-v1-'): + streams[stream.fileno()] = stream + return stream + def writable_fsync(descriptor): + stream = streams.get(descriptor) + if stream is not None and not stream.closed: + # Model Windows _commit rejecting a read-only file descriptor. + if not stream.writable(): + raise OSError(9, 'Bad file descriptor') + self.assertFalse(self.journal.path.exists()) + with sqlite3.connect(stream.name) as database: + self.assertEqual(database.execute('PRAGMA application_id').fetchone()[0], j.APPLICATION_ID) + self.assertEqual(database.execute('SELECT COUNT(*) FROM operations').fetchone()[0], 0) + flushed.append(descriptor) + return real_fsync(descriptor) + with patch.object(Path, 'open', track_open), patch.object(j.os, 'fsync', side_effect=writable_fsync): + record = self.admit() + self.assertEqual(len(flushed), 1) + self.assertEqual(j.Journal(self.directory).lookup('session-A', 'operation-A'), record) + self.assertEqual(list(self.directory.glob('.operations-v1-*.tmp')), []) + + def test_complete_record_survives_new_journal_instance_with_exact_identity_and_result(self): + first = self.admit(); self.phase('dispatched') + self.phase('executing', document={'documentId': 'exact-document', 'creationId': 'not-an-identity'}) + result = {'success': True, 'data': {'target': {'documentId': 'exact-document', 'revision': 'r2'}}} + self.phase('succeeded', result=result) + found = j.Journal(self.directory).lookup('session-A', 'operation-A') + self.assertEqual(found['result'], result) + self.assertEqual(found['requestDigest'], first['requestDigest']) + self.assertEqual(found['observedDocument']['documentId'], 'exact-document') + self.assertEqual([entry['phase'] for entry in found['events']], ['admitted', 'dispatched', 'executing', 'succeeded']) + + def test_duplicate_admission_and_argument_conflict_are_atomic_across_instances(self): + def admit(_): return j.Journal(self.directory).admit(self.request, '1.12.0-dev.8') + with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool: results = list(pool.map(admit, range(16))) + self.assertEqual(sum(result['created'] for result in results), 1) + with self.assertRaises(j.JournalError) as error: + self.journal.admit({**self.request, 'expression': '20 mm'}, '1.12.0-dev.8') + self.assertEqual(error.exception.code, 'operation_conflict') + + def test_published_initial_database_is_usable_before_staging_link_cleanup(self): + published, release = threading.Event(), threading.Event() + original = os.link + def pause_after_publication(source, destination): + original(source, destination); published.set() + if not release.wait(3): raise RuntimeError('test publication wait expired') + with patch.object(j.os, 'link', side_effect=pause_after_publication), concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit(self.admit) + try: + self.assertTrue(published.wait(1)) + other = j.Journal(self.directory) + self.assertIsNone(other.lookup('session-A', 'operation-A')) + admitted = other.admit(self.request, 'dev') + self.assertTrue(admitted['created']) + self.assertEqual(other.lookup('session-A', 'operation-A')['phase'], 'admitted') + finally: + release.set() + self.assertEqual(future.result()['phase'], 'admitted') + self.assertEqual(self.journal.path.stat().st_nlink, 1) + + def test_external_hardlink_is_refused_even_when_database_is_valid(self): + self.admit(); outside = Path(self.folder.name) / 'foreign-link.sqlite' + os.link(self.journal.path, outside) + with self.assertRaises(j.JournalError) as error: self.journal.lookup('session-A', 'operation-A') + self.assertEqual(error.exception.code, 'operation_storage_invalid_path') + + def test_only_one_generation_claims_dispatch_and_execution(self): + self.admit() + self.assertTrue(self.phase('dispatched')['claimed']) + self.assertFalse(self.phase('dispatched')['claimed']) + with self.assertRaises(j.JournalError): + self.journal.transition('session-A', 'operation-A', j.fingerprint(self.request), 'executing', dispatch_id='old-generation') + self.assertTrue(self.phase('executing')['claimed']) + self.assertFalse(self.phase('executing')['claimed']) + + def test_caller_mutation_cannot_rewrite_admitted_target_or_digest(self): + first = self.admit(); original = first['requestDigest'] + self.request['target']['documentId'] = 'another-document' + found = self.journal.lookup('session-A', 'operation-A') + self.assertEqual(found['requestedTarget']['documentId'], 'exact-document') + self.assertEqual(found['requestDigest'], original) + self.assertNotEqual(found['requestDigest'], j.fingerprint(self.request)) + + def test_each_crash_boundary_remains_nonterminal_without_automatic_progress(self): + self.admit() + for phase in ('admitted', 'dispatched', 'executing'): + if phase != 'admitted': self.phase(phase) + fresh = j.Journal(self.directory) + record = fresh.lookup('session-A', 'operation-A') + self.assertEqual(record['phase'], phase); self.assertIsNone(record['result']) + self.assertFalse(fresh.admit(self.request, 'new-version')['created']) + self.assertEqual(fresh.lookup('session-A', 'operation-A')['phase'], phase) + + def test_terminal_record_size_failure_keeps_executing_evidence(self): + self.admit(); self.phase('dispatched'); self.phase('executing') + self.journal.max_record_bytes = 1200 + with self.assertRaises(j.JournalError) as error: + self.phase('succeeded', result={'success': True, 'data': 'x' * 2000}) + self.assertEqual(error.exception.code, 'operation_record_too_large') + self.assertEqual(j.Journal(self.directory).lookup('session-A', 'operation-A')['phase'], 'executing') + + def test_first_lookup_recovers_hot_rollback_journal_after_real_process_exit(self): + self.admit(); self.phase('dispatched'); self.phase('executing') + before = self.journal.lookup('session-A', 'operation-A') + program = """ +import os, sqlite3, sys +connection = sqlite3.connect(sys.argv[1], isolation_level=None) +connection.execute('PRAGMA synchronous=FULL') +connection.execute('PRAGMA cache_size=1') +connection.execute('BEGIN IMMEDIATE') +connection.execute('UPDATE operations SET record_json=?', (b'X' * 1048576,)) +os._exit(77) +""" + child = subprocess.run([sys.executable, '-c', program, str(self.journal.path)], timeout=10) + self.assertEqual(child.returncode, 77) + hot = Path(str(self.journal.path) + '-journal') + self.assertTrue(hot.exists()); self.assertGreater(hot.stat().st_size, 0) + recovered = j.Journal(self.directory).lookup('session-A', 'operation-A') + self.assertEqual(recovered, before) + self.assertEqual(recovered['phase'], 'executing') + + def test_commit_failure_never_returns_claimed_and_preserves_prior_phase(self): + self.admit() + real_connect = self.journal._connect + class FailedCommit: + def __init__(self, connection): self.connection = connection + def execute(self, sql, *args): + if sql == 'COMMIT': raise sqlite3.OperationalError('disk full at commit') + return self.connection.execute(sql, *args) + def close(self): self.connection.close() + with patch.object(self.journal, '_connect', side_effect=lambda create: FailedCommit(real_connect(create))): + with self.assertRaises(j.JournalError): self.phase('dispatched') + self.assertEqual(self.journal.lookup('session-A', 'operation-A')['phase'], 'admitted') + + def test_capacity_never_evicts_old_entries_or_rejects_valid_lookup(self): + self.journal.max_records = 1; self.admit() + with self.assertRaises(j.JournalError) as error: + self.journal.admit({**self.request, 'operationId': 'new'}, 'dev') + self.assertEqual(error.exception.code, 'operation_capacity') + self.assertEqual(self.journal.lookup('session-A', 'operation-A')['phase'], 'admitted') + self.assertFalse(self.journal.admit(self.request, 'dev')['created']) + + def test_corrupted_record_blocks_lookup_and_unrelated_new_admission(self): + self.admit() + with sqlite3.connect(self.journal.path) as db: db.execute("UPDATE operations SET record_json=x'7b'") + for action in (lambda: self.journal.lookup('session-A', 'operation-A'), + lambda: self.journal.admit({**self.request, 'operationId': 'other'}, 'dev')): + with self.assertRaises(j.JournalError) as error: action() + self.assertEqual(error.exception.code, 'operation_storage_corrupt') + + def test_wrong_storage_version_is_not_migrated_or_reset(self): + self.admit() + with sqlite3.connect(self.journal.path) as db: db.execute('PRAGMA user_version=999') + with self.assertRaises(j.JournalError) as error: self.journal.lookup('session-A', 'operation-A') + self.assertEqual(error.exception.code, 'operation_storage_version') + with sqlite3.connect(self.journal.path) as db: self.assertEqual(db.execute('PRAGMA user_version').fetchone()[0], 999) + + def test_invalid_paths_and_linked_database_refuse(self): + with self.assertRaises(j.JournalError): j.Journal(Path('relative')).admit(self.request, 'dev') + real = Path(self.folder.name) / 'real'; real.mkdir() + self.directory.symlink_to(real, target_is_directory=True) + with self.assertRaises(j.JournalError): self.admit() + self.directory.unlink(); self.directory.mkdir() + destination = real / 'existing'; destination.write_bytes(b'unchanged') + self.journal.path.symlink_to(destination) + with self.assertRaises(j.JournalError): self.admit() + self.assertEqual(destination.read_bytes(), b'unchanged') + + def test_false_success_and_out_of_order_terminal_state_are_rejected(self): + self.admit() + self.assertFalse(self.phase('succeeded', result={'success': True})['claimed']) + self.phase('dispatched'); self.phase('executing') + with self.assertRaises(j.JournalError): self.phase('succeeded', result={'success': False, 'error': 'failed'}) + self.assertEqual(self.journal.lookup('session-A', 'operation-A')['phase'], 'executing') + + +if __name__ == '__main__': unittest.main() diff --git a/tests/test_operation_status_route.py b/tests/test_operation_status_route.py new file mode 100644 index 0000000..01e9773 --- /dev/null +++ b/tests/test_operation_status_route.py @@ -0,0 +1,99 @@ +"""Observational legacy-route regressions; actual loopback server, no host APIs.""" +import ast +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +from pathlib import Path +import socket +import threading +import time +import unittest +import urllib.error +import urllib.request +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] + + +class StatusRoute(unittest.TestCase): + def setUp(self): + self.requests=[]; self.status=200; self.trickle=False + self.reply={'success':True,'sessionId':'s','operationId':'op','state':'running','phase':'executing'} + case=self + class Handler(BaseHTTPRequestHandler): + def log_message(self,*args):pass + def do_POST(self): + case.requests.append((self.path,json.loads(self.rfile.read(int(self.headers['Content-Length']))))) + body=case.reply if isinstance(case.reply,bytes) else json.dumps(case.reply).encode() + self.send_response(case.status) + if case.status==302:self.send_header('Location','http://127.0.0.1:1/forbidden-redirect') + self.end_headers() + try: + if case.trickle: + for byte in body: + self.wfile.write(bytes([byte]));self.wfile.flush();time.sleep(.01) + else:self.wfile.write(body) + except (BrokenPipeError,ConnectionResetError):pass + self.server=ThreadingHTTPServer(('127.0.0.1',0),Handler) + self.thread=threading.Thread(target=self.server.serve_forever,kwargs={'poll_interval':.01});self.thread.start() + source=ast.parse((ROOT/'server.py').read_text()) + names={'_read_operation_history','dispatch_command','_dispatch_command_inner'} + functions=[node for node in source.body if isinstance(node,ast.FunctionDef) and node.name in names] + self.env={'json':json,'urllib':urllib,'ADDIN_PORT':self.server.server_address[1]} + exec(compile(ast.Module(body=functions,type_ignores=[]),'server-route','exec'),self.env) + self.args={'sessionId':'s','operationId':'op'} + + def tearDown(self): + self.server.shutdown();self.server.server_close();self.thread.join(1) + self.assertFalse(self.thread.is_alive()) + + def test_public_and_internal_aliases_bypass_all_lifecycle_and_busy_functions(self): + def forbidden(*args,**kwargs):self.fail('Observational status reached lifecycle/etiquette code') + for name in ('_set_caller_identity','_note_driving_activity','_assert_active_document','_window_etiquette_pre', + '_window_etiquette_post','_snapshot_open_docs','_time_and_indicate','_addin_preamble', + '_get_long_command','_proxy_to_addin','_diagnose_addin_timeout'): + self.env[name]=forbidden + for name in ('dispatch_command','_dispatch_command_inner'): + for verb in ('fusion_mech_operation_status','mech_operation_status'): + result=self.env[name](verb,{**self.args,'foreground':True,'expectDocument':'ignored transport metadata'}, {'thread':'test'}) + self.assertEqual(result,self.reply) + self.assertEqual(len(self.requests),4) + self.assertTrue(all(path=='/command' and body['command']=='mech_operation_status' for path,body in self.requests)) + + def test_cold_endpoint_refuses_without_retry_or_repair(self): + sock=socket.socket();sock.bind(('127.0.0.1',0));port=sock.getsockname()[1];sock.close() + self.env['ADDIN_PORT']=port + result=self.env['dispatch_command']('fusion_mech_operation_status',self.args) + self.assertEqual(result['errorCode'],'operation_status_unavailable') + self.assertEqual(result['outcome'],'unknown');self.assertFalse(result['lifecycleActionTaken']) + self.assertEqual(self.requests,[]) + + def test_transport_timeout_does_not_diagnose_or_retry(self): + with patch.object(socket,'create_connection',side_effect=TimeoutError('timed out')) as connect: + result=self.env['dispatch_command']('mech_operation_status',self.args) + self.assertEqual(connect.call_count,1);self.assertEqual(result['errorCode'],'operation_status_unavailable') + self.assertFalse(result['lifecycleActionTaken']) + + def test_trickled_reply_cannot_extend_total_deadline(self): + self.trickle=True;before=time.monotonic() + result=self.env['_read_operation_history'](self.args,_timeout=.05) + self.assertLess(time.monotonic()-before,.4) + self.assertEqual(result['errorCode'],'operation_status_unavailable') + self.assertEqual(len(self.requests),1) + + def test_native_failure_is_preserved_and_bad_transport_is_unknown(self): + self.reply={'success':False,'errorCode':'operation_unknown','outcome':'unknown'} + self.assertEqual(self.env['dispatch_command']('mech_operation_status',self.args),self.reply) + for status,body in ((200,b'not json'),(200,[]),(503,{'success':True}), (302,b'redirect')): + self.status,self.reply=status,body + result=self.env['dispatch_command']('mech_operation_status',self.args) + self.assertEqual(result['errorCode'],'operation_status_unavailable') + self.assertFalse(result['lifecycleActionTaken']) + + def test_other_commands_keep_the_existing_dispatch_path(self): + with patch.dict(self.env,{'_time_and_indicate':lambda *args: {'ordinary':True}, + '_WINDOW_RAISING':set(),'_window_etiquette_post':lambda cmd,args,result,*rest:result}): + self.assertEqual(self.env['dispatch_command']('fusion_mechanical',{}),{'ordinary':True}) + self.assertEqual(self.requests,[]) + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_portable_tree.py b/tests/test_portable_tree.py new file mode 100644 index 0000000..33d2ec6 --- /dev/null +++ b/tests/test_portable_tree.py @@ -0,0 +1,39 @@ +import importlib.util +from pathlib import Path +import unittest + +SPEC = importlib.util.spec_from_file_location('portable_tree', Path(__file__).resolve().parents[1] / 'tools/check_portable_tree.py') +tree = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(tree) + + +class DependencyGateTests(unittest.TestCase): + def test_existing_portable_tls_path_is_reported(self): + failures, exceptions = tree.inspect('0fusion-core v1\n1ureq v2\n2rustls v3\n3ring v4\n4windows-sys v5\n5windows-targets v6\n6windows_x86_64_gnu v6') + self.assertFalse(failures) + self.assertEqual(len(exceptions), 3) + + def test_binding_also_reached_elsewhere_is_rejected(self): + text = '0fusion-core v1\n1ureq v2\n2rustls v3\n3ring v4\n4windows-sys v5\n1windows-sys v5' + failures, exceptions = tree.inspect(text) + self.assertEqual(failures, ['fusion-core -> windows-sys']) + self.assertEqual(len(exceptions), 1) + + def test_unreviewed_transitive_binding_is_rejected(self): + for package in ('windows', 'windows-sys', 'winapi', 'cocoa', 'objc2', 'core-foundation-sys'): + with self.subTest(package=package): + failures, _ = tree.inspect('0fusion-core v1\n1portable-looking v1\n2' + package + ' v1') + self.assertTrue(failures) + + def test_nonbinding_added_beneath_ring_does_not_grant_blanket_exception(self): + failures, _ = tree.inspect('0fusion-core v1\n1ureq v2\n2rustls v3\n3ring v4\n4new-wrapper v1\n5windows-sys v5') + self.assertTrue(failures) + + def test_missing_or_malformed_tree_fails(self): + for text in ('', 'cargo error', '1fusion-core v1', '0other v1', '0fusion-core v1\n3serde v1'): + with self.subTest(text=text), self.assertRaises(ValueError): + tree.inspect(text) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_ralph_assemblies.py b/tests/test_ralph_assemblies.py new file mode 100644 index 0000000..be4ce9a --- /dev/null +++ b/tests/test_ralph_assemblies.py @@ -0,0 +1,429 @@ +"""Independent CP6 campaign/oracle regressions. No host calls.""" +import contextlib +import copy +import io +import json +import math +from pathlib import Path +import sys +import tempfile +import types +import unittest +from unittest.mock import patch + +sys.path.insert(0,str(Path(__file__).resolve().parent)) +from ralph_assemblies import (ASSEMBLY_WITNESS, Assemblies, GROUPS, axis_parallel, + inverse, main, multiply, point, rotation_delta, rotation_z, rotation_x, same_rotation, timeline_edit_preserved, signed_z_rotation, coupled_step_matches, empty_position_refusal, mating_face_normals) +from ralph_support import equivalent + + +class Native: + def __init__(self, kind, **values): + self.objectType="adsk::fusion::"+kind + self.isValid=True + self.assemblyContext=None + self.nativeObject=None + self.__dict__.update(values) + + +class SnapshotRefusalTests(unittest.TestCase): + def test_face_center_cannot_hide_tilt(self): + self.assertFalse(mating_face_normals(rotation_x(90),rotation_z(0))) + self.assertFalse(mating_face_normals(rotation_x(180),rotation_z(0))) + self.assertTrue(mating_face_normals(rotation_z(45),rotation_z(0))) + + def test_degenerate_face_normal_refused(self): + invalid=rotation_z(0);invalid[10]=0 + self.assertFalse(mating_face_normals(invalid,rotation_z(0))) + + def case(self): + before={"count":9,"marker":9,"pending":False,"types":["adsk::fusion::Joint","adsk::fusion::Snapshot"]} + after={"count":9,"marker":9,"pending":False,"originalEntitiesPreserved":True,"added":[]} + result={"causeCode":"unhealthy_model","recovery":{"verified":True},"causeDetails":{"health":{ + "complete":True,"computeSucceeded":True,"healthy":False,"unknown":[],"faults":[{ + "name":"Position1","state":1,"index":"8", + "message":"Capture Position has no positions for current Components and JointsCompute FailedPosition1"}]}}} + return result,before,after + + def test_exact_observed_empty_snapshot_boundary(self): + self.assertTrue(empty_position_refusal(*self.case())) + + def test_unrelated_health_fault_never_qualifies(self): + for key,value in (("name","Joint"),("index","7"),("message","Other compute failure"),("state",2)): + result,before,after=self.case() + result["causeDetails"]["health"]["faults"][0][key]=value + with self.subTest(key=key):self.assertFalse(empty_position_refusal(result,before,after)) + + def test_additional_fault_or_unverified_restore_never_qualifies(self): + result,before,after=self.case();result["causeDetails"]["health"]["faults"].append({"name":"Other"}) + self.assertFalse(empty_position_refusal(result,before,after)) + result,before,after=self.case();result["recovery"]["verified"]=False + self.assertFalse(empty_position_refusal(result,before,after)) + + def test_lost_or_appended_history_never_qualifies(self): + for key,value in (("originalEntitiesPreserved",False),("count",10),("pending",True),("added",[{}])): + result,before,after=self.case();after[key]=value + with self.subTest(key=key):self.assertFalse(empty_position_refusal(result,before,after)) + + +def parameter(value=0):return types.SimpleNamespace(value=value,expression=str(value)) +def vec(x=0,y=0,z=0):return types.SimpleNamespace(x=x,y=y,z=z) +def matrix(values=None): + values=list(values or rotation_z(0)) + return types.SimpleNamespace(asArray=lambda:values) + + +class FusionClasses: + JointDirections=types.SimpleNamespace(XAxisJointDirection=0,YAxisJointDirection=1,ZAxisJointDirection=2,CustomJointDirection=3) + JointGeometryTypes=types.SimpleNamespace(JointTangentBRepFaceGeometry=99,JointTangentBRepFaceEdgeGeometry=100) + def __getattr__(self,name):return types.SimpleNamespace(cast=lambda x:x) + + +class GraphTests(unittest.TestCase): + def setUp(self): + def require(ok,message): + if not ok:raise RuntimeError(message) + self.env={"math":math,"adsk":types.SimpleNamespace(fusion=FusionClasses()),"require":require, + "witness":lambda d:{},"vector":lambda p:[p.x*10,p.y*10,p.z*10], + "everyday_geometry":lambda g:dict(g)} + exec(ASSEMBLY_WITNESS,self.env) + self.root=self.component("Root");self.child=self.component("Child") + self.body=Native("BRepBody",faces=[],edges=[],vertices=[]) + self.faces=[Native("BRepFace",body=self.body,geometry={"normal":[0,0,1]}) for _ in range(2)] + self.body.faces=self.faces;self.body.parentComponent=self.child;self.child.bRepBodies=[self.body] + self.occ=Native("Occurrence",fullPathName="Child:1",component=self.child,transform2=matrix(), + isGrounded=False,isGroundToParent=False,bRepBodies=[]) + self.sibling=Native("Occurrence",fullPathName="Child:2",component=self.child,transform2=matrix(), + isGrounded=False,isGroundToParent=False,bRepBodies=[]) + self.root.allOccurrences=[self.occ,self.sibling] + self.design=types.SimpleNamespace(rootComponent=self.root,allComponents=[self.root,self.child], + timeline=types.SimpleNamespace(markerPosition=4),snapshots=types.SimpleNamespace(hasPendingSnapshot=False)) + self.geometry=self.geometry_for(self.faces[0]) + self.joint=Native("Joint",name="Joint",jointMotion=Native("RigidJointMotion"),occurrenceOne=self.occ, + occurrenceTwo=None,isSuppressed=False,healthState=0,errorOrWarningMessage="", + geometryOrOriginOne=self.geometry,geometryOrOriginTwo=self.geometry, + geometryOneTransform=matrix(),geometryTwoTransform=matrix(),isFlipped=False,isLocked=False, + angle=parameter(),offset=parameter(),offsetX=parameter(),offsetY=parameter()) + self.root.joints=[self.joint] + self.origin=Native("JointOrigin",name="Origin",geometry=self.geometry,isFlipped=False,xAxisEntity=None,zAxisEntity=None, + transform=matrix(),secondaryAxisVector=vec(1,0,0),thirdAxisVector=vec(0,1,0),primaryAxisVector=vec(0,0,1), + angle=parameter(),offsetX=parameter(),offsetY=parameter(),offsetZ=parameter()) + self.child.jointOrigins=[self.origin] + def component(self,name): + comp=Native("Component",name=name,bRepBodies=[],sketches=[],constructionAxes=[],constructionPlanes=[],jointOrigins=[], + joints=[],asBuiltJoints=[],motionLinks=[]) + for key in ("xConstructionAxis","yConstructionAxis","zConstructionAxis","xYConstructionPlane","xZConstructionPlane","yZConstructionPlane","originConstructionPoint"): + setattr(comp,key,Native("ConstructionAxis" if "Axis" in key else "ConstructionPoint")) + return comp + def geometry_for(self,face): + return Native("JointGeometry",geometryType=1,keyPointType=2,entityOne=face,entityTwo=None,planeOne=None,planeTwo=None, + origin=vec(0.5,0.5,1),secondaryAxisVector=vec(1,0,0),thirdAxisVector=vec(0,1,0),primaryAxisVector=vec(0,0,1)) + def graph(self):return self.env["assembly_graph"](self.design) + def test_attachment_retarget_identical_geometry_changes_witness(self): + before=self.graph();self.geometry.entityOne=self.faces[1] + self.assertFalse(equivalent(before,self.graph())) + def test_occurrence_retarget_same_definition_changes_witness(self): + before=self.graph();self.joint.occurrenceOne=self.sibling + self.assertFalse(equivalent(before,self.graph())) + def test_occurrence_grounding_changes_witness(self): + before=self.graph();self.occ.isGroundToParent=True + self.assertFalse(equivalent(before,self.graph())) + def test_timeline_marker_changes_witness(self): + before=self.graph();self.design.timeline.markerPosition=1 + self.assertFalse(equivalent(before,self.graph())) + def test_joint_angle_value_changes_even_same_expression(self): + before=self.graph();self.joint.angle.value=1 + self.assertFalse(equivalent(before,self.graph())) + def test_native_origin_source_context_is_definition_scoped(self): + before=self.graph() + proxy=Native("BRepFace",nativeObject=self.faces[0],assemblyContext=self.sibling,geometry={"normal":[0,0,1]}) + self.origin.geometry=self.geometry_for(proxy) + self.origin.geometry.origin=vec(100,100,100) + # Incidental transient geometry context/frame must not corrupt the + # actual definition graph; the origin transform remains independent. + self.assertTrue(equivalent(before,self.graph())) + def test_origin_frame_change_is_not_hidden_by_definition_normalization(self): + before=self.graph();m=rotation_z(15);m[3]=1;self.origin.transform=matrix(m) + self.assertFalse(equivalent(before,self.graph())) + def test_origin_orientation_source_changes_witness(self): + before=self.graph();self.origin.xAxisEntity=self.child.xConstructionAxis + self.assertFalse(equivalent(before,self.graph())) + def test_foreign_attachment_refuses_graph(self): + self.geometry.entityOne=Native("BRepFace",geometry={}) + with self.assertRaisesRegex(RuntimeError,"relationship absent"):self.graph() + def test_duplicate_component_names_refuse_graph(self): + self.child.name=self.root.name + # Root has an explicit reserved name; two non-root names still refuse. + self.design.allComponents.append(self.component(self.child.name)) + with self.assertRaisesRegex(RuntimeError,"names must be unique"):self.graph() + def test_link_membership_and_ratio_are_native_evidence(self): + other=copy.copy(self.joint);other.name="Other";self.root.joints.append(other) + link=Native("MotionLink",name="Link",jointOne=self.joint,jointTwo=other,motionOne=1,motionTwo=1, + valueOne=parameter(1),valueTwo=parameter(2),isReversed=True,isSuppressed=False,healthState=0,errorOrWarningMessage="") + self.root.motionLinks=[link];before=self.graph();link.jointTwo=self.joint + self.assertFalse(equivalent(before,self.graph())) + link.jointTwo=other;link.valueTwo.value=3 + self.assertFalse(equivalent(before,self.graph())) + def test_compute_failed_health_changes_witness(self): + before=self.graph();self.joint.healthState=1;self.joint.errorOrWarningMessage="Missing origin" + self.assertFalse(equivalent(before,self.graph())) + def asbuilt(self): + joint=Native("AsBuiltJoint",name="Asbuilt",jointMotion=Native("RigidJointMotion"), + occurrenceOne=self.occ,occurrenceTwo=self.sibling,isSuppressed=False, + geometry=self.geometry,transform=matrix(), + timelineObject=types.SimpleNamespace(healthState=0,errorOrWarningMessage="")) + self.root.asBuiltJoints=[joint] + return joint + def test_asbuilt_health_reads_timeline_without_fake_direct_properties(self): + joint=self.asbuilt();before=self.graph() + self.assertFalse(hasattr(joint,"healthState")) + joint.timelineObject.healthState=1;joint.timelineObject.errorOrWarningMessage="ComputeFailed" + self.assertFalse(equivalent(before,self.graph())) + def test_asbuilt_attachment_and_frame_are_in_recovery_graph(self): + joint=self.asbuilt();before=self.graph() + joint.geometry=self.geometry_for(self.faces[1]) + self.assertFalse(equivalent(before,self.graph())) + joint.geometry=self.geometry;m=rotation_z(30);joint.transform=matrix(m) + self.assertFalse(equivalent(before,self.graph())) + def test_asbuilt_missing_timeline_health_refuses(self): + self.asbuilt().timelineObject=None + with self.assertRaisesRegex(RuntimeError,"timeline health unavailable"):self.graph() + + +class NumericOracleTests(unittest.TestCase): + def test_matrix_inverse_roundtrip_rotated_translation(self): + m=rotation_z(30);m[3:12:4]=[80,-20,40] + self.assertTrue(equivalent(multiply(inverse(m),m),rotation_z(0))) + self.assertTrue(equivalent(point(m,[5,5,10]),[81.8301270189222,-13.1698729810778,50])) + def test_orientation_check_does_not_accept_translation_only(self): + self.assertFalse(same_rotation(rotation_z(0),rotation_z(150))) + def test_rotation_magnitude_not_parameter_echo(self): + self.assertAlmostEqual(rotation_delta(rotation_z(90),rotation_z(105)),15) + self.assertEqual(rotation_delta(rotation_z(90),rotation_z(90)),0) + def test_axis_oracle_rejects_zero_nan_and_wrong_direction(self): + for a in ([0,0,0],[math.nan,0,1],[1,0,0]):self.assertFalse(axis_parallel(a,[0,0,1])) + self.assertTrue(axis_parallel([0,0,-3],[0,0,1])) + def test_placement_rejects_wrong_actual_pose_even_when_parameters_would_match(self): + runner=object.__new__(Assemblies);checks=[] + runner.check=lambda label,ok,evidence=None:checks.append((label,ok)) + initial={"occurrences":[{"path":"Grounded:1","transform":rotation_z(90)}, + {"path":"Movable:1","transform":rotation_z(30)}]} + # Position aligns, but rotation is wrong for the proven +30/no-flip case. + final=copy.deepcopy(initial);final["occurrences"][1]["transform"]=rotation_z(90) + runner.placement("case",initial,final,angle=150) + self.assertTrue(any(not ok for _,ok in checks)) + def recorded_poses(self): + # Minimal plain numeric occurrence matrices from both native motion + # families in cp6-native-frame-probe-2. These are not Joint transforms. + return [ + (0,0,False,[0,-1,0,10,1,0,0,20,0,0,1,30,0,0,0,1]), + (0,0,True,[0,1,0,0,1,0,0,20,0,0,-1,50,0,0,0,1]), + (30,2,False,[-0.5,-0.866025403784,0,11.830127018922,0.866025403784,-0.5,0,23.169872981078,0,0,1,32,0,0,0,1]), + (30,-2,True,[-0.5,0.866025403784,0,3.169872981078,0.866025403784,0.5,0,18.169872981078,0,0,-1,48,0,0,0,1])] + def placement_inputs(self,moved): + anchor=[0,-1,0,10,1,0,0,20,0,0,1,30,0,0,0,1] + original=[0.866025403784,-0.5,0,80,0.5,0.866025403784,0,-20,0,0,1,40,0,0,0,1] + graph=lambda m:{"occurrences":[{"path":"Grounded:1","transform":anchor},{"path":"Movable:1","transform":m}]} + return graph(original),graph(moved) + def test_all_eight_recorded_native_occurrence_poses_pass_physical_oracle(self): + runner=object.__new__(Assemblies) + def check(label,ok,evidence=None):self.assertTrue(ok,label) + runner.check=check + for family in ("rigid","revolute"): + for angle,offset,flipped,pose in self.recorded_poses(): + with self.subTest(family=family,angle=angle,flipped=flipped): + runner.placement(family,*self.placement_inputs(pose),angle=angle,offset=offset,flipped=flipped) + def test_omitted_physical_z_flip_is_rejected(self): + runner=object.__new__(Assemblies);checks=[] + runner.check=lambda label,ok,evidence=None:checks.append((label,ok)) + _,_,_,pose=self.recorded_poses()[1];pose=list(pose);pose[10]=1 + runner.placement("wrong flip",*self.placement_inputs(pose),flipped=True) + self.assertTrue(any("orientation" in label and not ok for label,ok in checks)) + + def test_native_joint_frame_angle_is_not_accepted_as_body_angle(self): + runner=object.__new__(Assemblies);checks=[] + runner.check=lambda label,ok,evidence=None:checks.append((label,ok)) + _,_,_,pose=self.recorded_poses()[2] + runner.placement("wrong angle",*self.placement_inputs(pose),angle=150,offset=2) + self.assertTrue(any("orientation" in label and not ok for label,ok in checks)) + + +class TimelineEditTests(unittest.TestCase): + def setUp(self): + self.before={"count":8,"marker":8,"pending":False} + self.after={"count":9,"marker":9,"pending":False,"originalEntitiesPreserved":True, + "added":[{"type":"adsk::fusion::Snapshot","nativeSnapshot":True}]} + def test_verified_snapshot_can_extend_end(self): + self.assertTrue(timeline_edit_preserved(self.before,self.after)) + def test_no_pending_position_need_not_add_snapshot(self): + self.assertTrue(timeline_edit_preserved(self.before,{**self.after,"count":8,"marker":8,"added":[]})) + def test_arbitrary_appended_feature_is_rejected(self): + self.after['added'][0]={"type":"adsk::fusion::ExtrudeFeature","nativeSnapshot":False} + self.assertFalse(timeline_edit_preserved(self.before,self.after)) + def test_snapshot_type_name_without_native_collection_identity_is_rejected(self): + self.after['added'][0]['nativeSnapshot']=False + self.assertFalse(timeline_edit_preserved(self.before,self.after)) + def test_pending_snapshot_is_rejected(self): + self.after['pending']=True + self.assertFalse(timeline_edit_preserved(self.before,self.after)) + def test_old_numeric_marker_before_new_end_is_rejected(self): + self.after['marker']=8 + self.assertFalse(timeline_edit_preserved(self.before,self.after)) + def test_replaced_or_reordered_original_entity_is_rejected(self): + self.after['originalEntitiesPreserved']=False + self.assertFalse(timeline_edit_preserved(self.before,self.after)) + def test_more_than_one_added_snapshot_is_rejected(self): + self.after.update(count=10,marker=10,added=self.after['added']*2) + self.assertFalse(timeline_edit_preserved(self.before,self.after)) + + +class CoupledStepTests(unittest.TestCase): + def test_native_resolved_baseline_replaces_old_driver_assumption(self): + self.assertTrue(coupled_step_matches(20,15,-20,-15,15,True)) + self.assertEqual(signed_z_rotation(rotation_z(110),rotation_z(105)),-5) + self.assertEqual(signed_z_rotation(rotation_z(70),rotation_z(75)),5) + def test_fixed_old_baseline_wrong_follower_delta_is_rejected(self): + self.assertFalse(coupled_step_matches(20,15,-20,-25,15,True)) + def test_uncoupled_follower_is_rejected(self): + self.assertFalse(coupled_step_matches(20,15,-20,-20,15,True)) + def test_wrong_final_driver_target_is_rejected(self): + self.assertFalse(coupled_step_matches(20,10,-20,-10,15,True)) + def test_positive_ratio_uses_post_unsuppression_baseline(self): + self.assertTrue(coupled_step_matches(-15,25,-15,25,25,False)) + def test_wrong_signed_physical_direction_cannot_pass_magnitude(self): + self.assertEqual(abs(signed_z_rotation(rotation_z(70),rotation_z(65))),5) + self.assertNotEqual(signed_z_rotation(rotation_z(70),rotation_z(65)),5) + def test_physical_z_oracle_refuses_flipped_fixture(self): + wrong=rotation_z(90);wrong[10]=-1 + with self.assertRaisesRegex(RuntimeError,'world-Z'):signed_z_rotation(rotation_z(90),wrong) + + +class CoupledPhysicalAnchorTests(unittest.TestCase): + def setUp(self): + self.runner=object.__new__(Assemblies) + def check(label,ok,evidence=None): + if not ok:raise RuntimeError(label) + self.runner.check=check + self.before=self.graph(20,-20) + self.after=self.graph(15,-15) + + @staticmethod + def graph(one,two): + def at_anchor(angle,anchor): + pose=rotation_z(angle);local=point(pose,[5,5,10]) + for index,desired,current in zip((3,7,11),anchor,local):pose[index]=desired-current + return pose + return {'components':[{'joints':[{'name':'First','motion':{'rotationValue':one}}, + {'name':'Second','motion':{'rotationValue':two}}],'asbuilt':[]}], + 'occurrences':[{'path':path,'transform':at_anchor(angle,anchor)} for path,angle,anchor in ( + ('Movable:1',90+one,[5,25,40]),('Follower:1',90+two,[55,25,40]), + ('Grounded:1',90,[5,25,40]),('Second ground:1',90,[55,25,40]))]} + + def test_true_off_origin_rotation_changes_translation_but_keeps_anchor(self): + old=self.before['occurrences'][1]['transform'];new=self.after['occurrences'][1]['transform'] + self.assertNotEqual(old[3],new[3]) + self.runner.coupled_step('valid coupling',self.before,self.after,target=15,reversed=True) + + def test_axial_follower_drift_cannot_pass_matching_joint_values_and_rotation(self): + self.after['occurrences'][1]['transform'][11]+=500 + with self.assertRaisesRegex(RuntimeError,'physical anchor retained Follower'): + self.runner.coupled_step('axial drift',self.before,self.after,target=15,reversed=True) + + def test_lateral_driver_drift_cannot_pass_matching_joint_values_and_rotation(self): + self.after['occurrences'][0]['transform'][3]+=5 + with self.assertRaisesRegex(RuntimeError,'physical anchor retained Movable'): + self.runner.coupled_step('lateral drift',self.before,self.after,target=15,reversed=True) + + def test_existing_attachment_offset_is_not_accepted_as_a_valid_baseline(self): + self.before['occurrences'][1]['transform'][11]+=5 + self.after['occurrences'][1]['transform'][11]+=5 + with self.assertRaisesRegex(RuntimeError,'physical anchor retained Follower'): + self.runner.coupled_step('wrong baseline',self.before,self.after,target=15,reversed=True) + + +class TimelineRegistryTests(unittest.TestCase): + def setUp(self): + def require(ok,message): + if not ok:raise RuntimeError(message) + self.env={"math":math,"adsk":types.SimpleNamespace(fusion=FusionClasses()),"require":require,"witness":lambda d:{}} + exec(ASSEMBLY_WITNESS,self.env) + def test_inventory_remains_json_after_native_timeline_baseline(self): + opaque=Native("Joint",entityToken="native-token") + timeline=types.SimpleNamespace(entity=opaque) + design=types.SimpleNamespace(timeline=types.SimpleNamespace(count=1,markerPosition=1,item=lambda i:timeline), + snapshots=types.SimpleNamespace(count=0,hasPendingSnapshot=False)) + row={"object":Native("Document"),"documentId":"session:owned","checkpoint":None} + run={"documents":{"session:owned":row}} + registry=self.env["assembly_timeline_registry"](run,row["documentId"]) + self.env["assembly_timeline_before"](design,registry) + self.assertIs(registry['assemblyTimelineProbe'][0]['object'],opaque) + # Match the maintained adapter's public_row projection exactly. + inventory=[{k:v for k,v in r.items() if k not in ('object','witness')} for r in run['documents'].values()] + self.assertEqual(json.loads(json.dumps(inventory)),[{"documentId":"session:owned","checkpoint":None}]) + self.assertNotIn('assemblyTimelineProbe',row) + def test_timeline_probe_isolated_by_exact_document_id(self): + run={};first=self.env["assembly_timeline_registry"](run,'session:first') + first['assemblyTimelineProbe']=[object()] + second=self.env["assembly_timeline_registry"](run,'session:second') + self.assertIsNot(first,second);self.assertEqual(second,{}) + + +class EntryTests(unittest.TestCase): + def test_plan_never_constructs_live_campaign(self): + with patch("ralph_assemblies.Assemblies",side_effect=AssertionError("host path")): + with contextlib.redirect_stdout(io.StringIO()) as output:self.assertEqual(main([]),0) + self.assertFalse(json.loads(output.getvalue())["hostCalls"]) + self.assertEqual(len(GROUPS),15) + def test_incomplete_run_refuses_before_campaign(self): + with patch("ralph_assemblies.Assemblies",side_effect=AssertionError("host path")): + with contextlib.redirect_stderr(io.StringIO()),self.assertRaises(SystemExit):main(["run"]) + def test_recovery_uses_assembly_graph_witness(self): + with patch("ralph_assemblies.shared_recover",return_value=0) as recover: + self.assertEqual(main(["recover","--target","host","--host-name","HOST","--evidence","existing"]),0) + self.assertIs(recover.call_args.kwargs["campaign_type"],Assemblies) + def test_ambiguous_operation_blocks_native_graph_probe(self): + runner=object.__new__(Assemblies);runner.ambiguous=True + with self.assertRaisesRegex(RuntimeError,"Unresolved"):runner.native("origin_probe") + def test_rich_witness_injected_before_lifecycle_guard(self): + runner=object.__new__(Assemblies);runner.ambiguous=False;runner.id="test-run-123456";runner.session="session" + runner.args=types.SimpleNamespace(host_name="HOST");runner.document_id="owned" + calls=[] + def call(verb,args,**kwargs):calls.append(args["script"]);return {"data":{"result":{"success":True}}} + runner.call=call + runner.native("witness",documentId="owned") + script=calls[0] + self.assertLess(script.index("def assembly_graph"),script.index("require(platform.node()")) + self.assertIn("result['assemblies'] = assembly_graph(design)",script) + + +class DeleteOutcomeTests(unittest.TestCase): + def test_referenced_origin_requires_public_mutation_refusal(self): + runner=object.__new__(Assemblies);calls=[] + runner.public_named=lambda *a: {"token":"origin"} + runner.failed=lambda *a,**k:calls.append((a,k)) + runner.healthy=lambda *a:None + runner.used_origin_delete() + self.assertEqual(calls[0][0],("Referenced origin deletion","joint_origin_delete")) + # No success-or-failure override: standard strict mutation_failed gate. + self.assertNotIn("code",calls[0][1]) + def test_failed_delete_requires_complete_graph_restore(self): + runner=object.__new__(Assemblies) + states=iter([{"graph":1},{"graph":2}]);runner.witness=lambda:next(states) + runner.mechanical=lambda *a,**k:{"success":False,"recovery":{"verified":True}} + runner.inspect=lambda:None + def check(label,ok,evidence=None): + if not ok:raise RuntimeError(label) + runner.check=check + with self.assertRaisesRegex(RuntimeError,"full native attachment graph restored"): + runner.failed("Referenced origin deletion","joint_origin_delete",origin={}) + def test_compute_success_does_not_override_unhealthy_native_joint(self): + runner=object.__new__(Assemblies) + graph={"components":[{"joints":[{"health":1,"message":"Missing origin"}],"asbuilt":[],"links":[]}],"pendingSnapshot":False} + def check(label,ok,evidence=None): + if not ok:raise RuntimeError(label) + runner.check=check + with self.assertRaisesRegex(RuntimeError,"healthy"): + runner.healthy("must stay healthy",graph) + + +if __name__=="__main__":unittest.main() diff --git a/tests/test_ralph_campaign.py b/tests/test_ralph_campaign.py new file mode 100644 index 0000000..2936d98 --- /dev/null +++ b/tests/test_ralph_campaign.py @@ -0,0 +1,270 @@ +"""Offline behavioral tests for Ralph's observation and exact-owner lifecycle. + +Native guards execute against controlled API objects; no bridge or CAD calls. +""" +import builtins +import copy +import hashlib +import json +from pathlib import Path +import platform +import struct +import sys +import tempfile +import types +import unittest +import zlib +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from fusion_harness import CallResult, OwnedRun, observe +from ralph_mechanical import Campaign, main +from ralph_support import NATIVE_PROGRAM, equivalent, health_identity_request, health_probe_script, validate_live_identity, visual_review + + +class Client: + def __init__(self, responses): self.responses, self.calls = responses, [] + def call(self, verb, args=None, **kw): + self.calls.append((verb, copy.deepcopy(args), kw)) + value = self.responses[verb] + return CallResult(value, observe(value), [{'verb': verb, 'args': args}], False, 0, kw.get('mutating', False)) + + +class BaselineTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.args = types.SimpleNamespace(evidence=self.tmp.name, target='machine', host_name='machine', expect_bridge='dev4', expect_addin='dev4') + self.responses = { + 'fusion_state': {'success': True, 'running': True, 'addinResponding': True, 'mainThreadBusy': None, 'blockingDialogs': []}, + 'fusion_addin_status': {'success': True, 'version': 'dev4', 'addinStale': False}, + 'bridge_list': {'success': True, 'bridges': [{'name': 'fusion360', 'paused': False, 'entryMismatch': False, 'runningVersion': 'dev4', + 'livePids': [456], 'port': 54321, 'entrypoint': 'C:/Program Files/Fusion/adom-fusion-bridge.exe', 'expectedImage': 'adom-fusion-bridge.exe', + 'spawn': {'kind': 'exe', 'runtimePort': 54321}, 'instanceCount': 1}]}, + 'fusion_document_info': {'success': True, 'data': {'openDocuments': [{'name': 'Untitled'}]}}, + 'fusion_run_modeling_script': {'success': True, 'data': {'result': {'host': 'machine', 'activeProductType': 'DesignProductType', 'documents': []}}}, + 'fusion_get_preferences': {'success': True, 'current': {'theme': 'device'}}} + self.health = {'host': 'machine', 'pid': 456, 'port': 54321, 'image': 'adom-fusion-bridge.exe', + 'executablePath': 'C:/Program Files/Fusion/adom-fusion-bridge.exe', 'processCreatedUtc': '2026-10-02T01:02:03Z', + 'stable': True, 'listenerAddresses': ['127.0.0.1'], + 'health': {'status': 'ok', 'exePort': 54321, 'bridgeVersion': 'dev4', 'busy': False, + 'fusion': {'running': True}, 'addin': {'busy': False, 'version': 'dev4'}}} + self.responses['run_script'] = {'success': True, 'exitCode': 0, 'timedOut': False, 'stdout': json.dumps(self.health)} + def tearDown(self): self.tmp.cleanup() + def invoke(self, versions=False): + self.client = Client(self.responses) + return Campaign(self.args, self.client).baseline(versions) + def test_stopped_refuses_before_launch_gated_queries(self): + self.responses['fusion_state']['running'] = False + with self.assertRaises(RuntimeError): self.invoke() + self.assertEqual([x[0] for x in self.client.calls], ['fusion_state', 'fusion_addin_status', 'bridge_list']) + def test_busy_refuses_before_pass_through(self): + self.responses['fusion_state']['mainThreadBusy'] = {'command': 'unknown'} + with self.assertRaises(RuntimeError): self.invoke() + self.assertNotIn('fusion_document_info', [x[0] for x in self.client.calls]) + def test_wrong_version_refuses_before_native_calls(self): + self.responses['fusion_addin_status']['version'] = 'old' + with self.assertRaises(RuntimeError): self.invoke(True) + self.assertNotIn('fusion_document_info', [x[0] for x in self.client.calls]) + def test_zero_documents_never_calls_script_or_preferences(self): + self.responses['fusion_document_info']['data']['openDocuments'] = [] + with self.assertRaises(RuntimeError): self.invoke() + self.assertNotIn('fusion_run_modeling_script', [x[0] for x in self.client.calls]) + self.assertNotIn('fusion_get_preferences', [x[0] for x in self.client.calls]) + def test_wrong_host_refuses(self): + self.responses['fusion_run_modeling_script']['data']['result']['host'] = 'other' + with self.assertRaisesRegex(RuntimeError, 'Native host'): self.invoke() + self.assertTrue(all(not call[2].get('mutating') for call in self.client.calls)) + def test_non_design_refuses_before_preferences(self): + self.responses['fusion_run_modeling_script']['data']['result']['activeProductType'] = 'PCBProductType' + with self.assertRaisesRegex(RuntimeError, 'active Design'): self.invoke() + self.assertNotIn('fusion_get_preferences', [x[0] for x in self.client.calls]) + def test_observation_baseline_sends_no_mutations(self): + self.assertEqual(self.invoke(True)['native']['host'], 'machine') + self.assertTrue(all(not call[2].get('mutating') for call in self.client.calls)) + def test_stale_inventory_with_correct_live_version_is_accepted(self): + self.responses['bridge_list']['bridges'][0]['runningVersion'] = 'old-stale' + result = self.invoke(True) + self.assertEqual(result['runtime']['runningVersion'], 'old-stale') + self.assertEqual(result['liveIdentity']['health']['bridgeVersion'], 'dev4') + def test_wrong_actual_version_refuses_even_when_inventory_matches(self): + self.health['health']['bridgeVersion'] = 'wrong' + self.responses['run_script']['stdout'] = json.dumps(self.health) + with self.assertRaisesRegex(ValueError, 'Actual native bridge version'): self.invoke(True) + self.assertNotIn('fusion_document_info', [x[0] for x in self.client.calls]) + def test_health_from_wrong_process_refuses(self): + self.health['pid'] = 789 + self.responses['run_script']['stdout'] = json.dumps(self.health) + with self.assertRaisesRegex(ValueError, 'process identity'): self.invoke(True) + def test_health_from_wrong_host_refuses(self): + self.health['host'] = 'other' + self.responses['run_script']['stdout'] = json.dumps(self.health) + with self.assertRaisesRegex(ValueError, 'process identity'): self.invoke(True) + def test_health_from_wrong_port_refuses(self): + self.health['health']['exePort'] = 12345 + self.responses['run_script']['stdout'] = json.dumps(self.health) + with self.assertRaisesRegex(ValueError, 'discovered port'): self.invoke(True) + def test_nonloopback_listener_refuses(self): + self.health['listenerAddresses'] = ['0.0.0.0'] + self.responses['run_script']['stdout'] = json.dumps(self.health) + with self.assertRaisesRegex(ValueError, 'loopback listener'): self.invoke(True) + def test_sidecar_version_cannot_attest_native_executable(self): + self.health['health']['sidecarPort'] = 54322 + self.responses['run_script']['stdout'] = json.dumps(self.health) + with self.assertRaisesRegex(ValueError, 'Sidecar health'): self.invoke(True) + def test_multiple_inventory_processes_refuse_before_script(self): + self.responses['bridge_list']['bridges'][0]['livePids'].append(789) + with self.assertRaisesRegex(ValueError, 'exactly one process'): self.invoke(True) + self.assertNotIn('run_script', [x[0] for x in self.client.calls]) + def test_health_probe_does_not_execute_discovered_executable(self): + request = health_identity_request(self.responses['bridge_list']['bridges'][0], 'machine') + script = health_probe_script(request) + self.assertIn("'/health'", script) + self.assertNotIn('Start-Process', script) + self.assertNotIn('Stop-Process', script) + self.assertNotIn('__REQUEST_B64__', script) + def test_plan_never_constructs_bridge_client(self): + with patch('ralph_mechanical.BridgeClient', side_effect=AssertionError('Host call')): + with patch('builtins.print'): self.assertEqual(main(['plan']), 0) + def test_cleanup_finally_runs_on_interrupt(self): + campaign = Campaign(self.args, Client(self.responses)) + campaign.baseline = lambda **kw: (_ for _ in ()).throw(KeyboardInterrupt()) + called = [] + campaign.cleanup = lambda: (called.append(True), campaign.report.update(cleanup='PASS')) + self.assertEqual(campaign.run(), 1) + self.assertEqual(called, [True]) + self.assertEqual(campaign.report['native'], 'FAIL') + def test_nested_failure_does_not_count_as_success(self): + self.responses['fusion_state']['data'] = {'success': False, 'error': 'bad'} + with self.assertRaises(RuntimeError): self.invoke() + + +class Attributes: + def __init__(self): self.rows = {} + def add(self, group, name, value): + row = types.SimpleNamespace(groupName=group, name=name, value=value) + self.rows[group, name] = row + return row + def itemByName(self, group, name): return self.rows.get((group, name)) + def __iter__(self): return iter(self.rows.values()) + + +class NativeGuardTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.rid = 'offline-native-guard-123456' + self.session = 'offline-session' + point = types.SimpleNamespace(x=0, y=0, z=0) + self.root = types.SimpleNamespace(attributes=Attributes(), name='Root', partNumber='', description='', material=None, + bRepBodies=[], sketches=[], allOccurrences=[], getPhysicalProperties=lambda _: types.SimpleNamespace(volume=0, mass=0, centerOfMass=point)) + self.design = types.SimpleNamespace(rootComponent=self.root, allComponents=[self.root], allParameters=[], timeline=types.SimpleNamespace(count=0)) + def document(name): + return types.SimpleNamespace(name=name, creationId='deliberate-copy-collision', isSaved=False, isModified=False, + products=types.SimpleNamespace(itemByProductType=lambda _: self.design)) + self.original = document('Untitled') + self.doc = document('Untitled') + self.doc.isModified = True + self.docs = [self.original, self.doc] + self.app = types.SimpleNamespace(documents=self.docs, activeDocument=self.doc) + def close(save): + self.assertFalse(save) + self.docs.remove(self.doc) + self.app.activeDocument = self.original + return True + self.doc.close = close + self.doc.activate = lambda: True + self.module = types.ModuleType('offline.mechanical.core') + self.module.SESSION_ID = self.session + self.module.active = lambda app: (app.activeDocument, self.design, self.session + ':exact') + sys.modules[self.module.__name__] = self.module + self.adsk = types.SimpleNamespace(fusion=types.SimpleNamespace(Design=types.SimpleNamespace(cast=lambda x: x), CalculationAccuracy=types.SimpleNamespace(HighCalculationAccuracy=1))) + self.scratch = Path(self.tmp.name) / self.rid + self.scratch.mkdir() + self.run = {'sessionId': self.session, 'documents': {}, 'scratch': str(self.scratch), + 'baselineObjects': [self.original], 'baseline': [], 'activeObject': self.original} + self.previous = getattr(builtins, '_adom_mechanical_ralph_runs', None) + builtins._adom_mechanical_ralph_runs = {self.rid: self.run} + self.execute('register', documentId=self.session + ':exact', role='fixture') + self.row = self.run['documents'][self.session + ':exact'] + self.path = self.scratch / 'verified.f3d' + self.path.write_bytes(b'archive') + self.row['checkpoint'] = {'path': str(self.path), 'sha256': hashlib.sha256(b'archive').hexdigest()} + self.row['witness'] = self.execute('witness', documentId=self.session + ':exact')['witness'] + def tearDown(self): + sys.modules.pop(self.module.__name__, None) + if self.previous is None: del builtins._adom_mechanical_ralph_runs + else: builtins._adom_mechanical_ralph_runs = self.previous + self.tmp.cleanup() + def execute(self, action, **kw): + env = {'REQUEST': {'action': action, 'host': platform.node(), 'runId': self.rid, 'sessionId': self.session, **kw}, + 'app': self.app, 'ui': types.SimpleNamespace(activeCommand='SelectCommand'), 'adsk': self.adsk} + exec(NATIVE_PROGRAM, env) + return env['result'] + def test_exact_object_closes_despite_creation_id_collision(self): + owner = self.execute('verify', documentId=self.session + ':exact') + self.assertEqual(owner['ownerRunId'], self.rid) + result = self.execute('close', documentId=self.session + ':exact') + self.assertEqual(result['closedDocumentId'], self.session + ':exact') + self.assertEqual(self.docs, [self.original]) + def test_baseline_object_is_never_closed(self): + self.row['object'] = self.original + with self.assertRaisesRegex(RuntimeError, 'baseline'): self.execute('close', documentId=self.session + ':exact') + self.assertEqual(len(self.docs), 2) + def test_wrong_run_marker_retains(self): + self.root.attributes.itemByName('AdomRalph', 'owner').value = 'different' + with self.assertRaisesRegex(RuntimeError, 'marker'): self.execute('close', documentId=self.session + ':exact') + self.assertEqual(len(self.docs), 2) + def test_changed_checkpoint_bytes_retains(self): + self.path.write_bytes(b'changed') + with self.assertRaisesRegex(RuntimeError, 'bytes changed'): self.execute('close', documentId=self.session + ':exact') + self.assertEqual(len(self.docs), 2) + def test_missing_checkpoint_retains(self): + self.row['checkpoint'] = None + with self.assertRaisesRegex(RuntimeError, 'No verified'): self.execute('close', documentId=self.session + ':exact') + def test_saved_owned_document_retains(self): + self.doc.isSaved = True + with self.assertRaisesRegex(RuntimeError, 'saved externally'): self.execute('close', documentId=self.session + ':exact') + def test_wrong_session_retains(self): + self.module.SESSION_ID = 'restarted' + with self.assertRaisesRegex(RuntimeError, 'session is unavailable'): self.execute('close', documentId=self.session + ':exact') + def test_user_selected_external_document_retains(self): + self.app.activeDocument = self.original + with self.assertRaisesRegex(RuntimeError, 'external document'): self.execute('close', documentId=self.session + ':exact') + def test_model_witness_change_retains(self): + self.root.description = 'Changed after checkpoint' + with self.assertRaisesRegex(RuntimeError, 'Model changed'): self.execute('close', documentId=self.session + ':exact') + def test_failed_import_identity_keeps_provisional_object(self): + imported = types.SimpleNamespace(name='Untitled') + self.app.importManager = types.SimpleNamespace(createFusionArchiveImportOptions=lambda p: p, importToNewDocument=lambda options: imported) + with self.assertRaisesRegex(RuntimeError, 'no active document'): + self.execute('checkpoint', documentId=self.session + ':exact', path=str(self.path)) + self.assertIs(self.run['provisionalImports'][0]['object'], imported) + + +class VisualTests(unittest.TestCase): + def image(self, directory): + def chunk(kind, data): return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data) & 0xffffffff) + data = b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', 640, 480, 8, 2, 0, 0, 0)) + data += chunk(b'IDAT', zlib.compress((b'\0' + b'\xff' * (640 * 3)) * 480)) + chunk(b'IEND', b'') + path = Path(directory) / 'test.png' + path.write_bytes(data) + return {'kind': 'viewport', 'path': str(path), 'sha256': hashlib.sha256(data).hexdigest()} + def test_empty_review_is_not_visual_pass(self): + with self.assertRaises(ValueError): visual_review({'artifacts': []}, [], 'Verifier') + def test_unreviewed_image_rejected(self): + with self.assertRaisesRegex(ValueError, 'every current'): + visual_review({'artifacts': [{'kind': 'viewport', 'sha256': 'abc'}]}, [], 'Verifier') + def test_nonfinite_native_values_never_match(self): + self.assertFalse(equivalent(float('nan'), float('nan'))) + self.assertFalse(equivalent(float('inf'), float('inf'))) + def test_review_is_bound_to_exact_image_bytes(self): + with tempfile.TemporaryDirectory() as directory: + image = self.image(directory) + rows = [{'sha256': image['sha256'], 'verdict': 'FAIL', 'notes': 'Controlled blank image, no geometry.'}] + result = visual_review({'artifacts': [image]}, rows, 'Offline verifier') + self.assertEqual(result['status'], 'FAIL') + Path(image['path']).write_bytes(Path(image['path']).read_bytes() + b'changed') + with self.assertRaisesRegex(ValueError, 'SHA-256'): + visual_review({'artifacts': [image]}, rows, 'Offline verifier') + + +if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/tests/test_ralph_everyday.py b/tests/test_ralph_everyday.py new file mode 100644 index 0000000..8f92395 --- /dev/null +++ b/tests/test_ralph_everyday.py @@ -0,0 +1,135 @@ +"""Offline independent witness and campaign guards; never contacts a host.""" +import contextlib +import copy +import io +import json +import tempfile +from pathlib import Path +import sys +import types +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from ralph_everyday import EXTRA_WITNESS, Everyday, main +from ralph_support import equivalent + + +def point(x=0, y=0): + return types.SimpleNamespace(x=x, y=y, z=0, objectType='adsk::core::Point3D') + + +def sketch_point(x=0, y=0): + value = types.SimpleNamespace(geometry=point(x, y), isFixed=False, isLinked=False, + isReference=False, isFullyConstrained=False) + return value + + +class WitnessTests(unittest.TestCase): + def setUp(self): + identity = types.SimpleNamespace(cast=lambda x: x) + fusion = types.SimpleNamespace(SketchLinearDimension=identity, CoincidentConstraint=identity) + core = types.SimpleNamespace(Point3D=identity, Line3D=identity, NurbsCurve3D=identity) + def require(value, message): + if not value: raise RuntimeError(message) + self.env = {'adsk': types.SimpleNamespace(core=core, fusion=fusion), 'witness': lambda doc: {}, + 'vector': lambda p: [p.x*10, p.y*10, p.z*10], 'require': require} + exec(EXTRA_WITNESS, self.env) + # Distinct equal-position native points, deliberately no token identity. + class NativePoint: + def __init__(self): + self.geometry = point() + self.isFixed = self.isLinked = self.isReference = self.isFullyConstrained = False + self.a, self.b = NativePoint(), NativePoint() + self.c = NativePoint(); self.c.geometry = point(1, 0) + self.line = types.SimpleNamespace(objectType='adsk::fusion::SketchLine', + geometry=types.SimpleNamespace(objectType='adsk::core::Line3D',startPoint=self.a.geometry,endPoint=self.c.geometry), + startSketchPoint=self.a,endSketchPoint=self.c,isConstruction=False,isFixed=False,isLinked=False,isReference=False,isFullyConstrained=False) + self.dimension = types.SimpleNamespace(objectType='adsk::fusion::SketchLinearDimension', + parameter=types.SimpleNamespace(name='d1',expression='10 mm',value=1),isDriving=True,entityOne=self.a,entityTwo=self.c) + self.constraint = types.SimpleNamespace(objectType='adsk::fusion::CoincidentConstraint',point=self.a,entity=self.line) + self.sketch = types.SimpleNamespace(name='Fixture',isFullyConstrained=False,healthState=0, + errorOrWarningMessage='',isComputeDeferred=False,sketchCurves=[self.line],sketchPoints=[self.a,self.b,self.c], + geometricConstraints=[self.constraint],sketchDimensions=[self.dimension]) + def witness(self): return self.env['everyday_sketch'](self.sketch) + def test_coincident_retarget_same_geometry_changes_witness(self): + before=self.witness(); self.constraint.point=self.b + self.assertFalse(equivalent(before,self.witness())) + def test_dimension_retarget_same_geometry_changes_witness(self): + before=self.witness(); self.dimension.entityOne=self.b + self.assertFalse(equivalent(before,self.witness())) + def test_curve_endpoint_retarget_same_geometry_changes_witness(self): + before=self.witness(); self.line.startSketchPoint=self.b + self.assertFalse(equivalent(before,self.witness())) + def test_dimension_value_changes_witness(self): + before=self.witness(); self.dimension.parameter.value=2 + self.assertFalse(equivalent(before,self.witness())) + def test_nullable_known_link_is_recorded(self): + self.constraint.entity=None + self.assertIn('entity',self.witness()['constraints'][0]['links']) + self.assertIsNone(self.witness()['constraints'][0]['links']['entity']) + def test_foreign_graph_relationship_fails_closed(self): + self.dimension.entityOne=sketch_point(2, 0) + with self.assertRaisesRegex(RuntimeError,'endpoint missing'): self.witness() + def test_spline_controls_are_geometry_evidence(self): + controls=[point(),point(1,2),point(3,0)] + geometry=types.SimpleNamespace(objectType='adsk::core::NurbsCurve3D',getData=lambda:(True,controls,2,[0,0,0,1,1,1],False,[],False)) + before=self.env['everyday_geometry'](geometry) + controls[1].y=3 + self.assertFalse(equivalent(before,self.env['everyday_geometry'](geometry))) + def test_invalid_native_spline_data_refuses(self): + geometry=types.SimpleNamespace(objectType='adsk::core::NurbsCurve3D',getData=lambda:(False,[],0,[],False,[],False)) + with self.assertRaisesRegex(RuntimeError,'control data'): self.env['everyday_geometry'](geometry) + + +class EntryTests(unittest.TestCase): + def test_default_plan_never_constructs_campaign(self): + with patch('ralph_everyday.Everyday',side_effect=AssertionError('host path reached')): + with contextlib.redirect_stdout(io.StringIO()): self.assertEqual(main([]),0) + def test_incomplete_run_never_constructs_campaign(self): + with patch('ralph_everyday.Everyday',side_effect=AssertionError('host path reached')): + with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): main(['run']) + def test_everyday_recovery_selects_extended_witness_campaign(self): + with patch('ralph_everyday.shared_recover',return_value=0) as recover: + self.assertEqual(main(['recover','--target','host','--host-name','HOST','--evidence','existing']),0) + self.assertIs(recover.call_args.kwargs['campaign_type'],Everyday) + def test_shared_recovery_default_stays_cp4_campaign(self): + from ralph_mechanical import Campaign,recover + self.assertEqual(recover.__defaults__,(Campaign,)) + def test_ambiguous_operation_blocks_extra_native_oracles(self): + campaign=object.__new__(Everyday); campaign.ambiguous=True + with self.assertRaisesRegex(RuntimeError,'Unresolved operation'): campaign.native('probe_refs') + + + +class RecoveryAccountingTests(unittest.TestCase): + def test_recovery_counts_new_checks_and_preserves_prior_failure(self): + from ralph_mechanical import recover + with tempfile.TemporaryDirectory() as tmp: + directory=Path(tmp) + prior={'native':'FAIL','cleanup':'FAIL','overall':'FAIL', + 'checks':[{'label':'original failure','status':'FAIL'}],'counts':{'PASS':0,'FAIL':1}, + 'baseline':{'liveIdentity':{'health':{'bridgeVersion':'dev5'}},'addin':{'version':'dev5'}}} + (directory/'report.json').write_text(json.dumps(prior)) + (directory/'ownership.json').write_text(json.dumps({'target':'host','host':'HOST','runId':'recorded','sessionId':'session'})) + class Runner: + def __init__(self,args): self.args=args + def runtime_observation(self,**kwargs): pass + def call(self,verb): return {'sessionId':'session'} + def native(self,action): return {'documents':[]} + def cleanup(self): + self.report['checks'].append({'label':'original restored','status':'PASS'}) + self.report['cleanup']='PASS' + def persist(self): (directory/'report.json').write_text(json.dumps(self.report)) + owned=types.SimpleNamespace(manifest={},persist=lambda:None) + args=types.SimpleNamespace(evidence=tmp,target='host',host_name='HOST') + with patch('ralph_mechanical.BridgeClient',return_value=object()), patch('ralph_mechanical.OwnedRun.resume',return_value=owned): + self.assertEqual(recover(args,campaign_type=Runner),0) + after=json.loads((directory/'report.json').read_text()) + self.assertEqual(after['counts'],{'PASS':1,'FAIL':1}) + self.assertEqual(after['native'],'FAIL') + self.assertEqual(after['overall'],'FAIL') + self.assertEqual(after['checks'][0],prior['checks'][0]) + + +if __name__ == '__main__': unittest.main() diff --git a/tests/test_ralph_jobs.py b/tests/test_ralph_jobs.py new file mode 100644 index 0000000..89b31e6 --- /dev/null +++ b/tests/test_ralph_jobs.py @@ -0,0 +1,217 @@ +"""Offline CP8 acceptance-runner guards and independent history assertions.""" +import contextlib +import copy +import io +import json +from pathlib import Path +from types import SimpleNamespace as NS +import unittest +from unittest.mock import patch + +import ralph_jobs as r + + +class HistoryContract(unittest.TestCase): + def setUp(self): + self.request={'op':'parameter_set','sessionId':'session','operationId':'operation', + 'target':{'documentId':'exact-document','revision':'original'},'name':'Depth','expression':'20 mm'} + self.response={'success':True,'sessionId':'session','operationId':'operation', + 'data':{'target':{'documentId':'exact-document','revision':'after'}}, + 'durability':{'terminalRecorded':True,'recordedSessionId':'session','storageVersion':1,'phase':'succeeded'}} + self.status={'success':True,'sessionId':'session','currentSessionId':'session','operationId':'operation', + 'historical':False,'state':'succeeded','phase':'succeeded','outcome':'recorded','reconciliationRequired':False, + 'result':copy.deepcopy(self.response),'history':{ + 'sessionId':'session','operationId':'operation','operation':'parameter_set','sourceVersion':'dev8','storageVersion':1,'phase':'succeeded', + 'requestDigest':r.mechanical_request_digest(self.request),'requestedTarget':copy.deepcopy(self.request['target']), + 'observedDocument':{'documentId':'exact-document','creationId':'advisory','dataFileId':None}, + 'createdAt':1.,'updatedAt':4.,'events':[{'phase':phase,'at':i+1.} for i,phase in enumerate( + ('admitted','dispatched','executing','succeeded'))]}} + + def errors(self):return r.terminal_contract(self.request,self.response,self.status,'dev8','exact-document') + + def test_matching_actual_history_and_transport_only_replay_pass(self): + self.assertEqual(self.errors(),[]) + self.response['replayed']=True;self.request['caller']={'nested':'relay'};self.request['_callerBudgetMs']=1 + self.assertEqual(self.errors(),[]) + + def test_same_name_different_document_digest_and_source_do_not_pass(self): + for field,value in (('observedDocument',{'documentId':'wrong-document','creationId':'advisory','dataFileId':None}), + ('requestDigest','0'*64),('sourceVersion','other-build'),('requestedTarget',{'documentId':'exact-document','revision':'wrong'})): + before=copy.deepcopy(self.status) + self.status['history'][field]=value + self.assertTrue(self.errors(),field) + self.status=before + + def test_nonterminal_or_out_of_order_history_refuses(self): + for change in ({'state':'running'},{'outcome':'unknown'},{'reconciliationRequired':True}): + original=copy.deepcopy(self.status);self.status.update(change);self.assertTrue(self.errors());self.status=original + self.status['history']['events'][2]['at']=9 + self.assertTrue(self.errors()) + + def test_native_failure_is_a_durable_failed_result_not_status_success(self): + self.response.update(success=False,errorCode='mutation_failed',causeCode='invalid_expression',recovery={'verified':True}) + self.response['durability']['phase']='failed' + self.status.update(state='failed',phase='failed',result=copy.deepcopy(self.response)) + self.status['history']['phase']='failed' + self.status['history']['events'][-1]['phase']='failed' + self.assertEqual(self.errors(),[]) + self.status['result']['success']=True + self.assertTrue(self.errors()) + + def test_only_replay_annotation_may_differ_in_immutable_result(self): + self.status['result']['data']['target']['revision']='silently-new-result' + self.assertTrue(self.errors()) + + def test_contradictory_history_phase_and_execution_claims_refuse(self): + for phase in ('executing','failed',None): + self.status['history']['phase']=phase;self.assertTrue(self.errors()) + self.status['history']['phase']='succeeded' + for value in (False,0,1,'true'): + self.status['nativeExecutionStarted']=value;self.assertTrue(self.errors()) + self.status.pop('nativeExecutionStarted') + self.response['nativeExecutionStarted']=value + self.status['result']['nativeExecutionStarted']=value + self.assertTrue(self.errors()) + for value in (None,True): + self.status['nativeExecutionStarted']=value;self.response['nativeExecutionStarted']=value + self.status['result']['nativeExecutionStarted']=value;self.assertEqual(self.errors(),[]) + + def test_actual_cli_and_rust_decorations_do_not_change_recorded_native_result(self): + # Sanitized shape from a real public mechanical receipt. Timing and + # hygiene are computed outside the journal, including on replay. + native=copy.deepcopy(self.response) + self.response.update(shell='rust',_contribute='source link',_hint='background operation', + documentHygiene={'needsSaveCount':1},windowEtiquette='background', + _timing={'totalSec':3.5},timeoutSeconds=120,statusVerb='fusion_state') + self.response['output']=json.dumps(self.response) + self.response.update(durationMs=3600,schemaVersion=1,status='ok') + self.assertEqual(self.errors(),[]) + replay=copy.deepcopy(self.response);replay.pop('output');replay.update(replayed=True,durationMs=2,_timing={'totalSec':.001}) + self.assertEqual(r.native_envelope(replay),native) + self.response['unreviewedSemanticField']='must not vanish' + self.assertTrue(self.errors()) + + def test_annotation_projection_never_masks_nested_failure_or_changed_payload(self): + for change in ({'success':False,'errorCode':'connection_error','error':'connection refused'}, + {'data':{'target':{'documentId':'different','revision':'after'}}}): + value=copy.deepcopy(self.response);nested=copy.deepcopy(value);nested.update(change) + value.update(output=json.dumps(nested),status='ok') + with self.assertRaises(ValueError):r.native_envelope(value) + + def test_real_successful_cli_empty_error_is_only_an_outer_annotation(self): + native=copy.deepcopy(self.response) + self.response.update(output=json.dumps(native),error='') + self.assertEqual(self.errors(),[]) + self.assertEqual(r.native_envelope(self.response),native) + # A native error field, even empty, remains part of native identity. + native['error']='' + value={**native,'output':json.dumps(native)} + self.assertEqual(r.native_envelope(value),native) + self.assertNotEqual(r.native_signature(native),r.native_signature(self.status['result'])) + + def test_blank_error_exception_cannot_hide_other_types_payload_or_failure(self): + for value in (None,False,0,{},'failure'): + outer={**self.response,'output':json.dumps(self.response),'error':value} + with self.assertRaises(ValueError):r.native_envelope(outer) + nested=copy.deepcopy(self.response);nested['data']['error']='native diagnostic' + outer={**self.response,'output':json.dumps(nested),'error':''} + with self.assertRaises(ValueError):r.native_envelope(outer) + nested=copy.deepcopy(self.response);nested.update(success=False,errorCode='mutation_failed') + outer={**nested,'output':json.dumps(nested),'error':''} + with self.assertRaises(ValueError):r.native_envelope(outer,'mutation_failed') + + def test_immutable_result_comparison_preserves_json_types(self): + self.response['data']['value']=1 + for value in (True,1.0): + self.status['result']=copy.deepcopy(self.response) + self.status['result']['data']['value']=value + self.assertTrue(self.errors()) + original=copy.deepcopy(self.response) + original['output']=json.dumps(self.status['result']) + with self.assertRaises(ValueError):r.native_signature(original) + + def test_history_comparison_retains_failed_result_with_outer_annotations(self): + self.test_native_failure_is_a_durable_failed_result_not_status_success() + self.status['result']=copy.deepcopy(self.response) + self.status['output']=json.dumps(self.status) + self.status['durationMs']=11 + projected=r.native_envelope(self.status,'mutation_failed') + self.assertFalse(projected['result']['success']) + self.assertNotIn('durationMs',projected) + + +class LifecycleGuards(unittest.TestCase): + def test_owned_depth_selection_distinguishes_equal_model_parameter_and_refuses_ambiguity(self): + depth={'name':'depth','unit':'mm','value':10,'expression':'10 mm'} + model={'name':'d3','unit':'mm','value':10,'expression':'depth'} + self.assertEqual(r.box_depth_parameter({'parameters':[model,depth]}),depth) + for rows in ([model],[depth,depth],[dict(depth,unit='deg')],[dict(depth,value=20)]): + with self.assertRaises(ValueError):r.box_depth_parameter({'parameters':rows}) + + def test_default_plan_is_offline_and_excludes_restart_faults(self): + with patch.object(r,'DurableOperations') as campaign,contextlib.redirect_stdout(io.StringIO()) as stdout: + self.assertEqual(r.main([]),0) + campaign.assert_not_called() + result=json.loads(stdout.getvalue());self.assertFalse(result['hostCalls']) + self.assertEqual(result['notRun'],list(r.EXCLUDED)) + + def test_missing_versions_and_only_filter_cannot_dispatch(self): + for args in (['run','--target','host','--host-name','host','--evidence','unused'],['--only','restart_history']): + with patch.object(r,'DurableOperations') as campaign,contextlib.redirect_stderr(io.StringIO()),self.assertRaises(SystemExit): + r.main(args) + campaign.assert_not_called() + + def test_dispatch_persists_immutable_request_before_single_public_call(self): + runner=object.__new__(r.DurableOperations);runner.ambiguous=False;events=[] + runner.owned=NS(manifest={},persist=lambda:events.append(copy.deepcopy(runner.owned.manifest))) + runner.call=lambda verb,args,**kwargs:events.append((verb,copy.deepcopy(args),kwargs)) or {'success':True} + request={'sessionId':'session','operationId':'op','nested':{'value':1}} + runner.dispatch(request) + request['nested']['value']=2 + self.assertEqual(events[0]['lastDispatch']['nested']['value'],1) + self.assertEqual(events[1][0],'fusion_mechanical');self.assertTrue(events[1][2]['mutating']) + self.assertEqual(runner.owned.manifest['lastDispatch']['nested']['value'],1) + runner.ambiguous=True + with self.assertRaisesRegex(RuntimeError,'unresolved'):runner.dispatch(request) + self.assertEqual(len(events),2) + + def test_registration_failure_retains_exact_created_identity_before_other_checks(self): + runner=object.__new__(r.DurableOperations) + runner.session='session';runner.id='unique-run-123456';runner.fixture='ordinary';runner.target=None;runner.ambiguous=False + runner.args=NS(inject_failure=None);persisted=[] + runner.owned=NS(manifest={},persist=lambda:persisted.append(copy.deepcopy(runner.owned.manifest))) + runner.check=lambda label,condition,*args: self.assertTrue(condition,label) + def call(verb,args=None,**kwargs): + if verb=='fusion_mech_capabilities':return {'sessionId':'session','idempotency':{ + 'storageVersion':1,'capacity':10000,'databaseByteLimit':64*1024*1024,'recordByteLimit':4*1024*1024, + 'admission':'FULL SQLite commit before native dispatch'}} + if args['op']=='inspect':return {'data':{'target':{'documentId':'original'}}} + return {'success':True,'data':{'target':{'documentId':'created-exact','revision':'new'}}} + def native(action,**values): + if action=='document_set':return {'openDocuments':['baseline:0']} + self.assertEqual(action,'register');self.assertEqual(values['documentId'],'created-exact') + self.assertIn('created-exact',runner.owned.manifest['pendingRegistrations']) + raise RuntimeError('registration failed') + runner.call=call;runner.native=native + with self.assertRaisesRegex(RuntimeError,'registration failed'):runner.ordinary() + self.assertIn('created-exact',persisted[-1]['pendingRegistrations']) + self.assertEqual(runner.owned.manifest['lastDispatch']['op'],'new_design') + + def test_ambiguous_work_blocks_extra_native_document_inventory(self): + runner=object.__new__(r.DurableOperations);runner.ambiguous=True + with patch.object(runner,'call') as call,self.assertRaisesRegex(RuntimeError,'Unresolved'): + runner.native('document_set') + call.assert_not_called() + + def test_document_set_oracle_uses_native_object_equality_without_name_matching(self): + runner=object.__new__(r.DurableOperations);runner.ambiguous=False + runner.id='unique-run-123456';runner.session='session';runner.args=NS(host_name='host') + scripts=[] + runner.call=lambda verb,args:scripts.append(args['script']) or {'data':{'result':{'success':True}}} + runner.native('document_set') + compile(scripts[0],'native-doc-set','exec') + self.assertIn("document == original",scripts[0]);self.assertIn("document == row['object']",scripts[0]) + self.assertNotIn('document.name ==',scripts[0].split('labels = []')[-1]) + + +if __name__=='__main__':unittest.main()