feat: add deterministic annotation plan canonicalization
This commit is contained in:
@@ -58,8 +58,12 @@ public sealed record PlantObjectSnapshot
|
||||
public required GeometrySnapshot Geometry { get; init; }
|
||||
public IReadOnlyDictionary<string, string?> Properties { get; init; } = new Dictionary<string, string?>();
|
||||
public IReadOnlyList<string> Diagnostics { get; init; } = Array.Empty<string>();
|
||||
public ViewMembershipStatus MembershipStatus { get; init; } = ViewMembershipStatus.NotResolved;
|
||||
public IReadOnlyList<string> MembershipEvidenceCodes { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
public enum ViewMembershipStatus { NotResolved, Resolved, PartiallyResolved, ObjectOutsideView, Unsupported, Failed }
|
||||
|
||||
public sealed record PlantRuntimeContext
|
||||
{
|
||||
public bool HasActiveDocument { get; init; }
|
||||
@@ -206,6 +210,7 @@ public sealed record AnnotationIntent
|
||||
|
||||
public sealed record AnnotationPlan
|
||||
{
|
||||
public int PlanSchemaVersion { get; init; }
|
||||
public string PlanId { get; init; } = string.Empty;
|
||||
public string CommandName { get; init; } = string.Empty;
|
||||
public IReadOnlyList<AnnotationOperationPlan> Operations { get; init; } = Array.Empty<AnnotationOperationPlan>();
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace PRecAnnot.Core;
|
||||
|
||||
public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder
|
||||
{
|
||||
public const int CurrentPlanSchemaVersion = 1;
|
||||
public AnnotationPlan Build(AnnotationPlanningRequest request)
|
||||
{
|
||||
var objects = request.Objects.OrderBy(x => x.SnapshotId, StringComparer.Ordinal).ToArray();
|
||||
@@ -30,6 +31,12 @@ public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.MembershipStatus is not ViewMembershipStatus.Resolved and not ViewMembershipStatus.PartiallyResolved)
|
||||
{
|
||||
operations.Add(new AnnotationOperationPlan { SourceSnapshotId = item.SnapshotId, Kind = AnnotationOperationKind.Skip, Reasons = new[] { "VIEW_MEMBERSHIP_NOT_RESOLVED" } });
|
||||
continue;
|
||||
}
|
||||
|
||||
var text = string.IsNullOrWhiteSpace(item.LineNumber) ? item.Tag?.Trim() : item.LineNumber!.Trim();
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
@@ -84,7 +91,8 @@ public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder
|
||||
|
||||
return new AnnotationPlan
|
||||
{
|
||||
PlanId = BuildPlanId(request, objects),
|
||||
PlanSchemaVersion = CurrentPlanSchemaVersion,
|
||||
PlanId = BuildPlanId(request, objects, operations),
|
||||
CommandName = request.CommandName,
|
||||
Operations = operations,
|
||||
Diagnostics = diagnostics,
|
||||
@@ -92,18 +100,31 @@ public sealed class AnnotationPlanBuilder : IAnnotationPlanBuilder
|
||||
Create = creates,
|
||||
Update = updates,
|
||||
Delete = Array.Empty<string>(),
|
||||
SkipReasons = operations.Where(x => x.Kind == AnnotationOperationKind.Skip).SelectMany(x => x.Reasons).ToArray(),
|
||||
SkipReasons = operations.Where(x => x.Kind == AnnotationOperationKind.Skip).SelectMany(x => x.Reasons).OrderBy(x => x, StringComparer.Ordinal).ToArray(),
|
||||
ExaminedCount = objects.Length,
|
||||
IsDeterministic = true
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildPlanId(AnnotationPlanningRequest request, IReadOnlyList<PlantObjectSnapshot> objects)
|
||||
private static string BuildPlanId(AnnotationPlanningRequest request, IReadOnlyList<PlantObjectSnapshot> objects, IReadOnlyList<AnnotationOperationPlan> operations)
|
||||
{
|
||||
var canonical = new StringBuilder(request.CommandName).Append('|').Append(request.Viewport.SourceDrawingName).Append('|').Append(request.Viewport.Id);
|
||||
foreach (var item in objects)
|
||||
canonical.Append('|').Append(item.SnapshotId).Append('|').Append(item.ObjectClass).Append('|').Append(item.Tag).Append('|').Append(item.LineNumber).Append('|').Append(item.Geometry.PaperPosition);
|
||||
var canonical = new StringBuilder("schema=").Append(CurrentPlanSchemaVersion)
|
||||
.Append("|command=").Append(Normalize(request.CommandName))
|
||||
.Append("|source=").Append(Normalize(Path.GetFileName(request.Viewport.SourceDrawingName)))
|
||||
.Append("|view=").Append(Normalize(request.Viewport.Id));
|
||||
foreach (var item in objects.OrderBy(x => x.SnapshotId, StringComparer.Ordinal))
|
||||
{
|
||||
canonical.Append("|object=").Append(Normalize(item.SnapshotId)).Append('|').Append(Normalize(item.ObjectClass))
|
||||
.Append('|').Append(Normalize(item.Tag)).Append('|').Append(Normalize(item.LineNumber))
|
||||
.Append('|').Append(item.MembershipStatus);
|
||||
foreach (var property in item.Properties.OrderBy(x => x.Key, StringComparer.Ordinal))
|
||||
canonical.Append("|property=").Append(Normalize(property.Key)).Append('=').Append(Normalize(property.Value));
|
||||
}
|
||||
foreach (var operation in operations.OrderBy(x => x.SourceSnapshotId, StringComparer.Ordinal).ThenBy(x => x.Kind))
|
||||
canonical.Append("|operation=").Append(Normalize(operation.SourceSnapshotId)).Append(':').Append(operation.Kind).Append(':').Append(string.Join(',', operation.Reasons.OrderBy(x => x, StringComparer.Ordinal)));
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical.ToString()));
|
||||
return "plan-" + Convert.ToHexString(hash)[..24];
|
||||
}
|
||||
|
||||
private static string Normalize(string? value) => (value ?? string.Empty).Trim().Normalize(NormalizationForm.FormC);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ public sealed class AnnotationRuleEngine : IAnnotationRuleEngine
|
||||
ObjectClass = item.ClassName,
|
||||
Tag = item.Tag,
|
||||
LineNumber = item.LineNumber,
|
||||
Geometry = new GeometrySnapshot { PaperPosition = new Point2Dto(item.Position.X, item.Position.Y), Rotation = item.Rotation }
|
||||
Geometry = new GeometrySnapshot { PaperPosition = new Point2Dto(item.Position.X, item.Position.Y), Rotation = item.Rotation },
|
||||
MembershipStatus = ViewMembershipStatus.Resolved
|
||||
}).ToArray();
|
||||
|
||||
return new AnnotationPlanBuilder().Build(new AnnotationPlanningRequest
|
||||
|
||||
@@ -77,9 +77,11 @@ public sealed class EntryPoint : IExtensionApplication
|
||||
|
||||
var builder = new AnnotationPlanBuilder();
|
||||
var plans = new List<AnnotationPlan>();
|
||||
var objectsByViewport = new List<IReadOnlyList<PlantObjectSnapshot>>();
|
||||
foreach (var viewport in context.Viewports)
|
||||
{
|
||||
var objects = new PlantObjectReader().ReadObjects(viewport, CancellationToken.None);
|
||||
objectsByViewport.Add(objects);
|
||||
plans.Add(builder.Build(new AnnotationPlanningRequest { CommandName = commandName, Viewport = viewport, Objects = objects }));
|
||||
}
|
||||
|
||||
@@ -94,7 +96,16 @@ public sealed class EntryPoint : IExtensionApplication
|
||||
WarningCount = plans.Sum(x => x.Summary.WarningCount),
|
||||
ErrorCount = plans.Sum(x => x.Summary.ErrorCount)
|
||||
};
|
||||
var membershipResolved = objectsByViewport.SelectMany(x => x).Count(x => x.MembershipStatus == ViewMembershipStatus.Resolved);
|
||||
var membershipPartial = objectsByViewport.SelectMany(x => x).Count(x => x.MembershipStatus == ViewMembershipStatus.PartiallyResolved);
|
||||
var membershipUnresolved = objectsByViewport.SelectMany(x => x).Count(x => x.MembershipStatus == ViewMembershipStatus.NotResolved);
|
||||
var planId = plans.Count == 1 ? plans[0].PlanId : null;
|
||||
details["ObjectsExamined"] = summary.ObjectsExamined;
|
||||
details["PlanSchemaVersion"] = AnnotationPlanBuilder.CurrentPlanSchemaVersion;
|
||||
details["PlanId"] = planId;
|
||||
details["MembershipResolved"] = membershipResolved;
|
||||
details["MembershipPartial"] = membershipPartial;
|
||||
details["MembershipUnresolved"] = membershipUnresolved;
|
||||
details["PlanCreateCount"] = summary.CreateCount;
|
||||
details["PlanUpdateCount"] = summary.UpdateCount;
|
||||
details["PlanSkipCount"] = summary.SkipCount;
|
||||
@@ -103,6 +114,7 @@ public sealed class EntryPoint : IExtensionApplication
|
||||
details["ErrorCount"] = summary.ErrorCount;
|
||||
RuntimeDiagnostics.Write("CommandPreflightCompleted", operationId: operationId, commandName: commandName, resultStatus: "DryRunCompleted", document: document, details: details);
|
||||
WriteEditor(document, $"\nP_Rec_Annot — диагностический план\nПроект: {context.ProjectDisplayName}\nТип чертежа: Ortho\nViewport: {context.Viewports.Count}\nОбъектов найдено: {summary.ObjectsExamined}\nБудет создано: {summary.CreateCount}\nБудет обновлено: {summary.UpdateCount}\nБудет пропущено: {summary.SkipCount}\nКонфликтов: {summary.ConflictCount}\nОшибок: {summary.ErrorCount}\nDWG не изменён.");
|
||||
WriteEditor(document, $"\nPlan schema: {AnnotationPlanBuilder.CurrentPlanSchemaVersion}; PlanId: {planId ?? "multiple"}; membership resolved/partial/unresolved: {membershipResolved}/{membershipPartial}/{membershipUnresolved}.");
|
||||
WriteUsage(operationId, commandName, "DryRunCompleted", document, details, stopwatch.ElapsedMilliseconds);
|
||||
RuntimeDiagnostics.Write("CommandCompleted", operationId: operationId, commandName: commandName, resultStatus: "DryRunCompleted", document: document, details: details);
|
||||
}
|
||||
|
||||
@@ -104,6 +104,37 @@ public sealed class FoundationTests
|
||||
Assert.Equal(first.Operations.Select(x => x.SourceSnapshotId), second.Operations.Select(x => x.SourceSnapshotId));
|
||||
}
|
||||
|
||||
[Fact] public void AnnotationPlan_ReversedObjectOrder_HasSamePlanId()
|
||||
{
|
||||
var first = Build(Snapshot("a", "Pipe", "L-1"), Snapshot("b", "Pipe", "L-2"));
|
||||
var second = Build(Snapshot("b", "Pipe", "L-2"), Snapshot("a", "Pipe", "L-1"));
|
||||
Assert.Equal(first.PlanId, second.PlanId);
|
||||
Assert.Equal(first.Summary, second.Summary);
|
||||
}
|
||||
|
||||
[Fact] public void AnnotationPlan_AbsolutePath_DoesNotAffectPlanId()
|
||||
{
|
||||
var builder = new AnnotationPlanBuilder();
|
||||
var item = Snapshot("a", "Pipe", "L-1");
|
||||
var first = builder.Build(new AnnotationPlanningRequest { CommandName = "PHP_REC_ANNOT", Viewport = new OrthoViewportInfo { Id = "v", DisplayName = "v", SourceDrawingName = "C:\\one\\model.dwg" }, Objects = new[] { item } });
|
||||
var second = builder.Build(new AnnotationPlanningRequest { CommandName = "PHP_REC_ANNOT", Viewport = new OrthoViewportInfo { Id = "v", DisplayName = "v", SourceDrawingName = "D:\\two\\model.dwg" }, Objects = new[] { item } });
|
||||
Assert.Equal(first.PlanId, second.PlanId);
|
||||
}
|
||||
|
||||
[Fact] public void AnnotationPlan_ChangedRelevantProperty_ChangesPlanId()
|
||||
{
|
||||
var first = Build(Snapshot("a", "Pipe", "L-1"));
|
||||
var second = Build(Snapshot("a", "Pipe", "L-2"));
|
||||
Assert.NotEqual(first.PlanId, second.PlanId);
|
||||
}
|
||||
|
||||
[Fact] public void AnnotationPlan_UnresolvedMembership_IsStableSkip()
|
||||
{
|
||||
var plan = Build(new PlantObjectSnapshot { SnapshotId = "a", ObjectClass = "Pipe", LineNumber = "L-1", Geometry = new GeometrySnapshot(), MembershipStatus = ViewMembershipStatus.NotResolved });
|
||||
Assert.Equal(AnnotationOperationKind.Skip, Assert.Single(plan.Operations).Kind);
|
||||
Assert.Contains("VIEW_MEMBERSHIP_NOT_RESOLVED", plan.SkipReasons);
|
||||
}
|
||||
|
||||
[Fact] public void AnnotationPlan_MissingLineNumber_IsSkipped()
|
||||
{
|
||||
var plan = Build(Snapshot("a", "Pipe", null, null));
|
||||
@@ -221,5 +252,5 @@ public sealed class FoundationTests
|
||||
}
|
||||
|
||||
private static AnnotationPlan Build(params PlantObjectSnapshot[] objects) => new AnnotationPlanBuilder().Build(new AnnotationPlanningRequest { CommandName = "PHP_REC_ANNOT", Viewport = new OrthoViewportInfo { Id = "vp", DisplayName = "A", SourceDrawingName = "test.dwg" }, Objects = objects });
|
||||
private static PlantObjectSnapshot Snapshot(string id, string type, string? line, string? tag = "TAG") => new() { SnapshotId = id, ObjectClass = type, LineNumber = line, Tag = tag, Geometry = new GeometrySnapshot { PaperPosition = new Point2Dto(1, 2) } };
|
||||
private static PlantObjectSnapshot Snapshot(string id, string type, string? line, string? tag = "TAG") => new() { SnapshotId = id, ObjectClass = type, LineNumber = line, Tag = tag, Geometry = new GeometrySnapshot { PaperPosition = new Point2Dto(1, 2) }, MembershipStatus = ViewMembershipStatus.Resolved };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user