diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/FirstLimitedApplyAutomation.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/FirstLimitedApplyAutomation.cs index 44729bf..9660cfd 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/FirstLimitedApplyAutomation.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/FirstLimitedApplyAutomation.cs @@ -23,6 +23,7 @@ internal sealed record FirstApplyAutomationConfiguration internal sealed record AutomationEntityCounts(int Total, int PipeBlocks, int PxpOaXdata, int Owned); internal sealed record PxpOaDiscoveryEvidence(bool Found, string? GlobalName, string? GroupName, string? Flags, string? DeclaringType, string? DeclaringAssembly, string? AssemblyLocation, string? AssemblySha256, bool AssemblyLoaded, bool CommandClassIncluded, bool BundleHashMatched, int DeclarationCount); internal sealed record AutomationSentinelEvidence(string Stage, string? DocumentIdentity, string? DatabaseIdentity, DateTimeOffset TimestampUtc); +internal sealed record AutomationCommandCorrelationEvidence(string EventKind, string RawCommandName, string NormalizedCommandName, bool CommandNameMatched, bool PreparedStateMatched, bool DocumentMatched, bool DatabaseMatched, bool FixturePathMatched, bool SequenceAccepted, string? RejectionReason, string? SenderType, string? SenderDocumentPath, string? ExpectedDocumentPath, string? ActiveDocumentPath, int RunCount, DateTimeOffset TimestampUtc); internal static class FirstLimitedApplyAutomationBootstrap { @@ -127,6 +128,7 @@ internal sealed record FirstApplyAutomationEvidence public PxpOaDiscoveryEvidence? PxpOaDiscovery { get; init; } public IReadOnlyList Sentinels { get; init; } = Array.Empty(); public IReadOnlyList CommandEvents { get; init; } = Array.Empty(); + public IReadOnlyList CommandCorrelations { get; init; } = Array.Empty(); } internal sealed class FirstLimitedApplyAutomationCoordinator diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/ScriptDrivenFirstApplyAutomation.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/ScriptDrivenFirstApplyAutomation.cs index 87d5400..c8a68ba 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/ScriptDrivenFirstApplyAutomation.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/ScriptDrivenFirstApplyAutomation.cs @@ -24,6 +24,7 @@ internal static class ScriptDrivenFirstApplyAutomation private static readonly List transitions = []; private static readonly List commandEvents = []; private static readonly List sentinels = []; + private static readonly List correlations = []; private static PxpOaDiscoveryEvidence? discovery; private static bool prepared; private static bool terminal; @@ -104,7 +105,9 @@ internal static class ScriptDrivenFirstApplyAutomation Guard(() => { commandEvents.Add("WillStart:" + args.GlobalCommandName); - if (!IsFixture(sender) || !IsPxp(args.GlobalCommandName) || runs.Count >= 2) return; + var correlation = Correlate("WillStart", sender, args.GlobalCommandName, runs.Count < 2); + correlations.Add(correlation); + if (correlation.RejectionReason is not null) 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); }); @@ -116,8 +119,11 @@ internal static class ScriptDrivenFirstApplyAutomation { Guard(() => { - commandEvents.Add((failed ? "Failed:" : cancelled ? "Cancelled:" : "Ended:") + args.GlobalCommandName); - if (!IsFixture(sender) || !IsPxp(args.GlobalCommandName) || runs.Count == 0) return; + var kind = failed ? "Failed" : cancelled ? "Cancelled" : "Ended"; + commandEvents.Add(kind + ":" + args.GlobalCommandName); + var correlation = Correlate(kind, sender, args.GlobalCommandName, runs.Count > 0); + correlations.Add(correlation); + if (correlation.RejectionReason is not null) 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!) }; @@ -125,8 +131,22 @@ internal static class ScriptDrivenFirstApplyAutomation 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 AutomationCommandCorrelationEvidence Correlate(string kind, object sender, string rawName, bool sequenceAccepted) + { + var normalized = rawName.Trim(); + var command = string.Equals(normalized, "PXP-OA", StringComparison.OrdinalIgnoreCase); + var state = prepared && !terminal && config is not null && fixture is not null && database is not null; + var senderDocument = sender as Document; + var senderPath = senderDocument?.Name; + var expectedPath = config?.OrthoDrawingPath; + var activePath = AcadApplication.DocumentManager.MdiActiveDocument?.Name; + var fixturePath = senderPath is not null && expectedPath is not null && SamePath(senderPath, expectedPath); + var activeFixture = activePath is not null && expectedPath is not null && SamePath(activePath, expectedPath); + var document = fixturePath && activeFixture; + var databaseMatch = senderDocument?.Database is not null && database is not null && SamePath(senderDocument.Database.Filename, database.Filename); + var rejection = !command ? "COMMAND_NAME_MISMATCH" : !state ? "NOT_PREPARED" : !document ? "DOCUMENT_IDENTITY_MISMATCH" : !databaseMatch ? "DATABASE_IDENTITY_MISMATCH" : !sequenceAccepted ? "UNEXPECTED_SEQUENCE" : null; + return new(kind, rawName, normalized, command, state, document, databaseMatch, fixturePath, sequenceAccepted, rejection, sender.GetType().FullName, senderPath, expectedPath, activePath, runs.Count, DateTimeOffset.UtcNow); + } 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 { } } @@ -154,7 +174,7 @@ internal static class ScriptDrivenFirstApplyAutomation { 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, PxpOaDiscovery = discovery, Sentinels = sentinels, CommandEvents = commandEvents }; + 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, PxpOaDiscovery = discovery, Sentinels = sentinels, CommandEvents = commandEvents, CommandCorrelations = correlations }; Directory.CreateDirectory(Path.GetDirectoryName(config.EvidencePath)!); var temp = config.EvidencePath + ".tmp"; File.WriteAllText(temp, JsonSerializer.Serialize(evidence)); File.Move(temp, config.EvidencePath, true); } diff --git a/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/PxpOaRegistrationTests.cs b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/PxpOaRegistrationTests.cs index 7f2a6c2..528f7d9 100644 --- a/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/PxpOaRegistrationTests.cs +++ b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/PxpOaRegistrationTests.cs @@ -28,4 +28,9 @@ public sealed class PxpOaRegistrationTests [Fact] public void Prepare_StopsOnPxpOaDiscoveryFailure() => Assert.True(Observer.IndexOf("RequireDiscovery(discovery)", StringComparison.Ordinal) < Observer.IndexOf("PlantProject.LoadProject", StringComparison.Ordinal)); [Fact] public void AutomationSentinel_DoesNotModifyDatabase() => Assert.DoesNotContain("StartTransaction", Entry[Entry.IndexOf("AutomationBeforePxp1Command", StringComparison.Ordinal)..Entry.IndexOf("private static void RunDiagnostic", StringComparison.Ordinal)]); [Fact] public void AutomationSentinel_IsTestOnly() => Assert.Contains("RequireGate", Observer); + [Fact] public void Observer_RecordsRawEventBeforeCorrelation() => Assert.True(Observer.IndexOf("commandEvents.Add", Observer.IndexOf("OnWillStart", StringComparison.Ordinal), StringComparison.Ordinal) < Observer.IndexOf("Correlate(\"WillStart\"", StringComparison.Ordinal)); + [Fact] public void Observer_RecordsEveryRejectionReason() => Assert.Contains("DOCUMENT_IDENTITY_MISMATCH", Observer); + [Fact] public void Observer_DoesNotUseManagedDocumentReferenceAsAcceptance() => Assert.DoesNotContain("ReferenceEquals(sender, fixture)", Observer); + [Fact] public void Observer_RecordsExactlyTwoSequentialRuns() => Assert.Contains("runs.Count < 2", Observer); + [Fact] public void Finalize_ReportsMissingPlanSeparatelyFromMissingRuns() => Assert.True(Observer.IndexOf("AUTOMATION_EXPECTS_EXACTLY_TWO_PXP_OA", StringComparison.Ordinal) < Observer.IndexOf("AUTOMATION_PLAN_IDS_DIFFER", StringComparison.Ordinal)); }