Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ jobs:
run: dotnet restore ClaudeRevit\ClaudeRevit.csproj
- name: Build (Release, no deploy)
run: dotnet build ClaudeRevit\ClaudeRevit.csproj -c Release -p:SkipDeploy=true --no-restore
- name: Compile native acceptance harness (does not run without Revit)
run: dotnet build ClaudeRevit.NativeTests\ClaudeRevit.NativeTests.csproj -c Release -p:SkipDeploy=true
- name: Unit tests (pure logic, no Revit)
run: dotnet test ClaudeRevit.Tests\ClaudeRevit.Tests.csproj -c Release
- name: Pane checks (isolated WPF, no Revit or account)
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,7 @@ Thumbs.db
*.env
appsettings.Development.json
secrets.json

# Local pane verification artifacts and release scanner reports
pane-qa/
release-security.json
14 changes: 14 additions & 0 deletions ClaudeRevit.NativeTests/ClaudeRevit.NativeTests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PlatformTarget>x64</PlatformTarget>
<RevitVersion>2027</RevitVersion>
<SkipDeploy>true</SkipDeploy>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../ClaudeRevit/ClaudeRevit.csproj" AdditionalProperties="RevitVersion=2027;SkipDeploy=true" />
</ItemGroup>
</Project>
59 changes: 59 additions & 0 deletions ClaudeRevit.NativeTests/GeometryFixtures.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using System.IO;
using System.Reflection;
using System.Text.Json;
using Autodesk.Revit.DB;
using Autodesk.Revit.DB.Structure;
using Autodesk.Revit.UI;
using ClaudeRevit.Tools;

namespace ClaudeRevit.NativeTests;

internal static class GeometryFixtures
{
public static object Bore(UIApplication app,JsonElement input)
{
var family=app.Application.OpenDocumentFile(input.GetProperty("section_file").GetString()!);
try
{
var extrusion=new FilteredElementCollector(family).OfClass(typeof(Extrusion)).Cast<Extrusion>().Single();
var method=typeof(CreateParametricSection).Assembly.GetType("ClaudeRevit.Tools.ConnectionOpeningGeometry",true)!.GetMethod("Inspect")!;
JsonElement Inspect(XYZ origin,double diameter)=>JsonSerializer.SerializeToElement(method.Invoke(null,new object[]{extrusion,origin,XYZ.BasisZ,diameter,.1}));
var good=Inspect(XYZ.Zero,200);var offset=Inspect(new XYZ(5/304.8,0,0),200);var oversize=Inspect(XYZ.Zero,280);
if(!good.GetProperty("Verified").GetBoolean()||Math.Abs(good.GetProperty("DiameterMm").GetDouble()-276)>.001)throw new InvalidOperationException("Native through bore was not identified: "+good);
if(offset.GetProperty("Verified").ValueKind==JsonValueKind.True||oversize.GetProperty("Verified").ValueKind==JsonValueKind.True)throw new InvalidOperationException("Misaligned/oversized bolt was accepted.");
return new{passed=true,good,offset_rejected=offset,oversize_rejected=oversize,limitation="Actual bore geometry only; no capacity or thread engagement assertion."};
}
finally{family.Close(false);}
}
public static object ReferencePlacement(UIApplication app,JsonElement input)
{
var project=app.Application.NewProjectDocument(UnitSystem.Metric);
try
{
FamilyInstance beam;FamilySymbol symbol;
using(var tx=new Transaction(project,"QA native reference framing placement"))
{
tx.Start();var level=Level.Create(project,0);
if(!project.LoadFamily(input.GetProperty("family_file").GetString()!,out var family))throw new InvalidOperationException("Cannot load reference framing.");
symbol=(FamilySymbol)project.GetElement(family.GetFamilySymbolIds().First());symbol.Activate();project.Regenerate();
beam=project.Create.NewFamilyInstance(Line.CreateBound(new XYZ(0,0,1000/304.8),new XYZ(6000/304.8,0,1000/304.8)),symbol,level,StructuralType.Beam);
tx.Commit();
}
if(beam.Category.Id.Value!=(long)BuiltInCategory.OST_StructuralFraming||beam.StructuralType!=StructuralType.Beam)throw new InvalidOperationException("Reference is not native structural framing.");
double Length()=>beam.get_Parameter(new Guid("b62d0a35-0f0f-432d-9d3d-e821093a7d02")).AsDouble()*304.8;
double Width()
{
var all=new FilteredElementCollector(project).OfClass(typeof(FamilyInstance)).Cast<FamilyInstance>().Where(f=>f.Id==beam.Id||f.SuperComponent?.Id==beam.Id);
var points=all.SelectMany(SyntheticFixtures.Solids).SelectMany(s=>s.Faces.Cast<Face>()).SelectMany(f=>f.Triangulate().Vertices).ToArray();
return (points.Max(p=>p.Y)-points.Min(p=>p.Y))*304.8;
}
var originalLength=Length();var originalWidth=Width();
using(var tx=new Transaction(project,"QA native section edit")){tx.Start();symbol.LookupParameter("Ширина").Set(260/304.8);tx.Commit();}
var editedWidth=Width();if(Math.Abs(editedWidth-260)>.01||Math.Abs(editedWidth-originalWidth)<.01)throw new InvalidOperationException("Native framing width did not follow type dimension: "+editedWidth);
using(var tx=new Transaction(project,"QA native placement length edit")){tx.Start();((LocationCurve)beam.Location).Curve=Line.CreateBound(new XYZ(0,0,1000/304.8),new XYZ(8000/304.8,0,1000/304.8));tx.Commit();}
var editedLength=Length();if(Math.Abs(editedLength-originalLength-2000)>.01)throw new InvalidOperationException("Actual family placement length did not follow curve edit.");
return new{passed=true,category=beam.Category.Name,structural_type=beam.StructuralType.ToString(),original_length_mm=originalLength,edited_length_mm=editedLength,original_width_mm=originalWidth,edited_width_mm=editedWidth,limitation="Downloaded box beam acceptance; this does not certify every generated structural template."};
}
finally{project.Close(false);}
}
}
114 changes: 114 additions & 0 deletions ClaudeRevit.NativeTests/NativeAcceptance.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Security.Cryptography;
using System.Text.Json;
using Autodesk.Revit.DB;
using Autodesk.Revit.UI;
using ClaudeRevit.Tools;

namespace ClaudeRevit.NativeTests;

// Loaded in a separate AssemblyLoadContext from the installed add-in. Its
// ExternalEvent runs after the launcher's transaction ends. No OnStartup, MCP
// server, pane, or global updater is started by this acceptance harness.
public static class NativeAcceptance
{
private static readonly Handler HandlerInstance = new();
private static ExternalEvent? _event;
private static string? _pending;
private static bool _running;
public static string Queue(string requestPath)
{
if (_running || _pending != null) throw new InvalidOperationException("Acceptance job is already queued/running.");
_event ??= ExternalEvent.Create(HandlerInstance);
_pending = Path.GetFullPath(requestPath);
var result = _event.Raise();
if (result != ExternalEventRequest.Accepted) { _pending = null; throw new InvalidOperationException("ExternalEvent: " + result); }
return "queued";
}
private sealed class Handler : IExternalEventHandler
{
public string GetName() => "ClaudeRevit native acceptance (disposable documents only)";
public void Execute(UIApplication app)
{
var requestPath = _pending; _pending = null;
if (requestPath == null) return;
_running = true;
var clock = Stopwatch.StartNew();
var output = requestPath + ".result.json";
var openBefore = app.Application.Documents.Cast<Document>().ToArray();
var activeBefore = app.ActiveUIDocument?.Document;
var modifiedBefore = openBefore.ToDictionary(d => d, d => d.IsModified);
object? result = null; string? error = null;
try
{
File.WriteAllText(requestPath + ".started.json", JsonSerializer.Serialize(new { utc = DateTime.UtcNow, state = "running" }));
var failureType = typeof(InspectFamilyFiles).Assembly.GetType("ClaudeRevit.Tools.FamilyInspectionFailures", true)!;
using var backgroundFailures = (IDisposable)Activator.CreateInstance(failureType, app.Application)!;
using var request = JsonDocument.Parse(File.ReadAllText(requestPath));
var root = request.RootElement;
var action = root.GetProperty("action").GetString();
if (action == "tool")
{
var typeName = root.GetProperty("type").GetString()!;
var type = typeof(InspectFamilyFiles).Assembly.GetType("ClaudeRevit.Tools." + typeName, true)!;
if (Activator.CreateInstance(type) is not IRevitTool tool) throw new InvalidOperationException("Not a native tool.");
// This entry point inspects references only. Editing/model tests
// below must create their own disposable document explicitly.
if (tool is not (InspectFamilyFiles or AnalyzeFamilyStructure or FlexFamily)) throw new InvalidOperationException("Reference tool is not read-only or rollback-only.");
var input = root.GetProperty("arguments").EnumerateObject().ToDictionary(p => p.Name, p => p.Value.Clone());
result = JsonSerializer.Deserialize<JsonElement>(tool.Execute(input, app));
}
else if (action == "sections") result = SyntheticFixtures.Sections(app, root);
else if (action == "checkpoint") result = SyntheticFixtures.Checkpoint(app, root);
else if (action == "nested") result = SyntheticFixtures.Nested(app, root);
else if (action == "live_schedule") result = SyntheticFixtures.LiveSchedule(app, root);
else if (action == "benchmark_probe") result = SyntheticFixtures.BenchmarkProbe(app);
else if (action == "dependent_node") result = SyntheticFixtures.DependentNode(app, root);
else if (action == "bore") result = GeometryFixtures.Bore(app,root);
else if (action == "reference_placement") result = GeometryFixtures.ReferencePlacement(app,root);
else if (action == "schedule_forms") result = SyntheticFixtures.ScheduleForms(app,root);
else if (action == "template_probe")
{
var rows = new List<object>();
foreach (var template in root.GetProperty("templates").EnumerateArray())
{
Document? family = null;
try
{
family = app.Application.NewFamilyDocument(template.GetString()!);
rows.Add(new { template = template.GetString(), view = family.ActiveView?.Name, direction = family.ActiveView == null ? null : new[] { family.ActiveView.ViewDirection.X, family.ActiveView.ViewDirection.Y, family.ActiveView.ViewDirection.Z },
views = new FilteredElementCollector(family).OfClass(typeof(View)).Cast<View>().Where(v => !v.IsTemplate).Select(v => new { name = v.Name, kind = v.ViewType.ToString(), direction = new[] { v.ViewDirection.X, v.ViewDirection.Y, v.ViewDirection.Z } }).ToArray(),
parameters = family.FamilyManager.Parameters.Cast<FamilyParameter>().Select(p => new { name = p.Definition.Name, instance = p.IsInstance, spec = p.Definition.GetDataType().TypeId }).ToArray() });
}
finally { if (family is { IsValidObject: true }) family.Close(false); }
}
result = rows;
}
else throw new InvalidOperationException("Unknown acceptance action: " + action);
}
catch (Exception ex) { error = ex.ToString(); }
finally
{
_running = false;
var openAfter = app.Application.Documents.Cast<Document>().ToArray();
var originalDocumentsPreserved = openBefore.All(d => d.IsValidObject && openAfter.Contains(d) && d.IsModified == modifiedBefore[d]);
var activeAfter = app.ActiveUIDocument?.Document;
// Revit may return a new managed wrapper for the same native document.
var activePreserved = activeBefore == null ? activeAfter == null :
activeAfter != null && activeBefore.IsValidObject && activeBefore.Equals(activeAfter);
File.WriteAllText(output, JsonSerializer.Serialize(new { utc = DateTime.UtcNow, revit = app.Application.VersionNumber, build = app.Application.VersionBuild,
assembly_sha256 = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(typeof(InspectFamilyFiles).Assembly.Location))).ToLowerInvariant(),
elapsed_seconds = clock.Elapsed.TotalSeconds, original_documents_preserved = originalDocumentsPreserved, active_document_preserved = activePreserved,
background_document_leaks = openAfter.Except(openBefore).Select(d => d.Title).ToArray(), result, error }, new JsonSerializerOptions { WriteIndented = true }));
}
}
}
internal static void Target(Document? document)
{
var type = typeof(InspectFamilyFiles).Assembly.GetType("ClaudeRevit.Tools.ToolContext", true)!;
if (document == null) type.GetMethod("Clear")!.Invoke(null, null);
else type.GetMethod("Set")!.Invoke(null, new object?[] { CancellationToken.None, document, null });
}
}
72 changes: 72 additions & 0 deletions ClaudeRevit.NativeTests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Native acceptance harness

This is a developer-only Revit 2027 library. CI compiles it but cannot execute its
checks without a licensed running Revit. It is not included in the installer.

Build with .NET 10 and `SkipDeploy=true`. Stage the resulting DLLs in a fresh,
immutable directory and scan them before loading. To test a development build
alongside an installed add-in, create a separate `AssemblyLoadContext`, eagerly
load its `ClaudeRevit.dll`, then load `ClaudeRevit.NativeTests.dll`. Share Revit API
and framework/dependency assemblies with the default context. Eager loading is
required: a late resolving callback alone can bind to the installed add-in.

From a valid Revit API callback, invoke `NativeAcceptance.Queue(requestPath)`.
The harness creates an `ExternalEvent` and runs after the launcher's transaction
has ended. Never invoke document lifecycle tests inside `execute_csharp`'s own
transaction. The installed add-in's server and pane are left running; the test
assembly does not call its `OnStartup` or register its global updaters.

Requests are UTF-8 JSON. A `.started.json` marker and `.result.json` report are
written next to the request. Reports record Revit build, exact development DLL
hash, elapsed time, active/original document preservation and leaked background
documents. Every test document is closed without saving user-owned originals.
Generated fixtures require unused output paths; existing files are not replaced.

Supported actions:

- `tool`: `type` is `InspectFamilyFiles`, `AnalyzeFamilyStructure` or `FlexFamily`;
`arguments` is that tool's schema. For downloaded references, supply absolute
`file_path` or `files[].path`; do not redistribute the library files.
- `template_probe`: `templates` contains absolute RFT paths. Reports actual
default view direction and parameters before choosing a sketch plane/driver.
- `sections`: `template`, `output_directory`, optional `shapes` list. Creates seven
constrained section seeds, checks analytical volume and physical mass outside
the tool's returned claims, then saves native RFA fixtures. Built-in section
flex additionally checks independent dimensions and return to nominal.
- `nested`: `template`, `child_file` (rectangle section seed), `output_directory`.
Creates two shared nesting levels, associates the independent width driver and
visibility variant, tests small/large/variant changes and verifies nominal
geometric volume without counting a shared child twice.
- `checkpoint`: `output_file`. Creates a new metric project, checks preview
rollback, bounded progress, saved/reopened persistence, duplicate retry and
rejection after an independent edit. The saved seed has one completed step.
- `benchmark_probe`: checks native level/unit and material evidence against the
objective evaluator, including a deliberately wrong elevation. It does not
invoke subscription models or establish performance timings.
- `live_schedule`: `section_file`, `output_file`. Compares independently expected
material mass after section-size edits, physical-density edits and deletion.
Optional `titleblock_template` and `image_directory` place native schedules on
an A3 sheet, export PNG pixels, and report native cell styles/rows and layout
audits. Numerical success does not certify wrapping or drawing compliance.
- `schedule_forms`: `section_file`, `output_file`, `titleblock_template`,
`image_directory`. Creates three live timber forms, checks native length-driven
amounts and physical material masses, then exports their A3 sheet.
- `bore`: `section_file` (tube seed). Checks actual inner cylindrical faces and
rejects an offset axis and a diameter larger than the through-bore.
- `reference_placement`: `family_file` (downloaded BIMStarter welded box beam).
Tests actual native beam placement length and width-driven solid bounds in a
disposable project. It does not certify a generated structural template.
- `dependent_node`: `section_file`, `output_file`. Checks relative placement and
parameter/actual-volume dependencies, preserves an independent managed-part
edit and tests a deleted source. The saved file is a negative regression case.

For updater checks, the harness briefly pauses the corresponding older installed
ClaudeRevit updater during its single synchronous API callback. A test updater
is registered only for the disposable test document. Both registration and pause
are undone in `finally`; no user edit can interleave on Revit's API thread.

Only executed reports can establish acceptance. Successful compilation, a
catalogue description or the presence of a fixture generator cannot do so.
See [executed October 2026 cases](../docs/native-acceptance-2026-10.md) for results
and the [BIMStarter findings](../docs/bimstarter-reference-cases.md) for expected
minimum-count behavior and remaining visibility checks.
Loading
Loading