Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfd31a3747 | ||
|
|
c5cf142159 | ||
|
|
bc481d4378 | ||
|
|
1fa25809de | ||
|
|
6ed1e4086b | ||
|
|
c633107273 | ||
|
|
2d9fbdd474 | ||
|
|
98564b807f | ||
|
|
1b4ff816ba | ||
|
|
13578b7c9b | ||
|
|
523d063ceb | ||
|
|
31171c9ed8 | ||
|
|
edf1640c4f | ||
|
|
81a075e684 | ||
|
|
32607e2f34 | ||
|
|
c9c4113e86 | ||
|
|
33f91c26aa | ||
|
|
ef615a1f8e | ||
|
|
e7765cbdda | ||
|
|
d8e61b7a5e | ||
|
|
79067b72de | ||
|
|
44de0cf787 | ||
|
|
239796aafe | ||
|
|
8a1735408a | ||
|
|
544b329077 |
@@ -14,6 +14,8 @@ public sealed class EntryPoint : IExtensionApplication
|
|||||||
{
|
{
|
||||||
RuntimeDiagnostics.Write("AssemblyInitializeStarted");
|
RuntimeDiagnostics.Write("AssemblyInitializeStarted");
|
||||||
RibbonIntegration.Register();
|
RibbonIntegration.Register();
|
||||||
|
// Test automation is driven only by the explicitly generated /b script.
|
||||||
|
// Initialize remains production-equivalent and must not orchestrate commands.
|
||||||
RuntimeDiagnostics.Write("AssemblyInitializeCompleted");
|
RuntimeDiagnostics.Write("AssemblyInitializeCompleted");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +35,21 @@ public sealed class EntryPoint : IExtensionApplication
|
|||||||
[CommandMethod("PXP-OA")]
|
[CommandMethod("PXP-OA")]
|
||||||
public void OrthoAnnotateCommand() => RunDiagnostic("PXP-OA");
|
public void OrthoAnnotateCommand() => RunDiagnostic("PXP-OA");
|
||||||
|
|
||||||
|
[CommandMethod("PHP_REC_AUTOMATION_START", CommandFlags.Session)]
|
||||||
|
public void AutomationStartCommand() => FirstLimitedApplyAutomationBootstrap.Arm();
|
||||||
|
|
||||||
|
// Test-only command-queue barrier. It deliberately performs no Plant or
|
||||||
|
// drawing work; its CommandEnded event proves the activated document is
|
||||||
|
// ready before the coordinator calls the Plant APIs.
|
||||||
|
[CommandMethod("PHP_REC_AUTOMATION_READY")]
|
||||||
|
public void AutomationReadyCommand() { }
|
||||||
|
|
||||||
|
[CommandMethod("PHP_REC_AUTOMATION_PREPARE", CommandFlags.Session)]
|
||||||
|
public void AutomationPrepareCommand() => ScriptDrivenFirstApplyAutomation.Prepare();
|
||||||
|
|
||||||
|
[CommandMethod("PHP_REC_AUTOMATION_FINALIZE", CommandFlags.Session)]
|
||||||
|
public void AutomationFinalizeCommand() => ScriptDrivenFirstApplyAutomation.FinalizePreview();
|
||||||
|
|
||||||
private static void RunDiagnostic(string commandName)
|
private static void RunDiagnostic(string commandName)
|
||||||
{
|
{
|
||||||
var operationId = Guid.NewGuid().ToString("N");
|
var operationId = Guid.NewGuid().ToString("N");
|
||||||
@@ -80,6 +97,8 @@ public sealed class EntryPoint : IExtensionApplication
|
|||||||
var applyPermit = (mode == PRecAnnotExecutionMode.Apply || hasLimitedApplyPermit) && document is not null
|
var applyPermit = (mode == PRecAnnotExecutionMode.Apply || hasLimitedApplyPermit) && document is not null
|
||||||
? FirstLimitedApplyPermitReader.ReadFor(document)
|
? FirstLimitedApplyPermitReader.ReadFor(document)
|
||||||
: null;
|
: null;
|
||||||
|
var automationTarget = FirstLimitedApplyAutomationCoordinator.SelectedTargetSnapshotId;
|
||||||
|
var limitedTarget = applyPermit?.TargetSnapshotId ?? automationTarget;
|
||||||
if (mode == PRecAnnotExecutionMode.Apply && context.Viewports.Count != 1)
|
if (mode == PRecAnnotExecutionMode.Apply && context.Viewports.Count != 1)
|
||||||
throw new InvalidOperationException("APPLY_REQUIRES_EXACTLY_ONE_VIEWPORT");
|
throw new InvalidOperationException("APPLY_REQUIRES_EXACTLY_ONE_VIEWPORT");
|
||||||
|
|
||||||
@@ -95,8 +114,8 @@ public sealed class EntryPoint : IExtensionApplication
|
|||||||
CommandName = commandName,
|
CommandName = commandName,
|
||||||
Viewport = viewport,
|
Viewport = viewport,
|
||||||
Objects = objects,
|
Objects = objects,
|
||||||
LimitedApplyTargetSnapshotId = applyPermit?.TargetSnapshotId,
|
LimitedApplyTargetSnapshotId = limitedTarget,
|
||||||
LimitedApplyRuleId = applyPermit is null ? null : FirstLimitedApplyPlanner.RuleId
|
LimitedApplyRuleId = limitedTarget is null ? null : FirstLimitedApplyPlanner.RuleId
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +160,14 @@ public sealed class EntryPoint : IExtensionApplication
|
|||||||
details["PlanConflictCount"] = summary.ConflictCount;
|
details["PlanConflictCount"] = summary.ConflictCount;
|
||||||
details["PlanUnsupportedCount"] = summary.UnsupportedCount;
|
details["PlanUnsupportedCount"] = summary.UnsupportedCount;
|
||||||
details["ErrorCount"] = summary.ErrorCount;
|
details["ErrorCount"] = summary.ErrorCount;
|
||||||
|
if (string.Equals(commandName, "PXP-OA", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var candidates = objectsByViewport.SelectMany(x => x)
|
||||||
|
.Where(x => string.Equals(x.ObjectClass, "Pipe", StringComparison.OrdinalIgnoreCase) && x.MembershipStatus == ViewMembershipStatus.Resolved && x.Geometry.PaperPosition is not null)
|
||||||
|
.Select(x => x.SnapshotId).OrderBy(x => x, StringComparer.Ordinal).ToArray();
|
||||||
|
ScriptDrivenFirstApplyAutomation.RecordPxpOa(planId, candidates, context.Viewports.SingleOrDefault()?.Id, summary.CreateCount, summary.SkipCount, mode == PRecAnnotExecutionMode.Apply ? applyPermit is not null : null, summary.ObjectsExamined, membershipResolved);
|
||||||
|
FirstLimitedApplyAutomationCoordinator.RecordPxpOa(planId, candidates, context.Viewports.SingleOrDefault()?.Id, summary.CreateCount, summary.SkipCount, mode == PRecAnnotExecutionMode.Apply ? applyPermit is not null : null);
|
||||||
|
}
|
||||||
if (mode == PRecAnnotExecutionMode.Apply)
|
if (mode == PRecAnnotExecutionMode.Apply)
|
||||||
{
|
{
|
||||||
var corePermit = new FirstLimitedApplyPermit
|
var corePermit = new FirstLimitedApplyPermit
|
||||||
@@ -155,6 +182,7 @@ public sealed class EntryPoint : IExtensionApplication
|
|||||||
RuntimeDiagnostics.Write("ApplyPreflightCompleted", operationId: operationId, commandName: commandName, resultStatus: "Accepted", document: document);
|
RuntimeDiagnostics.Write("ApplyPreflightCompleted", operationId: operationId, commandName: commandName, resultStatus: "Accepted", document: document);
|
||||||
RuntimeDiagnostics.Write("AnnotationTransactionStarted", operationId: operationId, commandName: commandName, document: document);
|
RuntimeDiagnostics.Write("AnnotationTransactionStarted", operationId: operationId, commandName: commandName, document: document);
|
||||||
var outcome = new FirstLimitedPipeAnnotationWriter().Write(document!, preflight.Operation, applyPermit.VisibilityState);
|
var outcome = new FirstLimitedPipeAnnotationWriter().Write(document!, preflight.Operation, applyPermit.VisibilityState);
|
||||||
|
FirstLimitedApplyAutomationCoordinator.RecordApplyOutcome(outcome);
|
||||||
details["ApplyOutcome"] = outcome.ToString();
|
details["ApplyOutcome"] = outcome.ToString();
|
||||||
details["AnnotationsCreated"] = outcome == PipeAnnotationWriteOutcome.Created ? 1 : 0;
|
details["AnnotationsCreated"] = outcome == PipeAnnotationWriteOutcome.Created ? 1 : 0;
|
||||||
details["AnnotationsSkipped"] = outcome == PipeAnnotationWriteOutcome.AlreadyExists ? 1 : 0;
|
details["AnnotationsSkipped"] = outcome == PipeAnnotationWriteOutcome.AlreadyExists ? 1 : 0;
|
||||||
|
|||||||
@@ -0,0 +1,495 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Autodesk.AutoCAD.ApplicationServices;
|
||||||
|
using Autodesk.AutoCAD.DatabaseServices;
|
||||||
|
using Autodesk.AutoCAD.Runtime;
|
||||||
|
using Autodesk.ProcessPower.PlantInstance;
|
||||||
|
using Autodesk.ProcessPower.ProjectManager;
|
||||||
|
using PRecAnnot.Abstractions;
|
||||||
|
using AcadApplication = Autodesk.AutoCAD.ApplicationServices.Application;
|
||||||
|
|
||||||
|
namespace PRecAnnot.Plant3D2026;
|
||||||
|
|
||||||
|
// Test-only coordinator. It is not registered in Ribbon and is inert unless
|
||||||
|
// the explicit NextPilot automation environment and local marker are present.
|
||||||
|
internal sealed record FirstApplyAutomationConfiguration
|
||||||
|
{
|
||||||
|
public required string Phase { get; init; }
|
||||||
|
public string? OrthoDrawingPath { get; init; }
|
||||||
|
public string? ModelDrawingPath { get; init; }
|
||||||
|
public required string EvidencePath { get; init; }
|
||||||
|
public required string FixtureMarkerPath { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record AutomationEntityCounts(int Total, int PipeBlocks, int PxpOaXdata, int Owned);
|
||||||
|
|
||||||
|
internal static class FirstLimitedApplyAutomationBootstrap
|
||||||
|
{
|
||||||
|
private enum BootstrapState { Created, Prepared, Armed, Starting, Completed }
|
||||||
|
private static BootstrapState state;
|
||||||
|
private static bool observedIdle;
|
||||||
|
private static int idleCallbackCount;
|
||||||
|
private static int coordinatorStartCount;
|
||||||
|
|
||||||
|
internal static int IdleCallbackCount => idleCallbackCount;
|
||||||
|
internal static int CoordinatorStartCount => coordinatorStartCount;
|
||||||
|
|
||||||
|
// Called from Initialize: BCL gates and one lightweight subscription only.
|
||||||
|
internal static void Prepare()
|
||||||
|
{
|
||||||
|
if (state != BootstrapState.Created) return;
|
||||||
|
state = BootstrapState.Prepared;
|
||||||
|
AcadApplication.Idle += OnIdle;
|
||||||
|
RuntimeDiagnostics.Write("AutomationBootstrapSubscribed", resultStatus: "Prepared");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public test command is intentionally side-effect free with respect to Plant APIs.
|
||||||
|
internal static void Arm()
|
||||||
|
{
|
||||||
|
if (state is BootstrapState.Prepared)
|
||||||
|
{
|
||||||
|
// The command itself is the post-NETLOAD barrier. The next Idle
|
||||||
|
// callback, not this command callback, is allowed to start work.
|
||||||
|
observedIdle = true;
|
||||||
|
state = BootstrapState.Armed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnIdle(object? sender, EventArgs args)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
idleCallbackCount++;
|
||||||
|
RuntimeDiagnostics.Write("AutomationBootstrapIdle", resultStatus: state.ToString());
|
||||||
|
// The script engine can consume the custom command after NETLOAD. The
|
||||||
|
// first idle is therefore the explicit post-NETLOAD arming point.
|
||||||
|
if (state == BootstrapState.Prepared) { observedIdle = true; state = BootstrapState.Armed; }
|
||||||
|
if (!observedIdle || state != BootstrapState.Armed) return;
|
||||||
|
state = BootstrapState.Starting;
|
||||||
|
AcadApplication.Idle -= OnIdle;
|
||||||
|
coordinatorStartCount++;
|
||||||
|
RuntimeDiagnostics.Write("AutomationCoordinatorStarting", resultStatus: "FirstPostStartupIdle");
|
||||||
|
new FirstLimitedApplyAutomationCoordinator().Start();
|
||||||
|
}
|
||||||
|
catch (System.Exception exception)
|
||||||
|
{
|
||||||
|
state = BootstrapState.Completed;
|
||||||
|
AcadApplication.Idle -= OnIdle;
|
||||||
|
FirstLimitedApplyAutomationCoordinator.WriteBootstrapFailure(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
internal sealed record FirstApplyAutomationEvidence
|
||||||
|
{
|
||||||
|
public int SchemaVersion { get; init; } = 1;
|
||||||
|
public required string Phase { get; init; }
|
||||||
|
public bool Completed { get; init; }
|
||||||
|
public bool? Prepared { get; init; }
|
||||||
|
public bool? FunctionalEvidenceCompleted { get; init; }
|
||||||
|
public string? ErrorCode { get; init; }
|
||||||
|
public int? DbmodBefore { get; init; }
|
||||||
|
public int? DbmodAfterCommand1 { get; init; }
|
||||||
|
public int? DbmodAfterCommand2 { get; init; }
|
||||||
|
public int? DbmodAfterUndo { get; init; }
|
||||||
|
public AutomationEntityCounts? Baseline { get; init; }
|
||||||
|
public AutomationEntityCounts? AfterCommand1 { get; init; }
|
||||||
|
public AutomationEntityCounts? AfterCommand2 { get; init; }
|
||||||
|
public AutomationEntityCounts? AfterUndo { get; init; }
|
||||||
|
public IReadOnlyList<string> CompletedCommands { get; init; } = Array.Empty<string>();
|
||||||
|
public string? PlanIdRun1 { get; init; }
|
||||||
|
public string? PlanIdRun2 { get; init; }
|
||||||
|
public bool? PlanIdsEqual { get; init; }
|
||||||
|
public int? EligibleCandidateCount { get; init; }
|
||||||
|
public string? SelectedCandidateHash { get; init; }
|
||||||
|
public string? ViewHash { get; init; }
|
||||||
|
public int? CreateRun1 { get; init; }
|
||||||
|
public int? CreateRun2 { get; init; }
|
||||||
|
public int? SkipRun2 { get; init; }
|
||||||
|
public string? ApplyOutcomeRun1 { get; init; }
|
||||||
|
public string? ApplyOutcomeRun2 { get; init; }
|
||||||
|
public bool? PermitAccepted { get; init; }
|
||||||
|
public bool? CommandsCompleted { get; init; }
|
||||||
|
public bool? InspectorsCompleted { get; init; }
|
||||||
|
public bool? EvidenceFlushed { get; init; }
|
||||||
|
public bool? ExitAttemptQueued { get; init; }
|
||||||
|
public bool? QuitCommandWillStart { get; init; }
|
||||||
|
public bool? QuitCommandEnded { get; init; }
|
||||||
|
public string? ExitFailureCode { get; init; }
|
||||||
|
public int? IdleCallbackCount { get; init; }
|
||||||
|
public int? CoordinatorStartCount { get; init; }
|
||||||
|
// Local test evidence only. This is intentionally never copied into Git
|
||||||
|
// evidence because exception stacks may contain machine-specific paths.
|
||||||
|
public string? ExceptionText { get; init; }
|
||||||
|
public string? PreviousState { get; init; }
|
||||||
|
public string? CurrentState { get; init; }
|
||||||
|
public IReadOnlyList<string> StateTransitions { get; init; } = Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class FirstLimitedApplyAutomationCoordinator
|
||||||
|
{
|
||||||
|
private enum CoordinatorState { Created, Starting, Running, Completed, Failed, ExitQueued }
|
||||||
|
private sealed record PxpResult(string? PlanId, IReadOnlyList<string> Candidates, string? View, int Create, int Skip, bool? PermitAccepted, string? ApplyOutcome);
|
||||||
|
private static string? selectedTargetSnapshotId;
|
||||||
|
private static readonly List<PxpResult> pxpResults = [];
|
||||||
|
internal static string? SelectedTargetSnapshotId => selectedTargetSnapshotId;
|
||||||
|
internal static void WriteBootstrapFailure(System.Exception exception) =>
|
||||||
|
RuntimeDiagnostics.Write("AutomationBootstrapFailed", "ERROR", resultStatus: "AUTOMATION_BOOTSTRAP_FAILED", exception: exception);
|
||||||
|
internal static void RecordPxpOa(string? planId, IReadOnlyList<string> candidates, string? view, int create, int skip, bool? permitAccepted)
|
||||||
|
{
|
||||||
|
if (string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_AUTOMATION"), "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal))
|
||||||
|
pxpResults.Add(new(planId, candidates, view, create, skip, permitAccepted, null));
|
||||||
|
}
|
||||||
|
internal static void RecordApplyOutcome(PipeAnnotationWriteOutcome outcome)
|
||||||
|
{
|
||||||
|
if (!string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_AUTOMATION"), "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal) || pxpResults.Count == 0) return;
|
||||||
|
var last = pxpResults[^1];
|
||||||
|
pxpResults[^1] = last with { ApplyOutcome = outcome.ToString() };
|
||||||
|
}
|
||||||
|
private FirstApplyAutomationConfiguration? config;
|
||||||
|
private Document? document;
|
||||||
|
private readonly List<Document> automationOpenedDocuments = [];
|
||||||
|
private readonly List<string> completed = [];
|
||||||
|
private int step;
|
||||||
|
private int? before, after1, after2, afterUndo;
|
||||||
|
private AutomationEntityCounts? baseline, counts1, counts2, countsUndo;
|
||||||
|
private readonly List<string> transitions = ["Created"];
|
||||||
|
private CoordinatorState state = CoordinatorState.Created;
|
||||||
|
private CoordinatorState previousState = CoordinatorState.Created;
|
||||||
|
private bool documentHandlersSubscribed;
|
||||||
|
private bool lifecycleHandlersSubscribed;
|
||||||
|
private bool fixtureDocumentActivated;
|
||||||
|
private bool projectActivationStarted;
|
||||||
|
private bool exitQueued;
|
||||||
|
private bool exitHandlerSubscribed;
|
||||||
|
private string? exceptionText;
|
||||||
|
private bool functionalCompleted;
|
||||||
|
private bool evidenceFlushed;
|
||||||
|
private bool exitAttemptQueued;
|
||||||
|
private string? exitFailureCode;
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Transition(CoordinatorState.Starting, "PostStartupReady");
|
||||||
|
config = ReadConfiguration();
|
||||||
|
if (string.Equals(config.Phase, "LifecycleProbe", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
CompleteLifecycleProbe();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(config.OrthoDrawingPath)) throw new InvalidOperationException("AUTOMATION_ORTHO_PATH_REQUIRED");
|
||||||
|
// A first startup Idle is sufficient to arm automation, but not to
|
||||||
|
// call Plant APIs. Establish an AutoCAD document first and wait for
|
||||||
|
// its activation plus a quiescent callback before project activation.
|
||||||
|
AcadApplication.DocumentManager.DocumentActivated += OnDocumentActivated;
|
||||||
|
AcadApplication.Idle += OnIdle;
|
||||||
|
lifecycleHandlersSubscribed = true;
|
||||||
|
if (!string.IsNullOrWhiteSpace(config.ModelDrawingPath)) automationOpenedDocuments.Add(AcadApplication.DocumentManager.Open(config.ModelDrawingPath, false));
|
||||||
|
transitions.Add("DocumentOpenStarted");
|
||||||
|
RuntimeDiagnostics.Write("AutomationBeforeDocumentOpen", resultStatus: "PostStartupReady");
|
||||||
|
document = AcadApplication.DocumentManager.Open(config.OrthoDrawingPath, false);
|
||||||
|
automationOpenedDocuments.Add(document);
|
||||||
|
AcadApplication.DocumentManager.MdiActiveDocument = document;
|
||||||
|
transitions.Add("DocumentOpened");
|
||||||
|
RuntimeDiagnostics.Write("AutomationAfterDocumentOpen", resultStatus: "WaitingForDocumentActivation", document: document);
|
||||||
|
document.CommandEnded += OnCommandEnded;
|
||||||
|
document.CommandFailed += OnCommandFailed;
|
||||||
|
document.CommandCancelled += OnCommandCancelled;
|
||||||
|
documentHandlersSubscribed = true;
|
||||||
|
Transition(CoordinatorState.Running, "AutomationRunning");
|
||||||
|
// DocumentActivated can be raised synchronously by Open before the
|
||||||
|
// coordinator receives the returned Document. Confirm the current
|
||||||
|
// active document after assignment so that one missed event cannot
|
||||||
|
// strand the workflow waiting for another startup Idle.
|
||||||
|
// Open was requested for this exact local fixture and it has just
|
||||||
|
// been made MDI-active. Queue a no-op public command regardless of
|
||||||
|
// wrapper identity/name normalisation; CommandEnded is the actual
|
||||||
|
// readiness proof before any Plant API is used.
|
||||||
|
fixtureDocumentActivated = true;
|
||||||
|
transitions.Add("FixtureDocumentActiveAfterOpen");
|
||||||
|
QueueReadinessBarrier();
|
||||||
|
}
|
||||||
|
catch (System.Exception ex) { FailFromCallback("Start", ex, "AUTOMATION_START_FAILED"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CompleteLifecycleProbe()
|
||||||
|
{
|
||||||
|
transitions.Add("LifecycleProbeCompleted");
|
||||||
|
WriteEvidence(true, null);
|
||||||
|
// LifecycleProbe is already running from the first verified Idle callback.
|
||||||
|
// Do not wait for a second Idle: a startup-script host is permitted not to
|
||||||
|
// raise one, which otherwise leaves acad.exe responsive but stranded.
|
||||||
|
RequestControlledExit(idleSafe: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnIdle(object? sender, EventArgs e) => RunCallback("Idle", TryActivateProjectAndVerifyContext);
|
||||||
|
private void OnDocumentActivated(object sender, DocumentCollectionEventArgs e)
|
||||||
|
{
|
||||||
|
RunCallback("DocumentActivated", () =>
|
||||||
|
{
|
||||||
|
if (document is null || e.Document is null) return;
|
||||||
|
if (!string.Equals(e.Document.Name, document.Name, StringComparison.OrdinalIgnoreCase)) return;
|
||||||
|
fixtureDocumentActivated = true;
|
||||||
|
transitions.Add("FixtureDocumentActivated");
|
||||||
|
TryActivateProjectAndVerifyContext();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
private void TryActivateProjectAndVerifyContext()
|
||||||
|
{
|
||||||
|
if (IsTerminal || document is null || !fixtureDocumentActivated || projectActivationStarted) return;
|
||||||
|
if (!AcadApplication.IsQuiescent) return;
|
||||||
|
projectActivationStarted = true;
|
||||||
|
AcadApplication.Idle -= OnIdle;
|
||||||
|
AcadApplication.DocumentManager.DocumentActivated -= OnDocumentActivated;
|
||||||
|
lifecycleHandlersSubscribed = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
transitions.Add("ProjectActivationStarted");
|
||||||
|
RuntimeDiagnostics.Write("AutomationBeforeProjectActivation", resultStatus: "DocumentActivatedAndQuiescent", document: document);
|
||||||
|
var projectXml = Path.Combine(Path.GetDirectoryName(config!.FixtureMarkerPath)!, "Project.xml");
|
||||||
|
var project = PlantProject.LoadProject(projectXml, false, string.Empty, string.Empty);
|
||||||
|
PlantApplication.SetCurrentProject(project, true);
|
||||||
|
transitions.Add("ProjectActivated");
|
||||||
|
RuntimeDiagnostics.Write("AutomationAfterProjectActivation", resultStatus: "ProjectAvailable", document: document);
|
||||||
|
transitions.Add("ContextVerificationStarted");
|
||||||
|
var context = new PlantRuntimeContextReader().ReadCurrentContext(document);
|
||||||
|
if (!context.HasPlantProject || !context.IsOrthoDrawing || context.Viewports.Count != 1) { Complete(false, $"AUTOMATION_ORTHO_CONTEXT_INVALID:P={context.HasPlantProject};O={context.IsOrthoDrawing};V={context.Viewports.Count}"); return; }
|
||||||
|
var candidates = new PlantObjectReader().ReadObjects(context.Viewports.Single(), CancellationToken.None)
|
||||||
|
.Where(x => string.Equals(x.ObjectClass, "Pipe", StringComparison.OrdinalIgnoreCase) && x.MembershipStatus == ViewMembershipStatus.Resolved && x.Geometry.PaperPosition is not null)
|
||||||
|
.Select(x => x.SnapshotId).OrderBy(x => x, StringComparer.Ordinal).ToArray();
|
||||||
|
if (candidates.Length == 0) { Complete(false, "AUTOMATION_NO_ELIGIBLE_CANDIDATE"); return; }
|
||||||
|
selectedTargetSnapshotId = candidates[0];
|
||||||
|
transitions.Add("ContextVerified");
|
||||||
|
before = Dbmod(); baseline = Counts();
|
||||||
|
Queue("PXP-OA");
|
||||||
|
}
|
||||||
|
catch (System.Exception ex) { FailFromCallback("ContextVerification", ex, "AUTOMATION_CONTEXT_FAILED"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Queue(string command) => _ = DispatchCommandAsync(command, false);
|
||||||
|
private void QueueReadinessBarrier()
|
||||||
|
{
|
||||||
|
_ = DispatchCommandAsync("PHP_REC_AUTOMATION_READY", true);
|
||||||
|
}
|
||||||
|
private async Task DispatchCommandAsync(string command, bool readiness)
|
||||||
|
{
|
||||||
|
if (IsTerminal || document is null) return;
|
||||||
|
var expected = document;
|
||||||
|
transitions.Add(readiness ? "ReadinessCommandDispatchRequested" : "CommandDispatchRequested:" + command);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await AcadApplication.DocumentManager.ExecuteInCommandContextAsync(async _ =>
|
||||||
|
{
|
||||||
|
transitions.Add("CommandContextEntered:" + command);
|
||||||
|
var active = AcadApplication.DocumentManager.MdiActiveDocument;
|
||||||
|
if (active is null || !string.Equals(active.Name, expected.Name, StringComparison.OrdinalIgnoreCase))
|
||||||
|
throw new InvalidOperationException("AUTOMATION_COMMAND_DOCUMENT_NOT_ACTIVE");
|
||||||
|
transitions.Add(readiness ? "ReadinessBarrierQueued" : "CommandQueued:" + command);
|
||||||
|
await active.Editor.CommandAsync(command);
|
||||||
|
transitions.Add("CommandInvocationReturned:" + command);
|
||||||
|
}, null);
|
||||||
|
}
|
||||||
|
catch (System.Exception exception) { FailFromCallback("CommandDispatcher", exception, "AUTOMATION_COMMAND_CONTEXT_FAILED"); }
|
||||||
|
}
|
||||||
|
private void OnCommandEnded(object sender, CommandEventArgs e)
|
||||||
|
{
|
||||||
|
RunCallback("CommandEnded", () =>
|
||||||
|
{
|
||||||
|
var name = e.GlobalCommandName?.Trim().TrimStart('.', '_').ToUpperInvariant();
|
||||||
|
if (name == "PHP_REC_AUTOMATION_READY")
|
||||||
|
{
|
||||||
|
transitions.Add("ReadinessBarrierCompleted");
|
||||||
|
TryActivateProjectAndVerifyContext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (name is not ("PXP-OA" or "UNDO" or "U")) return;
|
||||||
|
completed.Add(name);
|
||||||
|
transitions.Add("CommandCompleted:" + name);
|
||||||
|
if (step++ == 0) { after1 = Dbmod(); counts1 = Counts(); Queue("PXP-OA"); return; }
|
||||||
|
if (step == 2 && string.Equals(config?.Phase, "Apply", StringComparison.OrdinalIgnoreCase)) { after2 = Dbmod(); counts2 = Counts(); Queue("_.UNDO _1"); return; }
|
||||||
|
if (string.Equals(config?.Phase, "Apply", StringComparison.OrdinalIgnoreCase)) { afterUndo = Dbmod(); countsUndo = Counts(); Complete(true, null, safeToExitNow: true); }
|
||||||
|
else { after2 = Dbmod(); counts2 = Counts(); Complete(true, null, safeToExitNow: true); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
private void OnCommandFailed(object sender, CommandEventArgs e) => RunCallback("CommandFailed", () => Complete(false, "AUTOMATION_COMMAND_FAILED:" + e.GlobalCommandName));
|
||||||
|
private void OnCommandCancelled(object sender, CommandEventArgs e) => RunCallback("CommandCancelled", () => Complete(false, "AUTOMATION_COMMAND_CANCELLED:" + e.GlobalCommandName));
|
||||||
|
|
||||||
|
private int Dbmod() => Convert.ToInt32(AcadApplication.GetSystemVariable("DBMOD"));
|
||||||
|
private AutomationEntityCounts Counts()
|
||||||
|
{
|
||||||
|
if (document is null) throw new InvalidOperationException("AUTOMATION_DOCUMENT_UNAVAILABLE");
|
||||||
|
using var tx = document.Database.TransactionManager.StartOpenCloseTransaction();
|
||||||
|
var table = (BlockTable)tx.GetObject(document.Database.BlockTableId, OpenMode.ForRead);
|
||||||
|
var paper = (BlockTableRecord)tx.GetObject(table[BlockTableRecord.PaperSpace], OpenMode.ForRead);
|
||||||
|
var total = 0; var pipes = 0; var xdata = 0; var owned = 0;
|
||||||
|
foreach (ObjectId id in paper)
|
||||||
|
{
|
||||||
|
total++;
|
||||||
|
if (tx.GetObject(id, OpenMode.ForRead) is BlockReference block && string.Equals(block.Name, "PIPE", StringComparison.OrdinalIgnoreCase)) pipes++;
|
||||||
|
if (tx.GetObject(id, OpenMode.ForRead) is Entity entity)
|
||||||
|
{
|
||||||
|
using var data = entity.GetXDataForApplication("PXP-OA");
|
||||||
|
if (data is not null) { xdata++; if (data.AsArray().Any(x => x.TypeCode == 1000)) owned++; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new(total, pipes, xdata, owned);
|
||||||
|
}
|
||||||
|
private FirstApplyAutomationConfiguration ReadConfiguration()
|
||||||
|
{
|
||||||
|
if (!string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_AUTOMATION"), "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal)) throw new InvalidOperationException("AUTOMATION_DISABLED");
|
||||||
|
if (!string.Equals(RuntimeDiagnostics.RuntimeMode, "DryRun", StringComparison.OrdinalIgnoreCase) && !string.Equals(RuntimeDiagnostics.RuntimeMode, "Apply", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("AUTOMATION_MODE_BLOCKED");
|
||||||
|
var path = Environment.GetEnvironmentVariable("P_REC_ANNOT_AUTOMATION_CONFIG");
|
||||||
|
if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path) || path.StartsWith("\\\\", StringComparison.Ordinal)) throw new InvalidOperationException("AUTOMATION_CONFIGURATION_REQUIRED");
|
||||||
|
var loaded = JsonSerializer.Deserialize<FirstApplyAutomationConfiguration>(File.ReadAllText(path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? throw new InvalidOperationException("AUTOMATION_CONFIGURATION_INVALID");
|
||||||
|
if (!File.Exists(loaded.FixtureMarkerPath) || !string.Equals(File.ReadAllText(loaded.FixtureMarkerPath).Trim(), "P_REC_ANNOT_TEST_FIXTURE=FIRST_LIMITED_APPLY", StringComparison.Ordinal)) throw new InvalidOperationException("AUTOMATION_FIXTURE_MARKER_INVALID");
|
||||||
|
if (!string.Equals(loaded.Phase, "LifecycleProbe", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
(string.IsNullOrWhiteSpace(loaded.OrthoDrawingPath) || !Path.GetFullPath(loaded.OrthoDrawingPath).StartsWith(Path.GetDirectoryName(Path.GetFullPath(loaded.FixtureMarkerPath))! + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
throw new InvalidOperationException("AUTOMATION_FIXTURE_OUTSIDE_MARKER_ROOT");
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
|
private void Complete(bool success, string? error, bool safeToExitNow = false)
|
||||||
|
{
|
||||||
|
if (IsTerminal) return;
|
||||||
|
functionalCompleted = success;
|
||||||
|
Transition(success ? CoordinatorState.Completed : CoordinatorState.Failed, success ? "Completed" : "Failed");
|
||||||
|
UnsubscribeHandlers();
|
||||||
|
TryWriteEvidence(success, error);
|
||||||
|
selectedTargetSnapshotId = null;
|
||||||
|
pxpResults.Clear();
|
||||||
|
RequestControlledExit(safeToExitNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The managed Application.Quit call can leave a startup-script host alive
|
||||||
|
// when it is invoked from a CommandEnded callback. Queue AutoCAD's native
|
||||||
|
// QUIT command instead; the command processor runs it after the current
|
||||||
|
// public PXP-OA command has fully returned and discards every open drawing.
|
||||||
|
private void RequestControlledExit(bool idleSafe = false)
|
||||||
|
{
|
||||||
|
if (exitQueued) return;
|
||||||
|
exitQueued = true;
|
||||||
|
Transition(CoordinatorState.ExitQueued, "ExitQueued");
|
||||||
|
if (idleSafe)
|
||||||
|
{
|
||||||
|
DispatchNativeQuit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AcadApplication.Idle += OnExitIdle;
|
||||||
|
exitHandlerSubscribed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnExitIdle(object? sender, EventArgs args)
|
||||||
|
{
|
||||||
|
RunCallback("ControlledExitIdle", DispatchNativeQuit);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DispatchNativeQuit()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (exitHandlerSubscribed) AcadApplication.Idle -= OnExitIdle;
|
||||||
|
exitHandlerSubscribed = false;
|
||||||
|
exitAttemptQueued = true;
|
||||||
|
if (!AcadApplication.IsQuiescent)
|
||||||
|
{
|
||||||
|
transitions.Add("QuitDispatchDeferredNotQuiescent");
|
||||||
|
TryWriteEvidence(functionalCompleted, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// LifecycleProbe opens no fixture document, but the real startup
|
||||||
|
// document is available at this point (observed as Drawing1.dwg).
|
||||||
|
// Use that live document only to queue native QUIT; never retain it
|
||||||
|
// past this callback or use it for fixture/Plant work.
|
||||||
|
var exitDocument = AcadApplication.DocumentManager.MdiActiveDocument;
|
||||||
|
if (exitDocument is null)
|
||||||
|
{
|
||||||
|
exitFailureCode = "AUTOMATION_EXIT_DOCUMENT_UNAVAILABLE";
|
||||||
|
transitions.Add("QuitDispatchSkippedNoLiveDocument");
|
||||||
|
TryWriteEvidence(functionalCompleted, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
transitions.Add("QuitDispatchAttempted");
|
||||||
|
// Persist the exit boundary before handing control back to AutoCAD;
|
||||||
|
// process termination can otherwise prevent the final evidence flush.
|
||||||
|
TryWriteEvidence(functionalCompleted, null);
|
||||||
|
exitDocument.SendStringToExecute("_.QUIT _N\n", true, true, false);
|
||||||
|
document = null;
|
||||||
|
automationOpenedDocuments.Clear();
|
||||||
|
}
|
||||||
|
catch (System.Exception exception)
|
||||||
|
{
|
||||||
|
// This is terminal cleanup: contain the exception and leave process
|
||||||
|
// cleanup to Launcher rather than re-entering document APIs.
|
||||||
|
// A failed graceful-exit dispatch is not a failure of already
|
||||||
|
// completed PXP-OA/UNDO work. Launcher owns bounded forced cleanup.
|
||||||
|
exitFailureCode = "AUTOMATION_CONTROLLED_EXIT_FAILED:" + exception.GetType().Name;
|
||||||
|
transitions.Add("QuitDispatchFailed");
|
||||||
|
TryWriteEvidence(functionalCompleted, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsTerminal => state is CoordinatorState.Completed or CoordinatorState.Failed or CoordinatorState.ExitQueued;
|
||||||
|
|
||||||
|
private void Transition(CoordinatorState next, string transition)
|
||||||
|
{
|
||||||
|
previousState = state;
|
||||||
|
state = next;
|
||||||
|
transitions.Add(transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RunCallback(string callback, Action action)
|
||||||
|
{
|
||||||
|
if (IsTerminal && callback != "ControlledExitIdle") return;
|
||||||
|
try { action(); }
|
||||||
|
catch (System.Exception exception) { FailFromCallback(callback, exception, "AUTOMATION_CALLBACK_FAILED"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FailFromCallback(string callback, System.Exception exception, string code)
|
||||||
|
{
|
||||||
|
exceptionText = exception.ToString();
|
||||||
|
RuntimeDiagnostics.Write("AutomationCallbackFailed", "ERROR", resultStatus: code + ":" + callback, exception: exception);
|
||||||
|
if (!IsTerminal) Complete(false, code + ":" + callback + ":" + exception.GetType().Name);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
UnsubscribeHandlers();
|
||||||
|
if (functionalCompleted)
|
||||||
|
{
|
||||||
|
exitFailureCode = code + ":" + callback + ":" + exception.GetType().Name;
|
||||||
|
transitions.Add("ExitCallbackFailed");
|
||||||
|
TryWriteEvidence(true, null);
|
||||||
|
}
|
||||||
|
else TryWriteEvidence(false, code + ":" + callback + ":" + exception.GetType().Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UnsubscribeHandlers()
|
||||||
|
{
|
||||||
|
if (documentHandlersSubscribed && document is not null)
|
||||||
|
{
|
||||||
|
try { document.CommandEnded -= OnCommandEnded; document.CommandFailed -= OnCommandFailed; document.CommandCancelled -= OnCommandCancelled; }
|
||||||
|
catch (System.Exception exception) { RuntimeDiagnostics.Write("AutomationHandlerUnsubscribeFailed", "ERROR", resultStatus: "AUTOMATION_UNSUBSCRIBE_FAILED", exception: exception); }
|
||||||
|
}
|
||||||
|
documentHandlersSubscribed = false;
|
||||||
|
if (lifecycleHandlersSubscribed)
|
||||||
|
{
|
||||||
|
try { AcadApplication.DocumentManager.DocumentActivated -= OnDocumentActivated; AcadApplication.Idle -= OnIdle; }
|
||||||
|
catch (System.Exception exception) { RuntimeDiagnostics.Write("AutomationHandlerUnsubscribeFailed", "ERROR", resultStatus: "AUTOMATION_UNSUBSCRIBE_FAILED", exception: exception); }
|
||||||
|
}
|
||||||
|
lifecycleHandlersSubscribed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryWriteEvidence(bool success, string? error)
|
||||||
|
{
|
||||||
|
try { evidenceFlushed = true; WriteEvidence(success, error); return true; }
|
||||||
|
catch (System.Exception exception) { evidenceFlushed = false; RuntimeDiagnostics.Write("AutomationEvidenceWriteFailed", "ERROR", resultStatus: "AUTOMATION_EVIDENCE_WRITE_FAILED", exception: exception); return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteEvidence(bool success, string? error)
|
||||||
|
{
|
||||||
|
if (config is null) return;
|
||||||
|
var first = pxpResults.ElementAtOrDefault(0); var second = pxpResults.ElementAtOrDefault(1);
|
||||||
|
var expectedCommands = string.Equals(config.Phase, "Apply", StringComparison.OrdinalIgnoreCase) ? 3 : 2;
|
||||||
|
var evidence = new FirstApplyAutomationEvidence { Phase = config.Phase, Completed = success, ErrorCode = error, DbmodBefore = before, DbmodAfterCommand1 = after1, DbmodAfterCommand2 = after2, DbmodAfterUndo = afterUndo, Baseline = baseline, AfterCommand1 = counts1, AfterCommand2 = counts2, AfterUndo = countsUndo, CompletedCommands = completed, PlanIdRun1 = first?.PlanId, PlanIdRun2 = second?.PlanId, PlanIdsEqual = first is not null && second is not null ? string.Equals(first.PlanId, second.PlanId, StringComparison.Ordinal) : null, EligibleCandidateCount = first?.Candidates.Count, SelectedCandidateHash = selectedTargetSnapshotId, ViewHash = first?.View, CreateRun1 = first?.Create, CreateRun2 = second?.Create, SkipRun2 = second?.Skip, ApplyOutcomeRun1 = first?.ApplyOutcome, ApplyOutcomeRun2 = second?.ApplyOutcome, PermitAccepted = first?.PermitAccepted, CommandsCompleted = completed.Count >= expectedCommands, InspectorsCompleted = baseline is not null && counts1 is not null && counts2 is not null && (!string.Equals(config.Phase, "Apply", StringComparison.OrdinalIgnoreCase) || countsUndo is not null), EvidenceFlushed = evidenceFlushed, ExitAttemptQueued = exitAttemptQueued, ExitFailureCode = exitFailureCode, IdleCallbackCount = FirstLimitedApplyAutomationBootstrap.IdleCallbackCount, CoordinatorStartCount = FirstLimitedApplyAutomationBootstrap.CoordinatorStartCount, ExceptionText = exceptionText, PreviousState = previousState.ToString(), CurrentState = state.ToString(), StateTransitions = transitions };
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(config.EvidencePath)!);
|
||||||
|
File.WriteAllText(config.EvidencePath, JsonSerializer.Serialize(evidence));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,9 +16,11 @@ namespace PRecAnnot.Plant3D2026;
|
|||||||
|
|
||||||
internal sealed class PlantRuntimeContextReader : IPlantRuntimeContextReader
|
internal sealed class PlantRuntimeContextReader : IPlantRuntimeContextReader
|
||||||
{
|
{
|
||||||
public PlantRuntimeContext ReadCurrentContext()
|
public PlantRuntimeContext ReadCurrentContext() => ReadCurrentContext(null);
|
||||||
|
|
||||||
|
public PlantRuntimeContext ReadCurrentContext(Document? explicitDocument = null)
|
||||||
{
|
{
|
||||||
var document = AcadApplication.DocumentManager.MdiActiveDocument;
|
var document = explicitDocument ?? AcadApplication.DocumentManager.MdiActiveDocument;
|
||||||
if (document is null)
|
if (document is null)
|
||||||
{
|
{
|
||||||
return Empty(false, false, "NO_ACTIVE_DOCUMENT", "Активный документ отсутствует.");
|
return Empty(false, false, "NO_ACTIVE_DOCUMENT", "Активный документ отсутствует.");
|
||||||
|
|||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Autodesk.AutoCAD.ApplicationServices;
|
||||||
|
using Autodesk.AutoCAD.DatabaseServices;
|
||||||
|
using Autodesk.AutoCAD.Runtime;
|
||||||
|
using Autodesk.ProcessPower.PlantInstance;
|
||||||
|
using Autodesk.ProcessPower.ProjectManager;
|
||||||
|
using AcadApplication = Autodesk.AutoCAD.ApplicationServices.Application;
|
||||||
|
|
||||||
|
namespace PRecAnnot.Plant3D2026;
|
||||||
|
|
||||||
|
// Test-only observer. The AutoCAD /b processor, never this type, drives PXP-OA.
|
||||||
|
internal static class ScriptDrivenFirstApplyAutomation
|
||||||
|
{
|
||||||
|
private sealed record Config(string Phase, string OrthoDrawingPath, string EvidencePath, string FixtureMarkerPath);
|
||||||
|
private sealed record Run(int Sequence, string? PlanId, IReadOnlyList<string> Candidates, string? View, int Create, int Skip, int? DbmodBefore, int? DbmodAfter, AutomationEntityCounts? Before, AutomationEntityCounts? After, bool WillStart, bool Ended, bool Failed, bool Cancelled, int? SourceRows, int? MembershipResolved);
|
||||||
|
private static Config? config;
|
||||||
|
private static Document? fixture;
|
||||||
|
private static Database? database;
|
||||||
|
private static AutomationEntityCounts? baseline;
|
||||||
|
private static int? dbmodBaseline;
|
||||||
|
private static readonly List<Run> runs = [];
|
||||||
|
private static readonly List<string> transitions = [];
|
||||||
|
private static bool prepared;
|
||||||
|
private static bool terminal;
|
||||||
|
|
||||||
|
internal static void Prepare()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (terminal) return;
|
||||||
|
config = ReadConfig();
|
||||||
|
RequireGate(config);
|
||||||
|
var active = AcadApplication.DocumentManager.MdiActiveDocument ?? throw new InvalidOperationException("AUTOMATION_ACTIVE_DOCUMENT_MISSING");
|
||||||
|
if (!SamePath(active.Name, config.OrthoDrawingPath)) throw new InvalidOperationException("AUTOMATION_UNEXPECTED_ACTIVE_FIXTURE");
|
||||||
|
fixture = active;
|
||||||
|
database = active.Database ?? throw new InvalidOperationException("AUTOMATION_FIXTURE_DATABASE_MISSING");
|
||||||
|
fixture.CommandWillStart += OnWillStart;
|
||||||
|
fixture.CommandEnded += OnEnded;
|
||||||
|
fixture.CommandFailed += OnFailed;
|
||||||
|
fixture.CommandCancelled += OnCancelled;
|
||||||
|
var projectXml = Path.Combine(Path.GetDirectoryName(config.FixtureMarkerPath)!, "Project.xml");
|
||||||
|
if (!File.Exists(projectXml)) throw new InvalidOperationException("AUTOMATION_PROJECT_XML_MISSING");
|
||||||
|
var project = PlantProject.LoadProject(projectXml, false, string.Empty, string.Empty);
|
||||||
|
PlantApplication.SetCurrentProject(project, true);
|
||||||
|
var context = new PlantRuntimeContextReader().ReadCurrentContext(fixture);
|
||||||
|
if (!context.HasPlantProject || !context.IsOrthoDrawing || context.Viewports.Count != 1)
|
||||||
|
throw new InvalidOperationException("AUTOMATION_ORTHO_CONTEXT_INVALID");
|
||||||
|
dbmodBaseline = Dbmod();
|
||||||
|
baseline = Counts(database);
|
||||||
|
prepared = true;
|
||||||
|
transitions.Add("Prepared");
|
||||||
|
Write(false, null);
|
||||||
|
}
|
||||||
|
catch (System.Exception exception) { Fail("AUTOMATION_PREPARE_FAILED", exception); }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void RecordPxpOa(string? planId, IReadOnlyList<string> candidates, string? view, int create, int skip, bool? permitAccepted, int sourceRows, int membershipResolved)
|
||||||
|
{
|
||||||
|
if (!prepared || terminal || runs.Count == 0) return;
|
||||||
|
var last = runs[^1];
|
||||||
|
if (!last.WillStart || last.PlanId is not null) return;
|
||||||
|
runs[^1] = last with { PlanId = planId, Candidates = candidates, View = view, Create = create, Skip = skip, SourceRows = sourceRows, MembershipResolved = membershipResolved };
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void FinalizePreview()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!prepared) throw new InvalidOperationException("AUTOMATION_NOT_PREPARED");
|
||||||
|
if (runs.Count != 2) throw new InvalidOperationException("AUTOMATION_EXPECTS_EXACTLY_TWO_PXP_OA");
|
||||||
|
if (runs.Any(x => !x.WillStart || !x.Ended || x.Failed || x.Cancelled)) throw new InvalidOperationException("AUTOMATION_PXP_OA_NOT_ENDED");
|
||||||
|
if (runs.Any(x => string.IsNullOrWhiteSpace(x.PlanId)) || !string.Equals(runs[0].PlanId, runs[1].PlanId, StringComparison.Ordinal)) throw new InvalidOperationException("AUTOMATION_PLAN_IDS_DIFFER");
|
||||||
|
if (runs.Any(x => x.Create != 1)) throw new InvalidOperationException("AUTOMATION_PREVIEW_CREATE_NOT_ONE");
|
||||||
|
if (runs[0].Candidates.Count == 0) throw new InvalidOperationException("AUTOMATION_NO_ELIGIBLE_CANDIDATE");
|
||||||
|
if (Dbmod() != dbmodBaseline || !Equals(Counts(database!), baseline)) throw new InvalidOperationException("AUTOMATION_INSPECTOR_INVARIANCE_FAILED");
|
||||||
|
terminal = true;
|
||||||
|
transitions.Add("PreviewCompleted");
|
||||||
|
Write(true, null);
|
||||||
|
Unsubscribe();
|
||||||
|
}
|
||||||
|
catch (System.Exception exception) { Fail("PREVIEW_ACCEPTANCE_FAILED", exception); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnWillStart(object sender, CommandEventArgs args)
|
||||||
|
{
|
||||||
|
Guard(() =>
|
||||||
|
{
|
||||||
|
if (!IsFixture(sender) || !IsPxp(args.GlobalCommandName) || runs.Count >= 2) return;
|
||||||
|
runs.Add(new Run(runs.Count + 1, null, [], null, 0, 0, Dbmod(), null, Counts(database!), null, true, false, false, false, null, null));
|
||||||
|
transitions.Add("PxpOaWillStart:" + runs.Count);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
private static void OnEnded(object sender, CommandEventArgs args) => Finish(sender, args, false, false);
|
||||||
|
private static void OnFailed(object sender, CommandEventArgs args) => Finish(sender, args, true, false);
|
||||||
|
private static void OnCancelled(object sender, CommandEventArgs args) => Finish(sender, args, false, true);
|
||||||
|
private static void Finish(object sender, CommandEventArgs args, bool failed, bool cancelled)
|
||||||
|
{
|
||||||
|
Guard(() =>
|
||||||
|
{
|
||||||
|
if (!IsFixture(sender) || !IsPxp(args.GlobalCommandName) || runs.Count == 0) return;
|
||||||
|
var last = runs[^1];
|
||||||
|
if (last.Ended || last.Failed || last.Cancelled) return;
|
||||||
|
runs[^1] = last with { Ended = !failed && !cancelled, Failed = failed, Cancelled = cancelled, DbmodAfter = Dbmod(), After = Counts(database!) };
|
||||||
|
transitions.Add(failed ? "PxpOaFailed" : cancelled ? "PxpOaCancelled" : "PxpOaEnded:" + last.Sequence);
|
||||||
|
Write(false, null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
private static bool IsFixture(object sender) => !terminal && fixture is not null && ReferenceEquals(sender, fixture) && database is not null && ReferenceEquals(fixture.Database, database);
|
||||||
|
private static bool IsPxp(string name) => string.Equals(name.TrimStart('.', '_'), "PXP-OA", StringComparison.OrdinalIgnoreCase);
|
||||||
|
private static void Guard(Action action) { try { if (!terminal) action(); } catch (System.Exception ex) { Fail("AUTOMATION_EVENT_HANDLER_FAILED", ex); } }
|
||||||
|
private static void Fail(string code, System.Exception exception) { if (terminal) return; terminal = true; transitions.Add("Failed:" + code); try { Write(false, code + ":" + exception); } finally { Unsubscribe(); } }
|
||||||
|
private static void Unsubscribe() { if (fixture is null) return; try { fixture.CommandWillStart -= OnWillStart; fixture.CommandEnded -= OnEnded; fixture.CommandFailed -= OnFailed; fixture.CommandCancelled -= OnCancelled; } catch { } }
|
||||||
|
private static int? Dbmod() { try { return Convert.ToInt32(AcadApplication.GetSystemVariable("DBMOD")); } catch { return null; } }
|
||||||
|
private static AutomationEntityCounts Counts(Database db)
|
||||||
|
{
|
||||||
|
using var tx = db.TransactionManager.StartTransaction();
|
||||||
|
var space = (BlockTableRecord)tx.GetObject(db.CurrentSpaceId, OpenMode.ForRead);
|
||||||
|
var total = 0; var pipes = 0; var xdata = 0; var owned = 0;
|
||||||
|
foreach (ObjectId id in space) { if (tx.GetObject(id, OpenMode.ForRead) is not Entity entity) continue; total++; if (entity is BlockReference br && string.Equals(br.Name, "PIPE", StringComparison.OrdinalIgnoreCase)) pipes++; if (entity.XData is not null) { foreach (TypedValue value in entity.XData) if (value.Value is string text && string.Equals(text, "PXP-OA", StringComparison.OrdinalIgnoreCase)) { xdata++; owned++; break; } } }
|
||||||
|
return new(total, pipes, xdata, owned);
|
||||||
|
}
|
||||||
|
private static Config ReadConfig()
|
||||||
|
{
|
||||||
|
var path = Environment.GetEnvironmentVariable("P_REC_ANNOT_AUTOMATION_CONFIG") ?? throw new InvalidOperationException("AUTOMATION_CONFIG_MISSING");
|
||||||
|
using var json = JsonDocument.Parse(File.ReadAllText(path)); var root = json.RootElement;
|
||||||
|
return new(root.GetProperty("phase").GetString() ?? "Preview", root.GetProperty("orthoDrawingPath").GetString() ?? throw new InvalidOperationException("AUTOMATION_ORTHO_PATH_REQUIRED"), root.GetProperty("evidencePath").GetString() ?? throw new InvalidOperationException("AUTOMATION_EVIDENCE_PATH_REQUIRED"), root.GetProperty("fixtureMarkerPath").GetString() ?? throw new InvalidOperationException("AUTOMATION_MARKER_PATH_REQUIRED"));
|
||||||
|
}
|
||||||
|
private static void RequireGate(Config value)
|
||||||
|
{
|
||||||
|
if (!string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_AUTOMATION"), "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal) || !string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_LAUNCHER_MODE"), "NextPilot", StringComparison.Ordinal) || !File.Exists(value.FixtureMarkerPath)) throw new InvalidOperationException("AUTOMATION_GATE_REJECTED");
|
||||||
|
}
|
||||||
|
private static bool SamePath(string left, string right) => string.Equals(Path.GetFullPath(left), Path.GetFullPath(right), StringComparison.OrdinalIgnoreCase);
|
||||||
|
private static void Write(bool completed, string? error)
|
||||||
|
{
|
||||||
|
if (config is null) return;
|
||||||
|
var first = runs.ElementAtOrDefault(0); var second = runs.ElementAtOrDefault(1);
|
||||||
|
var evidence = new FirstApplyAutomationEvidence { Phase = config.Phase, Completed = completed, Prepared = prepared, FunctionalEvidenceCompleted = completed, ErrorCode = error, DbmodBefore = dbmodBaseline, DbmodAfterCommand1 = first?.DbmodAfter, DbmodAfterCommand2 = second?.DbmodAfter, Baseline = baseline, AfterCommand1 = first?.After, AfterCommand2 = second?.After, CompletedCommands = runs.Where(x => x.Ended).Select(x => "PXP-OA").ToArray(), PlanIdRun1 = first?.PlanId, PlanIdRun2 = second?.PlanId, PlanIdsEqual = first is not null && second is not null ? string.Equals(first.PlanId, second.PlanId, StringComparison.Ordinal) : null, EligibleCandidateCount = first?.Candidates.Count, SelectedCandidateHash = first?.Candidates.OrderBy(x => x, StringComparer.Ordinal).FirstOrDefault(), ViewHash = first?.View, CreateRun1 = first?.Create, CreateRun2 = second?.Create, SkipRun2 = second?.Skip, CommandsCompleted = runs.Count == 2 && runs.All(x => x.Ended), InspectorsCompleted = baseline is not null && first?.After is not null && second?.After is not null, EvidenceFlushed = true, CurrentState = terminal ? (completed ? "Completed" : "Failed") : "Prepared", StateTransitions = transitions };
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(config.EvidencePath)!);
|
||||||
|
var temp = config.EvidencePath + ".tmp"; File.WriteAllText(temp, JsonSerializer.Serialize(evidence)); File.Move(temp, config.EvidencePath, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,170 @@ public sealed class FoundationTests
|
|||||||
Assert.Contains("[CommandMethod(\"PHP_REC_ANNOT\")]", source);
|
Assert.Contains("[CommandMethod(\"PHP_REC_ANNOT\")]", source);
|
||||||
Assert.Contains("[CommandMethod(\"PXP-AAV\")]", source);
|
Assert.Contains("[CommandMethod(\"PXP-AAV\")]", source);
|
||||||
Assert.Contains("[CommandMethod(\"PXP-OA\")]", source);
|
Assert.Contains("[CommandMethod(\"PXP-OA\")]", source);
|
||||||
|
Assert.Contains("PHP_REC_AUTOMATION_READY", source);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void AutomationBootstrap_DefersPlantAndDocumentAccessUntilCoordinator()
|
||||||
|
{
|
||||||
|
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
|
||||||
|
var bootstrap = source[..source.IndexOf("internal sealed record FirstApplyAutomationEvidence", StringComparison.Ordinal)];
|
||||||
|
Assert.DoesNotContain("PlantProject.LoadProject", bootstrap);
|
||||||
|
Assert.DoesNotContain("MdiActiveDocument", bootstrap);
|
||||||
|
Assert.Contains("AcadApplication.Idle += OnIdle", bootstrap);
|
||||||
|
Assert.Contains("observedIdle = true", bootstrap);
|
||||||
|
Assert.Contains("coordinatorStartCount++", bootstrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void AutomationLifecycleProbe_CompletesBeforePlantActivation()
|
||||||
|
{
|
||||||
|
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
|
||||||
|
Assert.Contains("LifecycleProbe", source);
|
||||||
|
Assert.True(source.IndexOf("CompleteLifecycleProbe", StringComparison.Ordinal) < source.IndexOf("PlantProject.LoadProject", StringComparison.Ordinal));
|
||||||
|
Assert.Contains("PostStartupReady", source);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void AutomationProjectActivation_WaitsForFixtureDocumentAndQuiescence()
|
||||||
|
{
|
||||||
|
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
|
||||||
|
var activation = source[source.IndexOf("private void TryActivateProjectAndVerifyContext", StringComparison.Ordinal)..];
|
||||||
|
Assert.Contains("!fixtureDocumentActivated", activation);
|
||||||
|
Assert.Contains("!AcadApplication.IsQuiescent", activation);
|
||||||
|
Assert.True(activation.IndexOf("PlantProject.LoadProject", StringComparison.Ordinal) > activation.IndexOf("AutomationBeforeProjectActivation", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void AutomationDocumentActivation_IsSubscribedBeforeFixtureOpen()
|
||||||
|
{
|
||||||
|
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
|
||||||
|
Assert.True(source.IndexOf("DocumentActivated += OnDocumentActivated", StringComparison.Ordinal) < source.IndexOf("DocumentManager.Open(config.OrthoDrawingPath", StringComparison.Ordinal));
|
||||||
|
Assert.Contains("FixtureDocumentActivated", source);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void AutomationDocumentActivation_RecoversSynchronousOpenEvent()
|
||||||
|
{
|
||||||
|
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
|
||||||
|
var start = source[source.IndexOf("public void Start()", StringComparison.Ordinal)..source.IndexOf("private void CompleteLifecycleProbe", StringComparison.Ordinal)];
|
||||||
|
Assert.Contains("FixtureDocumentActiveAfterOpen", start);
|
||||||
|
Assert.Contains("fixtureDocumentActivated = true", start);
|
||||||
|
Assert.Contains("QueueReadinessBarrier();", start);
|
||||||
|
Assert.Contains("ReadinessBarrierCompleted", source);
|
||||||
|
Assert.Contains("TryActivateProjectAndVerifyContext();", source);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void QueueReadinessBarrier_RequiresActiveFixtureAndUsesSafeDispatch()
|
||||||
|
{
|
||||||
|
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
|
||||||
|
var dispatcher = source[source.IndexOf("private async Task DispatchCommandAsync", StringComparison.Ordinal)..source.IndexOf("private void OnCommandEnded", StringComparison.Ordinal)];
|
||||||
|
Assert.Contains("ExecuteInCommandContextAsync", dispatcher);
|
||||||
|
Assert.Contains("MdiActiveDocument", dispatcher);
|
||||||
|
Assert.Contains("AUTOMATION_COMMAND_DOCUMENT_NOT_ACTIVE", dispatcher);
|
||||||
|
Assert.Contains("Editor.CommandAsync", dispatcher);
|
||||||
|
Assert.DoesNotContain("SendStringToExecute", dispatcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void AutomationControlledExit_QueuesNativeQuitAfterPublicCommand()
|
||||||
|
{
|
||||||
|
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
|
||||||
|
Assert.Contains("RequestControlledExit", source);
|
||||||
|
Assert.Contains("AcadApplication.Idle += OnExitIdle", source);
|
||||||
|
Assert.Contains("QuitDispatchAttempted", source);
|
||||||
|
Assert.Contains("exitDocument.SendStringToExecute(\"_.QUIT _N\\n\", true, true, false);", source);
|
||||||
|
Assert.Contains("var exitDocument = AcadApplication.DocumentManager.MdiActiveDocument;", source);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void Callback_AfterDocumentDestroyed_DoesNotAccessDocument()
|
||||||
|
{
|
||||||
|
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
|
||||||
|
Assert.Contains("if (document is null || e.Document is null) return;", source);
|
||||||
|
Assert.Contains("if (IsTerminal || document is null) return;", source);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void Idle_WhenActiveDocumentNull_DoesNotThrow()
|
||||||
|
{
|
||||||
|
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
|
||||||
|
Assert.Contains("AUTOMATION_EXIT_DOCUMENT_UNAVAILABLE", source);
|
||||||
|
Assert.Contains("QuitDispatchSkippedNoLiveDocument", source);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void TerminalState_IgnoresFurtherCallbacks()
|
||||||
|
{
|
||||||
|
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
|
||||||
|
Assert.Contains("private bool IsTerminal", source);
|
||||||
|
Assert.Contains("if (IsTerminal && callback != \"ControlledExitIdle\") return;", source);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void HandlerException_ProducesFailedEvidence()
|
||||||
|
{
|
||||||
|
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
|
||||||
|
Assert.Contains("exceptionText = exception.ToString();", source);
|
||||||
|
Assert.Contains("ExceptionText = exceptionText", source);
|
||||||
|
Assert.Contains("FailFromCallback", source);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void HandlerException_DoesNotEscapeToAutoCad()
|
||||||
|
{
|
||||||
|
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
|
||||||
|
Assert.Contains("private void RunCallback", source);
|
||||||
|
Assert.Contains("catch (System.Exception exception) { FailFromCallback", source);
|
||||||
|
Assert.Contains("=> RunCallback(\"Idle\"", source);
|
||||||
|
Assert.Contains("=> RunCallback(\"CommandFailed\"", source);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void Unsubscribe_IsIdempotent()
|
||||||
|
{
|
||||||
|
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
|
||||||
|
Assert.Contains("private bool documentHandlersSubscribed", source);
|
||||||
|
Assert.Contains("private bool lifecycleHandlersSubscribed", source);
|
||||||
|
Assert.Contains("private void UnsubscribeHandlers()", source);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void ControlledExit_DoesNotReuseDestroyedDocument()
|
||||||
|
{
|
||||||
|
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
|
||||||
|
var controlledExit = source[source.IndexOf("private void RequestControlledExit", StringComparison.Ordinal)..];
|
||||||
|
Assert.DoesNotContain("document.CloseAndDiscard", controlledExit);
|
||||||
|
Assert.Contains("var exitDocument = AcadApplication.DocumentManager.MdiActiveDocument;", controlledExit);
|
||||||
|
Assert.Contains("document = null;", controlledExit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void FinalCommandCompletion_UsesItsQuiescentExitBoundary()
|
||||||
|
{
|
||||||
|
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
|
||||||
|
Assert.Contains("Complete(true, null, safeToExitNow: true)", source);
|
||||||
|
Assert.Contains("RequestControlledExit(safeToExitNow);", source);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void TerminalEvidence_IsFlushedBeforeExitAttempt()
|
||||||
|
{
|
||||||
|
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
|
||||||
|
var exit = source[source.IndexOf("private void DispatchNativeQuit", StringComparison.Ordinal)..];
|
||||||
|
Assert.True(exit.IndexOf("TryWriteEvidence(functionalCompleted, null);", StringComparison.Ordinal) < exit.IndexOf("SendStringToExecute(\"_.QUIT _N\\n", StringComparison.Ordinal));
|
||||||
|
Assert.Contains("EvidenceFlushed = evidenceFlushed", source);
|
||||||
|
Assert.Contains("CommandsCompleted", source);
|
||||||
|
Assert.Contains("InspectorsCompleted", source);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void ExitFailure_DoesNotInvalidateCompletedFunctionalEvidence()
|
||||||
|
{
|
||||||
|
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
|
||||||
|
Assert.Contains("if (functionalCompleted)", source);
|
||||||
|
Assert.Contains("ExitFailureCode = exitFailureCode", source);
|
||||||
|
Assert.Contains("TryWriteEvidence(functionalCompleted, null);", source);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void ExitDispatch_DoesNotUseUnavailableDocument()
|
||||||
|
{
|
||||||
|
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
|
||||||
|
var exit = source[source.IndexOf("private void DispatchNativeQuit", StringComparison.Ordinal)..];
|
||||||
|
Assert.Contains("AcadApplication.IsQuiescent", exit);
|
||||||
|
Assert.Contains("QuitDispatchSkippedNoLiveDocument", exit);
|
||||||
|
Assert.DoesNotContain("document ?? AcadApplication.DocumentManager", exit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] public void ProductionRuntime_DoesNotEnableForceAfterEvidence()
|
||||||
|
{
|
||||||
|
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "FirstLimitedApplyAutomation.cs"));
|
||||||
|
Assert.DoesNotContain("ForceAfterEvidence", source);
|
||||||
|
Assert.Contains("P_REC_ANNOT_AUTOMATION", source);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact] public void PrimaryCommand_IsRegistered() => Assert.Contains("[CommandMethod(\"PHP_REC_ANNOT\")]", File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "EntryPoint.cs")));
|
[Fact] public void PrimaryCommand_IsRegistered() => Assert.Contains("[CommandMethod(\"PHP_REC_ANNOT\")]", File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "EntryPoint.cs")));
|
||||||
|
|||||||
Reference in New Issue
Block a user