diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/LineNumberEditApplyPlan.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/LineNumberEditApplyPlan.cs new file mode 100644 index 0000000..60c35d4 --- /dev/null +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/LineNumberEditApplyPlan.cs @@ -0,0 +1,41 @@ +using System.Security.Cryptography; +using System.Text; +using PRecAnnot.Abstractions; + +namespace PRecAnnot.Core; + +/// +/// Produces the canonical, fixture-bound payload for the legacy line-number +/// editing commands. The command-specific Apply permit signs this value so a +/// controlled session cannot turn an arbitrary selected block or prompted +/// value into a write merely by switching runtime mode to Apply. +/// +public static class LineNumberEditApplyPlan +{ + public static string ComputeMutationHash(string commandName, string selectedObjectHandle, IReadOnlyList mutations) + { + if (string.IsNullOrWhiteSpace(commandName)) throw new ArgumentException("LINE_NUMBER_EDIT_COMMAND_REQUIRED", nameof(commandName)); + if (string.IsNullOrWhiteSpace(selectedObjectHandle)) throw new ArgumentException("LINE_NUMBER_EDIT_SELECTION_REQUIRED", nameof(selectedObjectHandle)); + ArgumentNullException.ThrowIfNull(mutations); + if (mutations.Count == 0) throw new ArgumentException("LINE_NUMBER_EDIT_MUTATIONS_REQUIRED", nameof(mutations)); + + var canonical = new StringBuilder() + .Append(commandName).Append('|') + .Append(selectedObjectHandle).Append('\n'); + foreach (var mutation in mutations.OrderBy(value => value.SourceStableId, StringComparer.Ordinal) + .ThenBy(value => value.PreviousMarkerNumber) + .ThenBy(value => value.MarkerNumber)) + { + canonical.Append(mutation.SourceStableId).Append('|') + .Append(mutation.PreviousMarkerNumber).Append('|') + .Append(mutation.MarkerNumber).Append('|') + .Append(mutation.Text.Replace("\r\n", "\n", StringComparison.Ordinal).Replace("\r", "\n", StringComparison.Ordinal)).Append('|'); + if (mutation.MarkerAttributePosition is { } position) + canonical.Append(position.X.ToString("R", System.Globalization.CultureInfo.InvariantCulture)).Append(',') + .Append(position.Y.ToString("R", System.Globalization.CultureInfo.InvariantCulture)).Append(',') + .Append(position.Z.ToString("R", System.Globalization.CultureInfo.InvariantCulture)); + canonical.Append('\n'); + } + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical.ToString()))); + } +} diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/LineNumberApplyPermit.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/LineNumberApplyPermit.cs index 58e7083..28f8fcb 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/LineNumberApplyPermit.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/LineNumberApplyPermit.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Autodesk.AutoCAD.ApplicationServices; using PRecAnnot.Abstractions; +using PRecAnnot.Core; namespace PRecAnnot.Plant3D2026; @@ -12,6 +13,15 @@ internal sealed record LineNumberApplyPermit public required string ExpectedText { get; init; } } +internal sealed record LineNumberEditApplyPermit +{ + public required bool AllowApply { get; init; } + public required string FixtureMarkerPath { get; init; } + public required string ExpectedCommandName { get; init; } + public required string ExpectedSelectedObjectHandle { get; init; } + public required string ExpectedMutationHash { get; init; } +} + internal static class LineNumberApplyPermitReader { private const string MarkerContent = "P_REC_ANNOT_TEST_FIXTURE=LINE_NUMBER_APPLY"; @@ -35,3 +45,31 @@ internal static class LineNumberApplyPermitReader ?? throw new InvalidOperationException("LINE_NUMBER_APPLY_PERMIT_INVALID"); } } + +internal static class LineNumberEditApplyPermitReader +{ + private const string MarkerContent = "P_REC_ANNOT_TEST_FIXTURE=LINE_NUMBER_EDIT_APPLY"; + + public static LineNumberEditApplyPermit Read() + { + var path = Environment.GetEnvironmentVariable("P_REC_ANNOT_LINE_NUMBER_EDIT_APPLY_PERMIT_PATH"); + if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path) || path.StartsWith("\\\\", StringComparison.Ordinal) || !File.Exists(path)) + throw new InvalidOperationException("LINE_NUMBER_EDIT_APPLY_PERMIT_REQUIRED"); + return JsonSerializer.Deserialize(File.ReadAllText(path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? throw new InvalidOperationException("LINE_NUMBER_EDIT_APPLY_PERMIT_INVALID"); + } + + public static void Validate(Document document, LineNumberEditApplyPermit permit, string commandName, string selectedObjectHandle, IReadOnlyList mutations) + { + var mutationHash = LineNumberEditApplyPlan.ComputeMutationHash(commandName, selectedObjectHandle, mutations); + if (!permit.AllowApply || + !string.Equals(permit.ExpectedCommandName, commandName, StringComparison.Ordinal) || + !string.Equals(permit.ExpectedSelectedObjectHandle, selectedObjectHandle, StringComparison.OrdinalIgnoreCase) || + !string.Equals(permit.ExpectedMutationHash, mutationHash, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("LINE_NUMBER_EDIT_APPLY_PERMIT_PLAN_MISMATCH"); + if (!Path.IsPathFullyQualified(permit.FixtureMarkerPath) || permit.FixtureMarkerPath.StartsWith("\\\\", StringComparison.Ordinal) || !File.Exists(permit.FixtureMarkerPath) || !string.Equals(File.ReadAllText(permit.FixtureMarkerPath).Trim(), MarkerContent, StringComparison.Ordinal)) + throw new InvalidOperationException("LINE_NUMBER_EDIT_APPLY_TEST_FIXTURE_REQUIRED"); + var fixtureRoot = Path.GetDirectoryName(Path.GetFullPath(permit.FixtureMarkerPath))! + Path.DirectorySeparatorChar; + if (!Path.GetFullPath(document.Name).StartsWith(fixtureRoot, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("LINE_NUMBER_EDIT_APPLY_TEST_FIXTURE_REQUIRED"); + } +} diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/LineNumberEditCommands.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/LineNumberEditCommands.cs index 288f40a..d488d7f 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/LineNumberEditCommands.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Plant3D2026/LineNumberEditCommands.cs @@ -16,7 +16,7 @@ internal static class LineNumberEditCommands var start = editor.GetInteger(new PromptIntegerOptions("\nВведите новое начальное значение маркера: ") { LowerLimit = 1 }); if (start.Status != PromptStatus.OK) return null; var rows = new LineNumberOwnedAnnotationReader().ReadList(document, selected); - return new LineNumberAnnotationWriter().ApplyEdits(document, LineNumberEditPlanner.Renumber(rows, start.Value)); + return LineNumberEditPlanner.Renumber(rows, start.Value); }, "Выберите блок списка номеров линий: "); } @@ -30,7 +30,7 @@ internal static class LineNumberEditCommands var row = rows.SingleOrDefault(value => value.MarkerNumber == marker.Value) ?? throw new InvalidOperationException("LINE_NUMBER_ROW_NOT_FOUND"); var text = editor.GetString(new PromptStringOptions("\nВведите новый текст номера линии: ") { AllowSpaces = true }); if (text.Status != PromptStatus.OK) return null; - return new LineNumberAnnotationWriter().ApplyEdits(document, [LineNumberEditPlanner.EditText(row, text.StringResult)]); + return [LineNumberEditPlanner.EditText(row, text.StringResult)]; }, "Выберите блок списка номеров линий: "); } @@ -40,11 +40,11 @@ internal static class LineNumberEditCommands { var row = new LineNumberOwnedAnnotationReader().ReadMarker(document, selected); var configured = LineNumberRuntimeConfigurationReader.Read().Assets.Marker.Texts[0].Position; - return new LineNumberAnnotationWriter().ApplyEdits(document, [LineNumberEditPlanner.ResetMarkerAttributePosition(row, configured)]); + return [LineNumberEditPlanner.ResetMarkerAttributePosition(row, configured)]; }, "Выберите маркер номера линии: "); } - private static void Run(string command, Func operation, string prompt) + private static void Run(string command, Func?> operation, string prompt) { var document = AcadApplication.DocumentManager.MdiActiveDocument; if (document is null) return; @@ -65,8 +65,11 @@ internal static class LineNumberEditCommands options.AddAllowedClass(typeof(BlockReference), true); var selected = document.Editor.GetEntity(options); if (selected.Status != PromptStatus.OK) return; - var result = operation(document, document.Editor, selected.ObjectId); - if (result is null) return; + var mutations = operation(document, document.Editor, selected.ObjectId); + if (mutations is null) return; + var selectedHandle = selected.ObjectId.Handle.ToString(); + LineNumberEditApplyPermitReader.Validate(document, LineNumberEditApplyPermitReader.Read(), command, selectedHandle, mutations); + var result = new LineNumberAnnotationWriter().ApplyEdits(document, mutations); document.Editor.WriteMessage($"\n{command}: updated={result.Updated}; unchanged={result.Unchanged}."); RuntimeDiagnostics.Write("LineNumberEditCompleted", commandName: command, resultStatus: result.Updated > 0 ? "Updated" : "AlreadyCurrent", document: document, details: new Dictionary { ["Updated"] = result.Updated, ["AlreadyCurrent"] = result.Unchanged }); diff --git a/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/LineNumberEditApplyPlanTests.cs b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/LineNumberEditApplyPlanTests.cs new file mode 100644 index 0000000..094b5b9 --- /dev/null +++ b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/LineNumberEditApplyPlanTests.cs @@ -0,0 +1,30 @@ +using PRecAnnot.Abstractions; +using PRecAnnot.Core; +using Xunit; + +namespace PRecAnnot.Core.Tests; + +public sealed class LineNumberEditApplyPlanTests +{ + [Fact] + public void MutationHash_IsStableAcrossInputOrder() + { + var first = new LineNumberRowMutation("pipe-a", 1, 10, "A", new(1, 2, 3)); + var second = new LineNumberRowMutation("pipe-b", 2, 11, "B", null); + + var expected = LineNumberEditApplyPlan.ComputeMutationHash("PXP-ESN", "1A2B", [first, second]); + + Assert.Equal(expected, LineNumberEditApplyPlan.ComputeMutationHash("PXP-ESN", "1A2B", [second, first])); + } + + [Fact] + public void MutationHash_BindsCommandSelectionAndRequestedEdit() + { + var mutation = new LineNumberRowMutation("pipe-a", 1, 10, "A", null); + var expected = LineNumberEditApplyPlan.ComputeMutationHash("PXP-ESN", "1A2B", [mutation]); + + Assert.NotEqual(expected, LineNumberEditApplyPlan.ComputeMutationHash("PXP-ELN", "1A2B", [mutation])); + Assert.NotEqual(expected, LineNumberEditApplyPlan.ComputeMutationHash("PXP-ESN", "2B3C", [mutation])); + Assert.NotEqual(expected, LineNumberEditApplyPlan.ComputeMutationHash("PXP-ESN", "1A2B", [mutation with { Text = "Changed" }])); + } +} diff --git a/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/LineNumberEditCommandRegistrationTests.cs b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/LineNumberEditCommandRegistrationTests.cs index bc09e63..769b82a 100644 --- a/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/LineNumberEditCommandRegistrationTests.cs +++ b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/LineNumberEditCommandRegistrationTests.cs @@ -39,7 +39,10 @@ public sealed class LineNumberEditCommandRegistrationTests public void MutationCommands_UseOneTransactionalWriter() { var commands = File.ReadAllText(Path.Combine(ProjectRoot, "LineNumberEditCommands.cs")); - Assert.Equal(3, System.Text.RegularExpressions.Regex.Matches(commands, "ApplyEdits\\(document").Count); + Assert.Single(System.Text.RegularExpressions.Regex.Matches(commands, "ApplyEdits\\(document").Cast()); + Assert.Contains("LineNumberEditApplyPermitReader.Validate", commands); + Assert.Contains("ExpectedSelectedObjectHandle", File.ReadAllText(Path.Combine(ProjectRoot, "LineNumberApplyPermit.cs"))); + Assert.Contains("ExpectedMutationHash", File.ReadAllText(Path.Combine(ProjectRoot, "LineNumberApplyPermit.cs"))); } [Fact]