From 1bfb91bc4bbe63c07b25dba93c435658bf5633c1 Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 3 Aug 2026 10:37:37 +0300 Subject: [PATCH] feat: add first limited Pipe Apply preflight --- .../src/PRecAnnot.Abstractions/Contracts.cs | 24 +++++++++ .../FirstLimitedApplyPlanner.cs | 52 +++++++++++++++++++ .../PRecAnnot.Core.Tests/FoundationTests.cs | 46 ++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/FirstLimitedApplyPlanner.cs diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs index c45a22d..0d4e1aa 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs @@ -183,6 +183,30 @@ public interface IAnnotationPlanBuilder { AnnotationPlan Build(AnnotationPlannin public enum PRecAnnotExecutionMode { Smoke, DryRun, Apply } public interface IRuntimeWriteGuard { void EnsureWriteAllowed(string operation); } +public sealed record CreatePipeAnnotationOperation +{ + public required string OperationId { get; init; } + public required string SourceObjectStableKey { get; init; } + public required string ViewStableKey { get; init; } + public required string RuleId { get; init; } + public required AnnotationPoint Placement { get; init; } + public required string DuplicateKey { get; init; } +} + +public sealed record FirstLimitedApplyPermit +{ + public required string FixtureMarker { get; init; } + public required bool AllowApply { get; init; } + public required string TargetSnapshotId { get; init; } + public required string ExpectedPlanId { get; init; } +} + +public sealed record FirstLimitedApplyPreflight +{ + public required string PlanId { get; init; } + public required CreatePipeAnnotationOperation Operation { get; init; } +} + public sealed record OrthoViewportRef(string DrawingId, string ViewportId); public sealed record PlantObjectData diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/FirstLimitedApplyPlanner.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/FirstLimitedApplyPlanner.cs new file mode 100644 index 0000000..4600cc1 --- /dev/null +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/FirstLimitedApplyPlanner.cs @@ -0,0 +1,52 @@ +using System.Security.Cryptography; +using System.Text; +using PRecAnnot.Abstractions; + +namespace PRecAnnot.Core; + +public sealed class FirstLimitedApplyPlanner +{ + public const string FixtureMarker = "FIRST_LIMITED_APPLY"; + public const string RuleId = "PXP-OA.PIPE.OPEN-END.V1"; + + public FirstLimitedApplyPreflight Prepare( + PRecAnnotExecutionMode mode, + AnnotationPlan plan, + OrthoViewportInfo viewport, + IReadOnlyList objects, + FirstLimitedApplyPermit permit) + { + if (mode != PRecAnnotExecutionMode.Apply) throw new InvalidOperationException("APPLY_MODE_REQUIRED"); + if (!permit.AllowApply || !string.Equals(permit.FixtureMarker, FixtureMarker, StringComparison.Ordinal)) + throw new InvalidOperationException("APPLY_TEST_FIXTURE_OR_PERMIT_REQUIRED"); + if (!string.Equals(permit.ExpectedPlanId, plan.PlanId, StringComparison.Ordinal)) + throw new InvalidOperationException("APPLY_PLAN_STALE"); + + var targets = objects.Where(x => string.Equals(x.SnapshotId, permit.TargetSnapshotId, StringComparison.Ordinal)).ToArray(); + if (targets.Length != 1) throw new InvalidOperationException("APPLY_REQUIRES_EXACTLY_ONE_TARGET"); + var target = targets[0]; + if (target.MembershipStatus != ViewMembershipStatus.Resolved) throw new InvalidOperationException("APPLY_REQUIRES_RESOLVED_MEMBERSHIP"); + if (!string.Equals(target.ObjectClass, "Pipe", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("APPLY_UNSUPPORTED_OBJECT_CLASS"); + if (target.Geometry.PaperPosition is not { } placement) throw new InvalidOperationException("ANNOTATION_PLACEMENT_NOT_RESOLVED"); + + var create = plan.Operations.Where(x => x.Kind == AnnotationOperationKind.Create && string.Equals(x.SourceSnapshotId, target.SnapshotId, StringComparison.Ordinal)).ToArray(); + if (create.Length != 1) throw new InvalidOperationException("APPLY_TARGET_CREATE_NOT_PLANNED"); + + var duplicateKey = Stable("duplicate", RuleId, viewport.Id, target.SnapshotId); + return new FirstLimitedApplyPreflight + { + PlanId = plan.PlanId, + Operation = new CreatePipeAnnotationOperation + { + OperationId = Stable("operation", RuleId, viewport.Id, target.SnapshotId), + SourceObjectStableKey = target.SnapshotId, + ViewStableKey = viewport.Id, + RuleId = RuleId, + Placement = new AnnotationPoint(placement.X, placement.Y), + DuplicateKey = duplicateKey + } + }; + } + + private static string Stable(params string[] values) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("|", values))))[..24]; +} diff --git a/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/FoundationTests.cs b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/FoundationTests.cs index 670c353..e479065 100644 --- a/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/FoundationTests.cs +++ b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/FoundationTests.cs @@ -283,6 +283,44 @@ public sealed class FoundationTests [Fact] public void ApplyMode_IsBlocked() => Assert.Throws(() => new ExecutionModeWriteGuard(PRecAnnotExecutionMode.Apply).EnsureWriteAllowed("test")); + [Fact] public void FirstLimitedApply_RequiresApplyMode() + { + var context = FirstApplyContext(); + Assert.Throws(() => new FirstLimitedApplyPlanner().Prepare(PRecAnnotExecutionMode.DryRun, context.Plan, context.Viewport, context.Objects, context.Permit)); + } + + [Fact] public void FirstLimitedApply_RequiresFreshPlan() + { + var context = FirstApplyContext(); + var permit = context.Permit with { ExpectedPlanId = "stale" }; + Assert.Throws(() => new FirstLimitedApplyPlanner().Prepare(PRecAnnotExecutionMode.Apply, context.Plan, context.Viewport, context.Objects, permit)); + } + + [Fact] public void FirstLimitedApply_RequiresResolvedMembership() + { + var context = FirstApplyContext(); + var objects = new[] { context.Objects[0] with { MembershipStatus = ViewMembershipStatus.NotResolved } }; + Assert.Throws(() => new FirstLimitedApplyPlanner().Prepare(PRecAnnotExecutionMode.Apply, context.Plan, context.Viewport, objects, context.Permit)); + } + + [Fact] public void FirstLimitedApply_RequiresExactlyOneTarget() + { + var context = FirstApplyContext(); + var permit = context.Permit with { TargetSnapshotId = "missing" }; + Assert.Throws(() => new FirstLimitedApplyPlanner().Prepare(PRecAnnotExecutionMode.Apply, context.Plan, context.Viewport, context.Objects, permit)); + } + + [Fact] public void FirstLimitedApply_CreatesDeterministicOperation() + { + var context = FirstApplyContext(); + var planner = new FirstLimitedApplyPlanner(); + var first = planner.Prepare(PRecAnnotExecutionMode.Apply, context.Plan, context.Viewport, context.Objects, context.Permit); + var second = planner.Prepare(PRecAnnotExecutionMode.Apply, context.Plan, context.Viewport, context.Objects, context.Permit); + Assert.Equal(first.Operation.OperationId, second.Operation.OperationId); + Assert.Equal(first.Operation.DuplicateKey, second.Operation.DuplicateKey); + Assert.DoesNotContain("C:\\", first.Operation.DuplicateKey, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void DryRunPath_HasNoForWriteOperations() { var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "PlantReadPath.cs")); @@ -292,6 +330,14 @@ public sealed class FoundationTests Assert.DoesNotContain("SetProperties", source); } + private static (AnnotationPlan Plan, OrthoViewportInfo Viewport, PlantObjectSnapshot[] Objects, FirstLimitedApplyPermit Permit) FirstApplyContext() + { + var viewport = new OrthoViewportInfo { Id = "vp", DisplayName = "view", SourceDrawingName = "model.dwg" }; + var objects = new[] { Snapshot("pipe", "Pipe", "TEST") }; + var plan = new AnnotationPlanBuilder().Build(new AnnotationPlanningRequest { CommandName = "PXP-OA", Viewport = viewport, Objects = objects }); + return (plan, viewport, objects, new FirstLimitedApplyPermit { FixtureMarker = FirstLimitedApplyPlanner.FixtureMarker, AllowApply = true, TargetSnapshotId = "pipe", ExpectedPlanId = plan.PlanId }); + } + 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) }, MembershipStatus = ViewMembershipStatus.Resolved }; }