Compare commits

...
13 changed files with 890 additions and 30 deletions
+129
View File
@@ -0,0 +1,129 @@
# AGENTS.md
## Назначение проекта
Этот репозиторий содержит аудит, архитектуру, исходный код, тесты, runtime
evidence и документацию миграции корпоративной экосистемы Autodesk AutoCAD
Plant 3D 2019 на Autodesk AutoCAD Plant 3D 2026.
Конечная цель — функционально эквивалентная рабочая схема Plant 3D 2026:
корпоративный Launcher, локальная доставка и активация пакетов, managed
плагины, команды, Ribbon, Plant project integration, DataLinks, Ortho,
аннотации, обновление, диагностика и rollback. Миграция не считается
завершённой после успешной компиляции: необходима runtime-проверка реальных
сценариев в Plant 3D 2026.
## Связанные репозитории
- основной Autodesk-репозиторий: `C:\dev\autodesk`;
- Launcher Next: `C:\dev\launcher-next`;
- legacy launcher (read-only): `C:\dev\launcher\git`, `C:\dev\p_serv_starter`;
- legacy P_Rec_Annot (read-only): `C:\dev\autodesk\link\P_Rec_Annot\v1.0.4\repo`.
Legacy и deployed-файлы нельзя изменять без отдельного прямого задания.
## Целевая платформа и принцип миграции
- Autodesk AutoCAD Plant 3D 2026, series R25.1, Windows x64, .NET 8;
- использовать реальные Autodesk 2026 managed assemblies;
- managed package загружается локально, не напрямую с UNC;
- Autodesk DLL нельзя коммитить, помещать в package/fixture или публиковать.
Переносится функциональное поведение, а не историческая структура файлов. Не
воспроизводить без отдельного доказательства 20 последовательных `NETLOAD`,
legacy LISP bootstrap, `OrthoLoader.dll`, загрузку с сети или прежние
workaround. Предпочтительная цепочка:
`Launcher Next → versioned local package → один managed entry assembly → internal dependencies → commands and Ribbon`.
## Статус legacy-команд
Ни одна активная команда Plant 3D 2019 не исчезает молча. Для каждой команды
фиксируется ровно один статус: `IMPLEMENTED`, `RUNTIME_VERIFIED`,
`REPLACED_WITH_NATIVE`, `RETIRED_APPROVED` или `BLOCKED_WITH_REASON`.
Aliases сохраняются, пока их удаление не утверждено отдельно.
## Runtime уровни
`Smoke` проверяет assembly load, регистрацию команд, Plant context, Ribbon,
отсутствие необработанных исключений и отсутствие записи в DWG.
`DryRun` проверяет реальные Plant data, Ortho/sheet/view definitions, DataLinks,
Plant objects, детерминированный план и отсутствие записи в DWG/project DB.
`Apply` разрешается только отдельным явным заданием: write guard, ограниченная
DWG transaction, тестовая копия проекта, baseline, rollback/UNDO, duplicate
protection и runtime evidence обязательны. Apply нельзя включать только
локальной конфигурацией.
## Runtime важнее предположений
Build и unit tests не доказывают Autodesk integration. Для
`RUNTIME_VERIFIED` необходимы фактические результаты: команда, module path,
DBMOD, Ribbon, DataLinks, Ortho status, telemetry и состояние файлов после
закрытия. Не выдумывать UI evidence: использовать `null`, `NOT VERIFIED` или
`BLOCKED WITH REASON`.
На тестовой машине реальный Plant 3D запуск разрешён, если он входит в задачу
и есть test branch, локальный fixture, пройденные build/tests, rollback и нет
production deploy. Не создавать искусственный review-gate перед Process.Start.
Остановка допустима лишь после завершения доступной части работы и при
конкретном внешнем blocker: установка, ОС, права, Autodesk runtime, fixture или
необходимость production-данных.
## Test fixtures и отсутствие записи
Fixtures создаются вне Git, например `<TEST_FIXTURE_ROOT>`. Ortho fixture —
настоящий Plant project с Pipe, DataLinks row, line group, Ortho drawing,
Ortho viewport и source-model connection; обычный AutoCAD viewport не годится.
Production DWG/project использовать нельзя без прямого разрешения.
Нельзя коммитить DWG, DCF, Project.xml с runtime-data, project DB, Ortho
generated files или реальные engineering values.
Для Smoke/DryRun проверять DBMOD до/после, SHA-256 содержательных project
files, отсутствие непредусмотренных transactions/DCF/project-metadata writes.
`LastWriteTime` недостаточен. Отделять служебные Plant cache/session files от
содержательных данных.
## Ribbon, механика и evidence
Ribbon — часть продукта: проверять tab, panel, button, command binding, stable
IDs, отсутствие дубликатов при документах/workspace/reinitialization. Команда
может быть отдельно рабочей, но plugin slice не завершён без Ribbon evidence
или отдельного UI defect.
Документировать каждую runtime-цепочку: component/project/class/method,
input/output, Autodesk transaction mode, writes, telemetry, failure code и
rollback action. Документация соответствует фактическому коду.
Evidence содержит только проверенные значения. Не добавлять username, permit,
полный environment, real project paths/line numbers/ObjectId/RowId/properties,
полные logs, local manifest или startup scripts. Путь fixture заменять на
`<TEST_FIXTURE_ROOT>`.
## Warnings и Git
Известный `MSB3277` WindowsBase 4.0.0.0/8.0.0.0 допустим только при полном
совпадении с ранее зафиксированным warning. Новые warnings исследовать; не
подавлять `NoWarn` без причины.
Запрещены force push, amend/rebase опубликованной истории, push binaries,
изменение неизвестного remote и production deployment из test branch. Перед
commit выполнять:
```powershell
git diff --check
git status --short
git diff --cached --check
```
Не смешивать Launcher defect, plugin defect, runtime evidence и unrelated
formatting в одном commit.
## Definition of Done
Срез завершён, когда реализованы код, build/tests, валидный package и требуемый
runtime-сценарий; записаны фактические результаты; выполнен rollback; fixture и
production изолированы; механика документирована; ветка опубликована обычным
push.
+79
View File
@@ -0,0 +1,79 @@
# AGENTS.md — P_Rec_Annot
## Назначение и package
`P_Rec_Annot` — приоритетный Plant 3D 2026 plugin для чтения, планирования и
последующего создания Ortho-аннотаций; это перенос активного legacy поведения
Plant 3D 2019 на .NET 8/Plant 3D 2026.
Единственная assembly для `NETLOAD` — `PRecAnnot.Plant3D2026.dll`.
`PRecAnnot.*.dll` — internal managed dependencies, их нельзя NETLOAD по одной.
Использовать Autodesk 2026 references; Autodesk DLL не включать в bundle.
## Commands и context
Поддерживаемые команды: `PHP_REC_ANNOT`, `PXP-AAV`, `PXP-OA`. Все
регистрируются реальным Autodesk API, работают в Smoke/DryRun, не изменяют DWG
в этих режимах и создают одно usage event на явный вызов.
Project status: `NotFound`, `FoundWithName`, `FoundWithoutDisplayName`,
`Ambiguous`, `Failed`. Никогда не выводить `проект '' найден`; для пустого
display name: `Plant 3D-проект открыт; отображаемое имя не определено.`
Diagnostic code: `PLANT_PROJECT_DISPLAY_NAME_EMPTY`.
Drawing states: `Ortho`, `NotOrtho`, `Ambiguous`, `Failed`. Обычный DWG не
классифицируется как Ortho; для реального Ortho применять Autodesk API и
`OrthoDocUtils.IsValidOrthoDrawing`.
## DryRun и determinism
Pipeline: `Plant context → Ortho detection → sheet/view definitions → viewport
snapshots → DataLinks → Plant properties → independent DTO → deterministic
AnnotationPlan → diagnostics → no write`.
Autodesk objects, transactions и database handles остаются в adapter layer;
Core/Application используют только независимые DTO. На неизменном fixture
повторный DryRun обязан давать тот же PlanId, counts, порядок операций и warning
codes, без side effects. PlanId не включает время, GUID, absolute path,
нестабильный ObjectId или пользовательские данные.
## Write guard, Ribbon, tests
Все writes проходят централизованный write guard; в Smoke/DryRun он всегда
запрещает изменения. Apply не обходится через command handler/adapter.
Ожидаемый Ribbon путь: `Промхимпроект → Аннотации → Аннотировать`. Требуются
stable IDs, отсутствие дублей, binding к `PHP_REC_ANNOT`, устойчивость к
документам/workspace и корректный Terminate.
Unit tests не требуют Autodesk runtime. Runtime evidence отдельно фиксирует
initialization, commands, context, Ortho, DataLinks, plan, Ribbon, DBMOD,
telemetry и shutdown.
Bundle содержит только фактический runtime dependency graph и никогда не
содержит Autodesk DLL, PDB, test assemblies, legacy 2019 assemblies, bin/obj
или production configuration.
Fixture: local Plant 3D 2026 project вне Git с настоящей Pipe, synthetic line
number, Ortho drawing/viewport и SHA baseline до DryRun.
## Ortho source DataLinks and DCF control
DataLinks активного Ortho drawing не являются источником Piping rows. Piping
rows читаются из source model drawing и Piping project part, разрешённых через
Ortho sheet/view definitions.
Перед предположениями об Autodesk runtime API необходимо исследовать текущий
код, доступный legacy usage, assembly metadata и реальные runtime types.
Reflection/runtime inspection применяется только для подтверждения данных,
которых нельзя установить статически. Production resolver не угадывает
Autodesk properties по строковым именам.
Подтверждённый контракт Plant 3D 2026: `IDwgViewDef.DwgQuery` →
`IDwgQuery.ModelFiles` → `ModelFile[].ModelFileName`. При несовпадении типа
возвращать `ORTHO_MODEL_FILES_API_UNSUPPORTED`, без fallback-эвристики.
Проверка DCF выполняется на парных `control` и `DryRun` fixtures, созданных из
одного закрытого состояния проекта. Сравнивать нужно `control delta` с
`DryRun delta`: одинаковые изменения классифицируются как baseline behavior
Plant 3D, а plugin-only изменения блокируют дальнейшую работу.
@@ -0,0 +1,28 @@
{
"date": "2026-08-03",
"executionMode": "DryRun",
"plantProjectDetected": true,
"projectDisplayNameStatus": "FoundWithName",
"orthoDetection": "Confirmed",
"viewportCount": 1,
"dataLinksAvailable": true,
"dataLinksSheetToSourceMapping": "resolved-through-piping-project-drawing-id",
"sourceDrawingsResolved": 1,
"sourceDataLinksAvailable": true,
"objectsRead": 7,
"pipeObjectsRead": 7,
"phpRecAnnotRuns": 1,
"pxpAavRuns": null,
"pxpOaRuns": null,
"planId": null,
"repeatedPlanIdEqual": null,
"dbmodBefore": null,
"dbmodAfter": null,
"dwgModified": null,
"dcfModified": null,
"changedProjectFiles": null,
"unhandledExceptions": 0,
"applyModeUsed": false,
"launcherRollback": "RolledBack",
"exactViewportMembership": "not-verified"
}
@@ -0,0 +1,28 @@
# Ortho DryRun runtime result
Date: 2026-08-03
The local test fixture's `TestPipeOrtho.dwg` was detected as Ortho with one
viewport. In `DryRun`, `PHP_REC_ANNOT` ran twice, `PXP-AAV` ran twice, and
`PXP-OA` ran once. All completed without an unhandled exception; `DBMOD` was
`0` before and after.
`PlantProject.Name` was available for this fixture and rendered as
`PRecAnnotTestProject`.
The active Ortho sheet does not expose direct Piping rows and must not be used
as a DataLinks source. Metadata inspection and a sanitized runtime probe
confirmed `IDwgViewDef.DwgQuery.ModelFiles[].ModelFileName` as the source-model
reference. The adapter resolves that reference through
`PipingProject.GetPnPDrawingFiles()` and reads
`DataLinksManager.SelectAcPpRowIds(PnPProjectDrawing.Id)`.
On the latest real DryRun, one source drawing was resolved and seven source
Plant objects were read. `PHP_REC_ANNOT` reported seven skipped operations,
zero writes, zero conflicts, and zero errors. This verifies source-model
DataLinks access; it does not yet verify exact source-object-to-viewport
membership or repeated-plan determinism.
The test fixture SHA baseline changed for `Piping.dcf` before the first DryRun
command. This run therefore does not prove DCF invariance. A paired control
fixture versus DryRun fixture comparison remains required.
@@ -0,0 +1,39 @@
# Ortho source-model mapping
## Confirmed Plant 3D 2026 read path
```text
Active Ortho document
→ OrthoProject.DwgSheetDefinitions
→ IDwgSheetDef.DwgViewDefs
→ IDwgViewDef.DwgQuery.ModelFiles[].ModelFileName
→ PipingProject.GetPnPDrawingFiles()
→ exact normalized-path match to PnPProjectDrawing
→ PnPProjectDrawing.Id
→ DataLinksManager.SelectAcPpRowIds(int dwgid)
→ GetObjectClassname / GetAllProperties
→ independent PlantObjectSnapshot
→ AnnotationPlanBuilder
```
`PlantOrthoViewportReader.ReadViewports` reads the typed DwgQuery contract and
creates an Ortho viewport snapshot. `PlantObjectReader.ReadObjects` resolves
the resulting model-file reference through the Piping project drawing list and
reads DataLinks by project drawing ID. The active Ortho database is not a
Piping-row source.
All calls are read-only. The adapter opens no write transaction and does not
save a drawing, DCF, or project metadata.
## Diagnostics
The source reader returns stable telemetry diagnostics rather than suppressing
the result of a failed query: `ORTHO_SOURCE_DRAWING_NOT_RESOLVED`,
`ORTHO_SOURCE_DRAWING_NOT_IN_PROJECT`, `ORTHO_SOURCE_DRAWING_AMBIGUOUS`,
`PIPING_PROJECT_PART_NOT_FOUND`, `SOURCE_DATALINKS_UNAVAILABLE`,
`SOURCE_DATALINKS_NO_ROWS`, `SOURCE_DATABASE_READ_FAILED`,
`SOURCE_PIPE_ROWS_NOT_FOUND`, or `SOURCE_DATALINKS_READ`.
Exact membership of a source object in a particular Ortho viewport is not yet
implemented. A source-level DryRun plan must not be presented as proof of
viewport membership.
@@ -0,0 +1,101 @@
# Plant 3D 2026 DwgQuery / ModelFiles API contract
## Scope and method
This report is based on metadata-only inspection of the installed Plant 3D 2026
assemblies. The assemblies were read from `<PLANT3D_2026_INSTALL>` and were not
copied into the repository. Metadata-only inspection was necessary because the
normal PowerShell host cannot load the product's .NET 8 dependencies safely.
Legacy source at the documented local path was not present on this machine, so
there is no legacy API claim in this report.
## Confirmed assemblies
| Assembly | Version | Relevant type |
| --- | --- | --- |
| `PnPDwg2dDef` | `0.0.0.0` | `Autodesk.ProcessPower.Drawings2d.DwgQuery`, `IDwgQuery`, `DwgViewDef`, `IDwgViewDef`, `DwgSheetDef`, `IDwgSheetDef` |
| `PnPDwg2dUtil` | `0.0.0.0` | `Autodesk.ProcessPower.Drawings2d.ModelFile` |
| `PnP3dOrthoProjectPart` | `17.0.60.1` | Ortho project-part integration assembly; the metadata scan found no public DwgQuery declaration there |
`PnPDwg2dDef` and `PnPDwg2dUtil` use `0.0.0.0` assembly versions in the
installed metadata. The supported runtime contract is therefore identified by
the exact public type names below, not by an invented product-version number.
## Confirmed contract
```text
Autodesk.ProcessPower.Drawings2d.IDwgViewDef.DwgQuery
: Autodesk.ProcessPower.Drawings2d.IDwgQuery
Autodesk.ProcessPower.Drawings2d.IDwgQuery.ModelFiles
: Autodesk.ProcessPower.Drawings2d.ModelFile[]
Autodesk.ProcessPower.Drawings2d.ModelFile.ModelFileName
: System.String
Autodesk.ProcessPower.Drawings2d.ModelFile.ModelFileGuid
: System.String
```
`DwgViewDef` implements `IDwgViewDef`; `DwgQuery` implements `IDwgQuery` and
`IDisposable`; `ModelFile` implements `IComparable<ModelFile>`.
`ModelFiles` is an array, not an untyped enumerable. `ModelFileName` is the
only confirmed string field representing the model-file reference. `ModelFileGuid`
is a separate identity field. `IsReferencedDwg` and `Xform` describe reference
state and transform, not a substitute path.
## Public members relevant to source resolution
`IDwgViewDef` exposes `DwgQuery`, `ViewName`, layout/orientation properties,
and `SetClippingPlanes`, `SetLayout`, `SetOrientation`, `Erase`, and `New`.
`IDwgQuery` exposes `ModelFiles`, `Execute`, and `Erase`.
`ModelFile` exposes `ModelFileName`, `ModelFileGuid`, `IsReferencedDwg`, and
`Xform`; its constructors accept a file-name string and optional transform,
reference flag, and GUID.
`IDwgSheetDef.DwgViewDefs` exposes the view-definition collection. Sheet
`DwgName`/`DwgPath` identify the Ortho sheet and must not be used as the source
of Piping DataLinks.
## Current and legacy usage
The original adapter used reflection and tried several possible names
(`ModelFileName`, `Value`, and arbitrary string values). That was not a valid
Plant 3D 2026 contract.
The production read path now uses the confirmed typed chain only:
```text
IDwgViewDef.DwgQuery.ModelFiles[].ModelFileName
```
The legacy source path was unavailable locally, so no legacy source-model or
DataLinks workaround has been imported.
## Runtime evidence
Static metadata cannot prove whether `ModelFileName` is project-relative or
absolute in a concrete Ortho fixture, or whether a particular view has zero,
one, or multiple model files. In DryRun only, setting
`P_REC_ANNOT_DIAGNOSTICS=OrthoStructure` enables a sanitized structure probe.
It records type names, collection counts, null state, string length, and a
one-way value hash; it does not emit paths or engineering data.
On the local DryRun fixture, the runtime probe recorded one view with one
`ModelFile`. The concrete types were `DwgViewDef`, `DwgQueryByBox`, and
`ModelFile[]`; `ModelFileName` was non-null and an absolute fixture-model
reference. The path itself was not retained in repository evidence.
The resolver resolves that reference to exactly one
`PipingProject.GetPnPDrawingFiles()` entry by normalized full-path equality,
then uses the confirmed `DataLinksManager.SelectAcPpRowIds(int dwgid)` overload
with the resolved `PnPProjectDrawing.Id`. It does not open or query the active
Ortho database for Piping rows.
The production resolver has no fallback property-name search. If the exact
contract is unavailable at runtime it reports
`ORTHO_MODEL_FILES_API_UNSUPPORTED` rather than guessing.
@@ -8,6 +8,7 @@ public sealed record RuntimeDiagnostic
}
public enum OrthoDetectionStatus { Confirmed, NotOrtho, Ambiguous, Unsupported, Failed }
public enum PlantProjectDetectionStatus { NotFound, FoundWithName, FoundWithoutDisplayName, Ambiguous, Failed }
public sealed record OrthoDetectionResult
{
@@ -66,6 +67,7 @@ public sealed record PlantRuntimeContext
public bool IsOrthoDrawing { get; init; }
public string? DrawingName { get; init; }
public string? ProjectDisplayName { get; init; }
public PlantProjectDetectionStatus ProjectDetectionStatus { get; init; } = PlantProjectDetectionStatus.NotFound;
public IReadOnlyList<OrthoViewportInfo> Viewports { get; init; } = Array.Empty<OrthoViewportInfo>();
public IReadOnlyList<RuntimeDiagnostic> Diagnostics { get; init; } = Array.Empty<RuntimeDiagnostic>();
public OrthoDetectionResult Detection { get; init; } = new() { Status = OrthoDetectionStatus.Unsupported, Evidence = Array.Empty<string>(), Diagnostics = Array.Empty<RuntimeDiagnostic>() };
@@ -74,6 +76,62 @@ public sealed record PlantRuntimeContext
public interface IPlantRuntimeContextReader { PlantRuntimeContext ReadCurrentContext(); }
public interface IOrthoViewportReader { IReadOnlyList<OrthoViewportInfo> ReadViewports(CancellationToken cancellationToken); }
public enum SourceDrawingResolutionStatus { Resolved, NotReferenced, Ambiguous, MissingFromProject, Unsupported, Failed }
public sealed record OrthoSourceDrawingReference
{
public required string StableId { get; init; }
public required string DisplayName { get; init; }
public string? ProjectRelativePath { get; init; }
public long? ProjectDrawingRowId { get; init; }
public required SourceDrawingResolutionStatus Status { get; init; }
public IReadOnlyList<string> EvidenceCodes { get; init; } = Array.Empty<string>();
}
public sealed record SourcePlantObjectSnapshot
{
public required string StableObjectKey { get; init; }
public required string ClassName { get; init; }
public string? SourceDrawingStableId { get; init; }
public string? LineGroupStableKey { get; init; }
public IReadOnlyDictionary<string, string?> Properties { get; init; } = new Dictionary<string, string?>();
}
public sealed record SourceDrawingReadResult
{
public required OrthoSourceDrawingReference Source { get; init; }
public IReadOnlyList<SourcePlantObjectSnapshot> Objects { get; init; } = Array.Empty<SourcePlantObjectSnapshot>();
public IReadOnlyList<RuntimeDiagnostic> Diagnostics { get; init; } = Array.Empty<RuntimeDiagnostic>();
}
public interface IOrthoSourceDrawingResolver
{
IReadOnlyList<OrthoSourceDrawingReference> Resolve(OrthoDrawingSnapshot drawing);
}
public sealed record OrthoDrawingSnapshot
{
public required string StableId { get; init; }
public IReadOnlyList<OrthoViewSourceSnapshot> Views { get; init; } = Array.Empty<OrthoViewSourceSnapshot>();
}
public sealed record OrthoViewSourceSnapshot
{
public required string ViewStableId { get; init; }
public IReadOnlyList<string> ReferencedDrawingPaths { get; init; } = Array.Empty<string>();
}
public interface ISourceDrawingDataLinksReader
{
SourceDrawingReadResult Read(PlantProjectContext project, OrthoSourceDrawingReference source);
}
public sealed record PlantProjectContext
{
public required string StableProjectId { get; init; }
public string? ProjectDisplayName { get; init; }
}
public enum AnnotationOperationKind { Create, Update, Skip, Conflict, Unsupported }
public sealed record AnnotationOperationPlan
@@ -0,0 +1,47 @@
using System.Security.Cryptography;
using System.Text;
using PRecAnnot.Abstractions;
namespace PRecAnnot.Core;
public sealed class OrthoSourceDrawingResolver : IOrthoSourceDrawingResolver
{
public IReadOnlyList<OrthoSourceDrawingReference> Resolve(OrthoDrawingSnapshot drawing)
{
var candidates = drawing.Views
.OrderBy(x => x.ViewStableId, StringComparer.Ordinal)
.SelectMany(x => x.ReferencedDrawingPaths)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x.Trim().Replace('\\', '/'))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.Select(path => new OrthoSourceDrawingReference
{
StableId = StableId(drawing.StableId, path),
DisplayName = Path.GetFileName(path),
ProjectRelativePath = path,
Status = SourceDrawingResolutionStatus.Resolved,
EvidenceCodes = new[] { "SOURCE_FROM_VIEW_DEFINITION", "SOURCE_PATH_MATCHED_PROJECT_RELATIVE" }
})
.ToArray();
return candidates.Length != 0
? candidates
: new[]
{
new OrthoSourceDrawingReference
{
StableId = StableId(drawing.StableId, "none"),
DisplayName = "source drawing unavailable",
Status = SourceDrawingResolutionStatus.NotReferenced,
EvidenceCodes = new[] { "ORTHO_SOURCE_DRAWING_NOT_RESOLVED" }
}
};
}
private static string StableId(string drawingId, string source)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"{drawingId}|{source}"));
return "src-" + Convert.ToHexString(hash)[..20];
}
}
@@ -0,0 +1,81 @@
using System.Collections;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using Autodesk.ProcessPower.Drawings2d;
namespace PRecAnnot.Plant3D2026;
// This is a diagnostic-only probe. It is never part of source resolution and is
// called only by the explicit DryRun/OrthoStructure environment flag combination.
internal static class OrthoRuntimeStructureInspector
{
public static void Inspect(IDwgViewDef view)
{
if (!RuntimeDiagnostics.IsOrthoStructureDiagnosticsEnabled) return;
try
{
var query = view.DwgQuery;
var modelFiles = query?.ModelFiles;
var details = new Dictionary<string, object?>
{
["ViewRuntimeType"] = TypeName(view.GetType()),
["DwgQueryRuntimeType"] = query is null ? null : TypeName(query.GetType()),
["ModelFilesDeclaredType"] = typeof(ModelFile[]).FullName,
["ModelFilesRuntimeType"] = modelFiles?.GetType().AssemblyQualifiedName,
["ModelFilesCount"] = modelFiles?.Length ?? 0,
["ModelFileElements"] = modelFiles?.Select(DescribeModelFile).ToArray() ?? Array.Empty<IReadOnlyDictionary<string, object?>>()
};
RuntimeDiagnostics.Write("OrthoRuntimeStructureInspected", resultStatus: "Captured", details: details);
}
catch (Exception exception)
{
RuntimeDiagnostics.Write("OrthoRuntimeStructureInspectionFailed", "WARNING", resultStatus: "Failed", exception: exception);
}
}
private static IReadOnlyDictionary<string, object?> DescribeModelFile(ModelFile? modelFile)
{
if (modelFile is null)
{
return new Dictionary<string, object?> { ["IsNull"] = true };
}
var properties = modelFile.GetType()
.GetProperties(BindingFlags.Instance | BindingFlags.Public)
.Where(property => property.CanRead && property.GetIndexParameters().Length == 0)
.OrderBy(property => property.Name, StringComparer.Ordinal)
.Select(property => DescribeProperty(modelFile, property))
.ToArray();
return new Dictionary<string, object?>
{
["RuntimeType"] = TypeName(modelFile.GetType()),
["Properties"] = properties
};
}
private static IReadOnlyDictionary<string, object?> DescribeProperty(ModelFile modelFile, PropertyInfo property)
{
object? value;
try { value = property.GetValue(modelFile); }
catch { value = null; }
return new Dictionary<string, object?>
{
["PropertyName"] = property.Name,
["PropertyType"] = property.PropertyType.FullName,
["ValueKind"] = value?.GetType().FullName ?? "null",
["IsNull"] = value is null,
["StringLength"] = value is string text ? text.Length : null,
["PathKind"] = property.Name == "ModelFileName" && value is string path
? (Path.IsPathRooted(path) ? "Absolute" : "ProjectRelative")
: null,
["CollectionCount"] = value is ICollection collection ? collection.Count : null,
["StableHash"] = value is null ? null : Hash(value.ToString() ?? string.Empty)
};
}
private static string TypeName(Type type) => $"{type.FullName}, {type.Assembly.GetName().Name}, {type.Assembly.GetName().Version}";
private static string Hash(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)))[..16];
}
@@ -14,7 +14,9 @@
<Reference Include="PnPDataObjects"><HintPath>$(AutoCAD2026ManagedDir)\PnPDataObjects.dll</HintPath><Private>false</Private><CopyLocal>false</CopyLocal></Reference>
<Reference Include="PnPProjectManagerMgd"><HintPath>$(Plant3D2026ManagedDir)\PnPProjectManagerMgd.dll</HintPath><Private>false</Private><CopyLocal>false</CopyLocal></Reference>
<Reference Include="PnP3dObjectsMgd"><HintPath>$(Plant3D2026ManagedDir)\PnP3dObjectsMgd.dll</HintPath><Private>false</Private><CopyLocal>false</CopyLocal></Reference>
<Reference Include="PnP3dProjectPartsMgd"><HintPath>$(Plant3D2026ManagedDir)\PnP3dProjectPartsMgd.dll</HintPath><Private>false</Private><CopyLocal>false</CopyLocal></Reference>
<Reference Include="PnP3dOrthoProjectPart"><HintPath>$(Plant3D2026ManagedDir)\PnP3dOrthoProjectPart.dll</HintPath><Private>false</Private><CopyLocal>false</CopyLocal></Reference>
<Reference Include="PnPDwg2dDef"><HintPath>$(Plant3D2026ManagedDir)\PnPDwg2dDef.dll</HintPath><Private>false</Private><CopyLocal>false</CopyLocal></Reference>
<Reference Include="PnPDwg2dUtil"><HintPath>$(Plant3D2026ManagedDir)\PnPDwg2dUtil.dll</HintPath><Private>false</Private><CopyLocal>false</CopyLocal></Reference>
</ItemGroup>
</Project>
@@ -1,7 +1,9 @@
using System.Security.Cryptography;
using System.Text;
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.ProcessPower.DataLinks;
using Autodesk.ProcessPower.Drawings2d;
using Autodesk.ProcessPower.P3dProjectParts;
using Autodesk.ProcessPower.PlantInstance;
using Autodesk.ProcessPower.ProjectManager;
@@ -32,6 +34,9 @@ internal sealed class PlantRuntimeContextReader : IPlantRuntimeContextReader
return Empty(true, false, "NO_PLANT_PROJECT", "Активный Plant 3D-проект не найден.");
}
var displayName = string.IsNullOrWhiteSpace(project.Name) ? null : project.Name.Trim();
var projectStatus = displayName is null ? PlantProjectDetectionStatus.FoundWithoutDisplayName : PlantProjectDetectionStatus.FoundWithName;
OrthoProject? orthoProject;
try { orthoProject = PlantApplication.ProjectPart("Ortho") as OrthoProject; }
catch (Exception exception)
@@ -60,9 +65,10 @@ internal sealed class PlantRuntimeContextReader : IPlantRuntimeContextReader
HasPlantProject = true,
IsOrthoDrawing = true,
DrawingName = Path.GetFileName(document.Name),
ProjectDisplayName = project.Name,
ProjectDisplayName = displayName ?? "display name unavailable",
ProjectDetectionStatus = projectStatus,
Viewports = viewports,
Diagnostics = Array.Empty<RuntimeDiagnostic>(),
Diagnostics = displayName is null ? new[] { new RuntimeDiagnostic { Code = "PLANT_PROJECT_DISPLAY_NAME_EMPTY", Message = "Plant project is open but no display name is available.", Severity = "Warning" } } : Array.Empty<RuntimeDiagnostic>(),
Detection = new OrthoDetectionResult
{
Status = OrthoDetectionStatus.Confirmed,
@@ -82,6 +88,8 @@ internal sealed class PlantRuntimeContextReader : IPlantRuntimeContextReader
HasActiveDocument = hasDocument,
HasPlantProject = hasProject,
IsOrthoDrawing = false,
ProjectDisplayName = hasProject ? "display name unavailable" : null,
ProjectDetectionStatus = hasProject ? PlantProjectDetectionStatus.Ambiguous : PlantProjectDetectionStatus.NotFound,
Diagnostics = new[] { new RuntimeDiagnostic { Code = code, Message = message } },
Detection = new OrthoDetectionResult { Status = status, Evidence = evidence, Diagnostics = new[] { new RuntimeDiagnostic { Code = code, Message = message } } }
};
@@ -124,7 +132,7 @@ internal sealed class PlantOrthoViewportReader : IOrthoViewportReader
TwistAngle = 0,
IsOn = true,
IsLocked = false,
SourceDrawingName = sheet.DwgName,
SourceDrawingName = ReadSourceDrawingPath(view),
ViewName = view.ViewName
});
}
@@ -143,52 +151,229 @@ internal sealed class PlantOrthoViewportReader : IOrthoViewportReader
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(sheetId + "|" + viewName));
return "vp-" + Convert.ToHexString(hash)[..20];
}
private static string? ReadSourceDrawingPath(IDwgViewDef view)
{
// Confirmed Plant 3D 2026 contract (PnPDwg2dDef/PnPDwg2dUtil):
// IDwgViewDef.DwgQuery -> IDwgQuery.ModelFiles -> ModelFile.ModelFileName.
// The Ortho sheet is never used as a Piping DataLinks source.
try
{
if (!OrthoModelFilesContract.IsSupported(view, out var diagnostic))
{
RuntimeDiagnostics.Write("OrthoModelFilesContractRejected", "WARNING", resultStatus: diagnostic.Code, details: diagnostic.Details);
return null;
}
if (RuntimeDiagnostics.IsOrthoStructureDiagnosticsEnabled)
{
OrthoRuntimeStructureInspector.Inspect(view);
}
return view.DwgQuery?.ModelFiles
.Select(file => file.ModelFileName)
.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
}
catch (Exception exception)
{
RuntimeDiagnostics.Write("OrthoModelFilesReadFailed", "WARNING", resultStatus: "ORTHO_MODEL_FILES_API_UNSUPPORTED", exception: exception);
}
return null;
}
}
internal static class OrthoModelFilesContract
{
private const string ExpectedViewType = "Autodesk.ProcessPower.Drawings2d.DwgViewDef";
private const string ExpectedQueryType = "Autodesk.ProcessPower.Drawings2d.DwgQueryByBox";
private const string ExpectedModelFileType = "Autodesk.ProcessPower.Drawings2d.ModelFile";
private const string ExpectedDefAssembly = "PnPDwg2dDef";
private const string ExpectedUtilAssembly = "PnPDwg2dUtil";
private const string ExpectedVersion = "0.0.0.0";
public static bool IsSupported(IDwgViewDef view, out OrthoModelFilesContractDiagnostic diagnostic)
{
var query = view.DwgQuery;
var files = query?.ModelFiles;
var valid = HasExpectedType(view, ExpectedViewType, ExpectedDefAssembly) &&
query is not null && HasExpectedType(query, ExpectedQueryType, ExpectedDefAssembly) &&
files is not null && files.GetType() == typeof(ModelFile[]) &&
files.All(file => file is not null && HasExpectedType(file, ExpectedModelFileType, ExpectedUtilAssembly));
diagnostic = new OrthoModelFilesContractDiagnostic(
valid ? "ORTHO_MODEL_FILES_API_SUPPORTED" : "ORTHO_MODEL_FILES_API_UNSUPPORTED",
new Dictionary<string, object?>
{
["ExpectedViewType"] = ExpectedViewType,
["ActualViewType"] = TypeName(view),
["ExpectedQueryType"] = ExpectedQueryType,
["ActualQueryType"] = query is null ? null : TypeName(query),
["ExpectedModelFileType"] = ExpectedModelFileType,
["ActualModelFilesType"] = files?.GetType().AssemblyQualifiedName,
["ExpectedAssemblyVersion"] = ExpectedVersion,
["ActualViewAssemblyVersion"] = view.GetType().Assembly.GetName().Version?.ToString(),
["ActualQueryAssemblyVersion"] = query?.GetType().Assembly.GetName().Version?.ToString(),
["ActualModelFilesAssemblyVersion"] = files?.GetType().Assembly.GetName().Version?.ToString()
});
return valid;
}
private static bool HasExpectedType(object value, string expectedType, string expectedAssembly) =>
string.Equals(value.GetType().FullName, expectedType, StringComparison.Ordinal) &&
string.Equals(value.GetType().Assembly.GetName().Name, expectedAssembly, StringComparison.Ordinal) &&
string.Equals(value.GetType().Assembly.GetName().Version?.ToString(), ExpectedVersion, StringComparison.Ordinal);
private static string TypeName(object value) => $"{value.GetType().FullName}, {value.GetType().Assembly.GetName().Name}, {value.GetType().Assembly.GetName().Version}";
}
internal sealed record OrthoModelFilesContractDiagnostic(string Code, IReadOnlyDictionary<string, object?> Details);
internal sealed class PlantObjectReader : IPlantObjectReader
{
public IReadOnlyList<PlantObjectSnapshot> ReadObjects(OrthoViewportInfo viewport, CancellationToken cancellationToken)
{
var document = AcadApplication.DocumentManager.MdiActiveDocument;
if (document is null) return Array.Empty<PlantObjectSnapshot>();
if (string.IsNullOrWhiteSpace(viewport.SourceDrawingName))
{
WriteOutcome("ORTHO_SOURCE_DRAWING_NOT_RESOLVED", viewport);
return Array.Empty<PlantObjectSnapshot>();
}
PipingProject? pipingProject;
try { pipingProject = PlantApplication.ProjectPart("Piping") as PipingProject; }
catch (Exception exception)
{
WriteOutcome("PIPING_PROJECT_PART_NOT_FOUND", viewport, exception);
return Array.Empty<PlantObjectSnapshot>();
}
if (pipingProject is null)
{
WriteOutcome("PIPING_PROJECT_PART_NOT_FOUND", viewport);
return Array.Empty<PlantObjectSnapshot>();
}
var sourceDrawing = ResolveProjectDrawing(pipingProject, viewport.SourceDrawingName);
if (sourceDrawing is null) return Array.Empty<PlantObjectSnapshot>();
DataLinksManager? dataLinks;
try { dataLinks = PlantApplication.ProjectPart("Piping")?.DataLinksManager; }
catch { return Array.Empty<PlantObjectSnapshot>(); }
if (dataLinks is null) return Array.Empty<PlantObjectSnapshot>();
try { dataLinks = pipingProject.DataLinksManager; }
catch (Exception exception)
{
WriteOutcome("SOURCE_DATALINKS_UNAVAILABLE", viewport, exception);
return Array.Empty<PlantObjectSnapshot>();
}
if (dataLinks is null)
{
WriteOutcome("SOURCE_DATALINKS_UNAVAILABLE", viewport);
return Array.Empty<PlantObjectSnapshot>();
}
var snapshots = new List<PlantObjectSnapshot>();
foreach (var rowId in dataLinks.SelectAcPpRowIds(document.Database))
try
{
try
// Confirmed Plant 3D 2026 contract: resolve the source model to the
// Piping project drawing record, then read its DataLinks by drawing Id.
// The active Ortho database is deliberately never passed to DataLinks.
var rowIds = dataLinks.SelectAcPpRowIds(sourceDrawing.Id);
if (rowIds.Count == 0)
{
cancellationToken.ThrowIfCancellationRequested();
var className = dataLinks.GetObjectClassname(rowId);
var properties = dataLinks.GetAllProperties(rowId, true)
.GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase)
.ToDictionary(x => x.Key, x => x.Last().Value, StringComparer.OrdinalIgnoreCase);
string? Value(params string[] names) => properties.FirstOrDefault(x => names.Any(name => string.Equals(name, x.Key, StringComparison.OrdinalIgnoreCase))).Value;
snapshots.Add(new PlantObjectSnapshot
{
SnapshotId = StableId(Path.GetFileName(document.Name), rowId, className),
ObjectClass = className ?? string.Empty,
Tag = Value("Tag", "TAG"),
LineNumber = Value("Line Number", "LineNumber", "LINENUMBER"),
SupportNumber = Value("Support Number", "SupportNumber"),
EquipmentNumber = Value("Equipment Number", "EquipmentNumber"),
Geometry = new GeometrySnapshot(),
Properties = properties.ToDictionary(x => x.Key, x => (string?)x.Value, StringComparer.OrdinalIgnoreCase),
Diagnostics = new[] { "GEOMETRY_NOT_MAPPED" }
});
WriteOutcome("SOURCE_DATALINKS_NO_ROWS", viewport, details: new Dictionary<string, object?> { ["ProjectDrawingResolved"] = true });
return Array.Empty<PlantObjectSnapshot>();
}
catch
foreach (var rowId in rowIds)
{
// Keep the read path best-effort; no write operation is attempted.
try
{
cancellationToken.ThrowIfCancellationRequested();
var className = dataLinks.GetObjectClassname(rowId);
var properties = dataLinks.GetAllProperties(rowId, true)
.GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase)
.ToDictionary(x => x.Key, x => x.Last().Value, StringComparer.OrdinalIgnoreCase);
string? Value(params string[] names) => properties.FirstOrDefault(x => names.Any(name => string.Equals(name, x.Key, StringComparison.OrdinalIgnoreCase))).Value;
snapshots.Add(new PlantObjectSnapshot
{
SnapshotId = StableId(viewport.SourceDrawingName, rowId, className),
ObjectClass = className ?? string.Empty,
Tag = Value("Tag", "TAG"),
LineNumber = Value("Line Number", "LineNumber", "LINENUMBER"),
SupportNumber = Value("Support Number", "SupportNumber"),
EquipmentNumber = Value("Equipment Number", "EquipmentNumber"),
Geometry = new GeometrySnapshot(),
Properties = properties.ToDictionary(x => x.Key, x => (string?)x.Value, StringComparer.OrdinalIgnoreCase),
Diagnostics = new[] { "GEOMETRY_NOT_MAPPED" }
});
}
catch (Exception exception)
{
RuntimeDiagnostics.Write("SourceDataLinksRowReadFailed", "WARNING", resultStatus: "SOURCE_DATABASE_READ_FAILED", exception: exception);
}
}
}
catch (DLException exception)
{
WriteOutcome("SOURCE_DATALINKS_UNAVAILABLE", viewport, exception);
}
catch (Exception exception)
{
WriteOutcome("SOURCE_DATABASE_READ_FAILED", viewport, exception);
}
WriteOutcome(snapshots.Count == 0 ? "SOURCE_PIPE_ROWS_NOT_FOUND" : "SOURCE_DATALINKS_READ", viewport,
details: new Dictionary<string, object?> { ["SourcePlantObjectsRead"] = snapshots.Count, ["PipeObjectsRead"] = snapshots.Count });
return snapshots.OrderBy(x => x.SnapshotId, StringComparer.Ordinal).ToArray();
}
private static PnPProjectDrawing? ResolveProjectDrawing(PipingProject pipingProject, string sourceDrawingPath)
{
if (!Path.IsPathRooted(sourceDrawingPath))
{
WriteOutcome("ORTHO_SOURCE_DRAWING_NOT_IN_PROJECT", null);
return null;
}
string normalizedSource;
try { normalizedSource = Path.GetFullPath(sourceDrawingPath); }
catch (Exception exception)
{
WriteOutcome("ORTHO_SOURCE_DRAWING_NOT_RESOLVED", null, exception);
return null;
}
var matches = pipingProject.GetPnPDrawingFiles()
.Where(drawing => MatchesSourcePath(drawing, normalizedSource))
.OrderBy(drawing => drawing.Id)
.ToArray();
if (matches.Length == 1)
{
RuntimeDiagnostics.Write("SourceProjectDrawingResolved", resultStatus: "SOURCE_FROM_PROJECT_DRAWING_ROW", details: new Dictionary<string, object?> { ["ProjectDrawingResolved"] = true });
return matches[0];
}
WriteOutcome(matches.Length == 0 ? "ORTHO_SOURCE_DRAWING_NOT_IN_PROJECT" : "ORTHO_SOURCE_DRAWING_AMBIGUOUS", null,
details: new Dictionary<string, object?> { ["CandidateCount"] = matches.Length });
return null;
}
private static bool MatchesSourcePath(PnPProjectDrawing drawing, string normalizedSource) =>
EquivalentPath(drawing.AbsoluteFileName, normalizedSource) ||
EquivalentPath(drawing.ResolvedFilePath, normalizedSource);
private static bool EquivalentPath(string? candidate, string normalizedSource)
{
if (string.IsNullOrWhiteSpace(candidate)) return false;
try { return string.Equals(Path.GetFullPath(candidate), normalizedSource, StringComparison.OrdinalIgnoreCase); }
catch { return false; }
}
private static void WriteOutcome(string code, OrthoViewportInfo? viewport, Exception? exception = null, IReadOnlyDictionary<string, object?>? details = null)
{
var sanitizedDetails = new Dictionary<string, object?>
{
["SourceResolved"] = viewport?.SourceDrawingName is not null
};
if (details is not null) foreach (var detail in details) sanitizedDetails[detail.Key] = detail.Value;
RuntimeDiagnostics.Write("SourceDataLinksReadCompleted", code == "SOURCE_DATALINKS_READ" ? "INFO" : "WARNING", resultStatus: code, exception: exception, details: sanitizedDetails);
}
private static string StableId(string drawingName, int rowId, string? className)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"{drawingName}|{rowId}|{className}"));
@@ -14,6 +14,10 @@ internal static class RuntimeDiagnostics
public static string RuntimeMode => Environment.GetEnvironmentVariable("P_REC_ANNOT_RUNTIME_MODE") ?? "Normal";
public static bool IsOrthoStructureDiagnosticsEnabled =>
string.Equals(RuntimeMode, "DryRun", StringComparison.OrdinalIgnoreCase) &&
string.Equals(Environment.GetEnvironmentVariable("P_REC_ANNOT_DIAGNOSTICS"), "OrthoStructure", StringComparison.OrdinalIgnoreCase);
public static void Write(
string eventType,
string level = "INFO",
@@ -130,6 +130,85 @@ public sealed class FoundationTests
Assert.Equal(AnnotationOperationKind.Conflict, plan.Operations.Last().Kind);
}
[Fact] public void OrthoSourceResolver_OneView_ResolvesOneModelDrawing()
{
var result = new OrthoSourceDrawingResolver().Resolve(new OrthoDrawingSnapshot
{
StableId = "sheet",
Views = new[] { new OrthoViewSourceSnapshot { ViewStableId = "view", ReferencedDrawingPaths = new[] { "Plant 3D Models/TestPipeModel.dwg" } } }
});
var source = Assert.Single(result);
Assert.Equal(SourceDrawingResolutionStatus.Resolved, source.Status);
Assert.Equal("TestPipeModel.dwg", source.DisplayName);
}
[Fact] public void OrthoSourceResolver_DuplicateViews_DeduplicatesSource()
{
var result = new OrthoSourceDrawingResolver().Resolve(new OrthoDrawingSnapshot
{
StableId = "sheet",
Views = new[]
{
new OrthoViewSourceSnapshot { ViewStableId = "a", ReferencedDrawingPaths = new[] { "models/A.dwg" } },
new OrthoViewSourceSnapshot { ViewStableId = "b", ReferencedDrawingPaths = new[] { "models/A.dwg" } }
}
});
Assert.Single(result);
}
[Fact] public void OrthoSourceResolver_MissingSource_ReturnsDiagnostic()
{
var result = new OrthoSourceDrawingResolver().Resolve(new OrthoDrawingSnapshot { StableId = "sheet" });
var source = Assert.Single(result);
Assert.Equal(SourceDrawingResolutionStatus.NotReferenced, source.Status);
Assert.Contains("ORTHO_SOURCE_DRAWING_NOT_RESOLVED", source.EvidenceCodes);
}
[Fact] public void DwgQueryContract_UsesConfirmedModelFileContract()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "PlantReadPath.cs"));
Assert.Contains("view.DwgQuery?.ModelFiles", source);
Assert.Contains("file.ModelFileName", source);
Assert.Contains("ORTHO_MODEL_FILES_API_UNSUPPORTED", source);
}
[Fact] public void DwgQueryContract_DoesNotGuessAlternativePropertyNames()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "PlantReadPath.cs"));
Assert.DoesNotContain("GetProperty(\"ModelFileName\")", source);
Assert.DoesNotContain("GetProperty(\"Value\")", source);
Assert.DoesNotContain("GetProperty(\"FileName\")", source);
}
[Fact] public void OrthoSourceReader_UsesResolvedPipingProjectDrawingId()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "PlantReadPath.cs"));
Assert.Contains("pipingProject.GetPnPDrawingFiles()", source);
Assert.Contains("dataLinks.SelectAcPpRowIds(sourceDrawing.Id)", source);
}
[Fact] public void OrthoSourceReader_DoesNotQueryTheActiveOrthoDatabase()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "PlantReadPath.cs"));
Assert.DoesNotContain("SelectAcPpRowIds(sourceDatabase)", source);
Assert.DoesNotContain("ReadDwgFile(viewport.SourceDrawingName", source);
}
[Fact] public void OrthoSourceReader_ReportsDistinctSourceDiagnostics()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "PlantReadPath.cs"));
Assert.Contains("ORTHO_SOURCE_DRAWING_NOT_IN_PROJECT", source);
Assert.Contains("SOURCE_DATALINKS_UNAVAILABLE", source);
Assert.Contains("SOURCE_DATALINKS_NO_ROWS", source);
}
[Fact] public void RuntimeInspector_IsDisabledByDefault()
{
var source = File.ReadAllText(Path.Combine(ProjectRoot, "src", "PRecAnnot.Plant3D2026", "RuntimeDiagnostics.cs"));
Assert.Contains("P_REC_ANNOT_DIAGNOSTICS", source);
Assert.Contains("OrthoStructure", source);
}
[Fact] public void ApplyMode_IsBlocked() => Assert.Throws<InvalidOperationException>(() => new ExecutionModeWriteGuard(PRecAnnotExecutionMode.Apply).EnsureWriteAllowed("test"));
[Fact] public void DryRunPath_HasNoForWriteOperations()