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]