diff --git a/docs/contracts/engine-bridge-v2.md b/docs/contracts/engine-bridge-v2.md new file mode 100644 index 0000000..ccb5485 --- /dev/null +++ b/docs/contracts/engine-bridge-v2.md @@ -0,0 +1,75 @@ +# Sprint Contract — engine-bridge v2 (MEF plugin masquerade) + +**Owner:** Generator +**Depends on:** engine-bridge v1 (완료) +**Issue:** #10 + +## Goal + +`engine-bridge` v1 probe design 문서가 권고한 **MEF plugin masquerade** 경로를 구현한다. recordingtest 전용 플러그인을 빌드해 `EG-BIM Modeler/Plugins/`에 drop-in하면, SUT가 로드할 때 플러그인이 로컬 HTTP 서버를 띄워 HmEG 상태(선택/카메라/씬/렌더)를 JSON으로 노출한다. recordingtest-side 클라이언트는 HTTP로 상태를 조회한다. + +PoC 범위: 플러그인 + 클라이언트 빌드 + 엔드투엔드 HTTP 테스트 (가짜 엔진 상태 주입). **SUT 실제 실행은 수동 smoke test 단계에서.** + +## Definition of Done + +- [ ] `src/Recordingtest.EgPlugin/` — MEF plugin dll + - Target: `net8.0-windows` + - `Editor03.PluginInterface.dll`을 `HintPath`로 참조 (Private=false, SUT의 정본 복사 금지) + - `Editor03.PluginInterface.dll`의 **실제 계약을 MetadataLoadContext로 분석**해서 구현할 최소 interface/attribute를 확인 (Generator가 discovery 수행) + - 플러그인 로드 시점에 `HttpListener`로 `http://localhost:{port}/` 시작 (기본 포트 `38080`, 환경변수 `RECORDINGTEST_BRIDGE_PORT`로 override) + - 엔드포인트: `/selection`, `/camera`, `/scene`, `/render`, `/health` + - 각 엔드포인트는 `IEngineStateProvider` 인터페이스로부터 JSON을 반환 + - 기본 `ReflectionEngineStateProvider`는 HmEG 내부 타입을 리플렉션으로 찾아 상태를 구성 (실패해도 예외 삼킴 — `{error: "..."}` 반환) + - 플러그인 unload 시 HttpListener 정리 +- [ ] `src/Recordingtest.EngineBridge.Client/` — HTTP 클라이언트 라이브러리 + - `HmEgHttpSnapshot : IEngineSnapshot` 구현 (v1 인터페이스 재사용) + - `HttpClient`로 엔드포인트 호출, 타임아웃 기본 2초 + - 각 속성은 on-demand HTTP GET (caching 없음 v2) + - `IsRenderComplete`는 `/render` 응답의 `complete` 필드 +- [ ] `tests/Recordingtest.EngineBridge.IntegrationTests/` — xUnit + - **fake HTTP 서버**: `HttpListener`를 테스트 안에서 띄우고 고정 JSON 응답 + - 테스트 ≥ 6: + 1. `Client_SelectionEndpoint_ReturnsIds` + 2. `Client_CameraEndpoint_ReturnsCameraState` + 3. `Client_SceneEndpoint_ReturnsSceneSummary` + 4. `Client_RenderEndpoint_ReturnsIsComplete` + 5. `Client_HealthEndpoint_ReturnsOk` + 6. `Client_Timeout_ThrowsOrReturnsError` (2초 이내 미응답 시) +- [ ] `src/Recordingtest.EgPlugin` 단위 테스트 ≥ 3 (플러그인 로직) + - 테스트에서 **실제 plugin dll을 SUT에 주입하지 않음**. 순수 로직만. + - `StateRouter_SerializesSelection_ToJson` + - `StateRouter_WithFaultyProvider_ReturnsErrorPayload` + - `PortResolver_PrefersEnvVar` (`RECORDINGTEST_BRIDGE_PORT`) +- [ ] `docs/guides/engine-bridge-deploy.md` — 수동 배포 가이드 (SUT Plugins/ 폴더에 dll 복사, 환경변수 설정, 검증 절차) +- [ ] `dotnet build` + `dotnet test` 전부 green +- [ ] 플러그인이 SUT 자체 파일을 건드리지 않음 (guard hook 준수) + +## Out of scope + +- SUT 실제 실행 (수동 smoke test) +- 인증/암호화 (PoC는 localhost only) +- 멀티클라이언트 / 동시성 (단일 HttpListener) +- HmEG 리플렉션 매핑 완성도 — v2는 skeleton + error fallback 중심, 진짜 매핑은 smoke test 후 조정 + +## Interfaces + +- **Inputs:** SUT가 plugin을 로드할 때의 MEF 계약 +- **Outputs:** `http://localhost:/{selection|camera|scene|render|health}` JSON +- **Side effects:** HttpListener 포트 점유 (플러그인 생명주기 내) + +## Evaluation plan + +1. `dotnet build recordingtest.sln` green +2. `dotnet test tests/Recordingtest.EngineBridge.IntegrationTests` — 6 pass +3. `dotnet test tests/Recordingtest.EgPlugin.Tests` — 3 pass +4. `Recordingtest.EgPlugin.dll` 출력 확인, `Editor03.PluginInterface`에 대한 HintPath/Private=false 확인 (csproj 리뷰) +5. Generator가 Editor03.PluginInterface.dll 메타데이터에서 발견한 실제 인터페이스 이름을 history에 기록했는지 확인 +6. `docs/guides/engine-bridge-deploy.md` 존재 + 배포 단계(복사·환경변수·검증) 기술 +7. SUT 폴더에 쓰기 흔적 없음 (grep) +8. Plugin 코드가 `HttpListener` 예외 상황(port 충돌, stop) 처리 + +## Risks + +- `Editor03.PluginInterface`가 WPF dependency를 가지면 net8.0-windows로 충분하지 않을 수 있음 — 필요 시 TFM 조정 +- 플러그인 dll이 SUT와 같은 디렉터리의 다른 dll 버전과 충돌 가능 — Private=false 필수 +- HmEG 리플렉션 실제 매핑은 SUT 런타임에서만 검증 가능 → v2는 error fallback 철저 diff --git a/docs/guides/engine-bridge-deploy.md b/docs/guides/engine-bridge-deploy.md new file mode 100644 index 0000000..14dba8a --- /dev/null +++ b/docs/guides/engine-bridge-deploy.md @@ -0,0 +1,97 @@ +# engine-bridge v2 Deployment Guide + +This guide explains how to deploy `Recordingtest.EgPlugin.dll` into the SUT +(`EG-BIM Modeler`) so the recordingtest harness can read live HmEG state over +HTTP. Issue #10. + +## 1. Build + +``` +dotnet publish src/Recordingtest.EgPlugin -c Release +``` + +Output: `src/Recordingtest.EgPlugin/bin/Release/net8.0-windows/publish/` +- `Recordingtest.EgPlugin.dll` +- `Recordingtest.EgPlugin.deps.json` +- `Recordingtest.EgPlugin.pdb` + +`Editor03.PluginInterface.dll` and `HmEG.dll` are referenced with +`false`, so the plugin output **does not** include copies +of the SUT contracts. The plugin will bind to whatever the SUT loads at +runtime. + +## 2. Copy into SUT Plugins folder + +> **Important:** writing into `EG-BIM Modeler/` is normally blocked by the +> repo guard hook. Ask the operator before performing the copy step. The +> copy is intentionally a manual operation. + +Create a per-plugin folder under `EG-BIM Modeler/Plugins/` and copy the +publish output: + +``` +mkdir "EG-BIM Modeler\Plugins\Recordingtest.EgPlugin" +copy src\Recordingtest.EgPlugin\bin\Release\net8.0-windows\publish\Recordingtest.EgPlugin.dll "EG-BIM Modeler\Plugins\Recordingtest.EgPlugin\" +copy src\Recordingtest.EgPlugin\bin\Release\net8.0-windows\publish\Recordingtest.EgPlugin.deps.json "EG-BIM Modeler\Plugins\Recordingtest.EgPlugin\" +``` + +`runtimeconfig.json` is not produced for class library projects; the SUT +hosts the CLR. If a future change makes the plugin executable, also copy +`Recordingtest.EgPlugin.runtimeconfig.json`. + +## 3. Configure environment (optional) + +The plugin listens on `http://localhost:38080/` by default. Override with: + +``` +set RECORDINGTEST_BRIDGE_PORT=38090 +``` + +The variable is read once at plugin construction. + +## 4. Launch SUT + +Start `EG-BIM Modeler.exe` normally. The SUT's `HmEG.PluginLoader` walks +`Plugins/` at startup and loads any DLL whose type derives from +`HmEG.IPlugin` (our plugin inherits `Editor.PluginInterface.EditorPlugin`, +which implements `IPlugin`). + +## 5. Verify + +``` +curl http://localhost:38080/health +``` + +Expected: + +``` +{"status":"ok","port":38080} +``` + +Other endpoints: + +- `GET /selection` -> `{"selected_ids":[...]}` +- `GET /camera` -> `{"eye":[..],"target":[..],"up":[..],"fov":n}` +- `GET /scene` -> `{"object_count":n,"document_path":"..."}` +- `GET /render` -> `{"complete":true|false}` + +The recordingtest client (`HmEgHttpSnapshot`) is the supported consumer. + +## 6. Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| `curl` connection refused | port already in use OR plugin failed to load | check SUT log under `EG-BIM Modeler/hmlogs/`, set a different `RECORDINGTEST_BRIDGE_PORT` | +| `Could not load file or assembly Editor03.PluginInterface` | Wrong contract version dropped next to plugin | delete any local copy of `Editor03.PluginInterface.dll` from the plugin folder; the SUT must resolve it from its own folder | +| Plugin loaded but `/health` 404 | SUT started a different `Plugins/Recordingtest.EgPlugin/` build | clean the folder and re-copy publish output | +| `HttpListener` access denied (Windows) | URL ACL not registered | run SUT elevated once, or `netsh http add urlacl url=http://localhost:38080/ user=Everyone` | + +SUT log location: `EG-BIM Modeler/hmlogs/` (Serilog rolling files). + +## 7. Uninstall + +``` +rmdir /s /q "EG-BIM Modeler\Plugins\Recordingtest.EgPlugin" +``` + +No registry, no services, no env vars beyond optional `RECORDINGTEST_BRIDGE_PORT`. diff --git a/docs/history/2026-04-07_이슈10-engine-bridge-v2-generator.md b/docs/history/2026-04-07_이슈10-engine-bridge-v2-generator.md new file mode 100644 index 0000000..9ff5be9 --- /dev/null +++ b/docs/history/2026-04-07_이슈10-engine-bridge-v2-generator.md @@ -0,0 +1,78 @@ +# 2026-04-07 — Issue #10 engine-bridge v2 Generator + +- 역할: Generator +- 계약: `docs/contracts/engine-bridge-v2.md` +- 소요 시간: 약 35분 (단일 세션) +- Context 사용량: 약 95k tokens (대용량 sln/.cs 디렉터리 스캔 + MetadataLoadContext 출력) + +## Editor03 / HmEG 디스커버리 결과 + +`MetadataLoadContext`로 `EG-BIM Modeler/Editor03.PluginInterface.dll`, +`Editor07.WidgetPluginInterface.dll`, `HmEG.dll`, `Plugins/EgBoxPlugin/EgBoxPlugin.dll`을 +메타데이터 전용으로 열어 다음을 확인했다: + +- **실제 plugin 컨트랙트는 `HmEG.IPlugin`** (HmEG.dll). Members: + `string Name { get; }`, `EGViewport View { get; set; }`, + `bool RethrowException { get; set; }`, `object Run(object[])`. +- **MEF Export 어트리뷰트는 사용되지 않는다.** 로딩은 `HmEG.PluginLoader` + (`LoadProjectPlugins`/`LoadPlugin(path,name)`)가 직접 수행한다. + `EgBoxPlugin.EgBoxPlugin` 샘플도 `[Export]` 없이 단순히 + `Editor.PluginInterface.EditorPlugin`을 상속한다. +- **`Editor.PluginInterface.EditorPlugin`** (Editor03.PluginInterface.dll) + 은 `HmEG.IPlugin`을 구현하는 추상 base다. + abstract 멤버: `Name { get; }`, `Description { get; }`, + `protected void Initialize()`. 그 외 `AppManager`, `RootSpace`, + `ViewportManager`, `View`, `AddModelToRootSpace(...)` 등의 헬퍼를 노출한다. +- **`Editor07.WidgetPluginInterface`** 는 위젯 전용(`WidzetPlugin`, + `HmEG_DebugWidzetPlugin`)이며 v2 범위 밖이라 미사용. + +→ 결론: `HmEgBridgePlugin : Editor.PluginInterface.EditorPlugin` 으로 +구현. `Name`/`Description` override + `protected override Initialize()`, +HTTP listener는 생성자에서 안전하게 부팅 (`Initialize`가 호출되지 않더라도 +listener는 살아있음). + +### 디스커버리 시 주의사항 + +- MetadataLoadContext에 `Microsoft.NETCore.App` 런타임 디렉터리만 넣으면 + `WindowsBase`의 `System.Windows.Markup.InternalTypeHelper`를 못 찾아 + `TypeLoadException`이 난다. **WindowsDesktop ref pack + (`packs/Microsoft.WindowsDesktop.App.Ref/8.0.22/ref/net8.0`)** 도 함께 + resolver에 등록해야 한다. 동일 파일명은 dedupe 필요. +- 같은 이유로 `Editor03.PluginInterface`는 `true`인 + net8.0-windows 프로젝트에서만 빌드된다. + +## 산출물 + +- `src/Recordingtest.EgPlugin/` (PortResolver, IEngineStateProvider, + Null/ReflectionEngineStateProvider, StateRouter, BridgeHttpServer, + HmEgBridgePlugin) +- `src/Recordingtest.EngineBridge.Client/` (HmEgHttpSnapshot, + EngineBridgeException) +- `tests/Recordingtest.EngineBridge.IntegrationTests/` (FakeBridgeServer + + 6 xUnit tests) +- `tests/Recordingtest.EgPlugin.Tests/` (5 xUnit tests for StateRouter + + PortResolver) +- `docs/guides/engine-bridge-deploy.md` + +## 빌드 / 테스트 결과 + +- `dotnet build recordingtest.sln` → green (warning 0, error 0) +- `dotnet test tests/Recordingtest.EngineBridge.IntegrationTests` → 6/6 통과 +- `dotnet test tests/Recordingtest.EgPlugin.Tests` → 5/5 통과 +- 신규 프로젝트 4개를 `recordingtest.sln`에 추가 +- `EG-BIM Modeler/` 폴더에는 일체 쓰지 않음 (가드 훅 준수). 배포는 가이드 + 문서로만 기술. + +## 리스크 / 후속 + +- `ReflectionEngineStateProvider`는 v2 skeleton: 모든 메서드가 안전한 + default를 반환한다. **HmEG 내부 타입(특히 `HmEGAppManager`, + `EGViewport`, 선택/카메라 manager)에 대한 진짜 reflection 매핑은 v3에서 + smoke test 후 확정해야 한다.** 후보 멤버는 + `docs/engine-catalog/hmeg-candidates.json` 참고. +- `HttpListener` urlacl이 등록 안 된 환경에서는 첫 실행에 관리자 권한 또는 + `netsh http add urlacl` 필요 — 가이드에 명시. +- `Editor.PluginInterface.EditorPlugin.Initialize()`가 protected이고 + HmEG.PluginLoader가 어느 시점에 호출하는지는 메타데이터로 확인 불가 + (런타임 동작). 그래서 listener를 생성자에서 부팅했다. PluginLoader가 + 생성자 호출만으로 충분한지는 smoke test에서 확인 필요. diff --git a/recordingtest.sln b/recordingtest.sln index 089886b..ea89653 100644 --- a/recordingtest.sln +++ b/recordingtest.sln @@ -33,6 +33,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EngineBridge. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EngineBridge.Tests", "tests\Recordingtest.EngineBridge.Tests\Recordingtest.EngineBridge.Tests.csproj", "{0811AC32-E2A4-4BFD-A29A-6451F5756F10}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EgPlugin", "src\Recordingtest.EgPlugin\Recordingtest.EgPlugin.csproj", "{51D7B803-5F6E-4B78-9A5D-326F28CD934F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EngineBridge.Client", "src\Recordingtest.EngineBridge.Client\Recordingtest.EngineBridge.Client.csproj", "{45D80D0C-A8A1-4173-B28C-68F0628EE346}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EngineBridge.IntegrationTests", "tests\Recordingtest.EngineBridge.IntegrationTests\Recordingtest.EngineBridge.IntegrationTests.csproj", "{BA346F72-6F9C-4D68-9CDD-DD05F9687095}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EgPlugin.Tests", "tests\Recordingtest.EgPlugin.Tests\Recordingtest.EgPlugin.Tests.csproj", "{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -223,6 +231,54 @@ Global {0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Release|x64.Build.0 = Release|Any CPU {0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Release|x86.ActiveCfg = Release|Any CPU {0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Release|x86.Build.0 = Release|Any CPU + {51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|x64.ActiveCfg = Debug|Any CPU + {51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|x64.Build.0 = Debug|Any CPU + {51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|x86.ActiveCfg = Debug|Any CPU + {51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|x86.Build.0 = Debug|Any CPU + {51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|Any CPU.Build.0 = Release|Any CPU + {51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|x64.ActiveCfg = Release|Any CPU + {51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|x64.Build.0 = Release|Any CPU + {51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|x86.ActiveCfg = Release|Any CPU + {51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|x86.Build.0 = Release|Any CPU + {45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|Any CPU.Build.0 = Debug|Any CPU + {45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|x64.ActiveCfg = Debug|Any CPU + {45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|x64.Build.0 = Debug|Any CPU + {45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|x86.ActiveCfg = Debug|Any CPU + {45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|x86.Build.0 = Debug|Any CPU + {45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|Any CPU.ActiveCfg = Release|Any CPU + {45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|Any CPU.Build.0 = Release|Any CPU + {45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|x64.ActiveCfg = Release|Any CPU + {45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|x64.Build.0 = Release|Any CPU + {45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|x86.ActiveCfg = Release|Any CPU + {45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|x86.Build.0 = Release|Any CPU + {BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|x64.ActiveCfg = Debug|Any CPU + {BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|x64.Build.0 = Debug|Any CPU + {BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|x86.ActiveCfg = Debug|Any CPU + {BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|x86.Build.0 = Debug|Any CPU + {BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|Any CPU.Build.0 = Release|Any CPU + {BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|x64.ActiveCfg = Release|Any CPU + {BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|x64.Build.0 = Release|Any CPU + {BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|x86.ActiveCfg = Release|Any CPU + {BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|x86.Build.0 = Release|Any CPU + {315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|x64.ActiveCfg = Debug|Any CPU + {315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|x64.Build.0 = Debug|Any CPU + {315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|x86.ActiveCfg = Debug|Any CPU + {315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|x86.Build.0 = Debug|Any CPU + {315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|Any CPU.Build.0 = Release|Any CPU + {315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|x64.ActiveCfg = Release|Any CPU + {315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|x64.Build.0 = Release|Any CPU + {315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|x86.ActiveCfg = Release|Any CPU + {315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -242,5 +298,9 @@ Global {938D464B-B810-425F-83B6-52877B584DE2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {B1EAD466-9C07-4C07-907C-3D5794F6689D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {0811AC32-E2A4-4BFD-A29A-6451F5756F10} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {51D7B803-5F6E-4B78-9A5D-326F28CD934F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {45D80D0C-A8A1-4173-B28C-68F0628EE346} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {BA346F72-6F9C-4D68-9CDD-DD05F9687095} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {315F3B4F-BF8F-4DBF-8F06-CAF55152725D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/src/Recordingtest.EgPlugin/BridgeHttpServer.cs b/src/Recordingtest.EgPlugin/BridgeHttpServer.cs new file mode 100644 index 0000000..423fba6 --- /dev/null +++ b/src/Recordingtest.EgPlugin/BridgeHttpServer.cs @@ -0,0 +1,66 @@ +using System.Net; +using System.Text; + +namespace Recordingtest.EgPlugin; + +/// +/// Hosts an HttpListener that delegates path routing to . +/// Designed to swallow listener errors so it never destabilises the SUT. +/// +public sealed class BridgeHttpServer : IDisposable +{ + private readonly HttpListener _listener; + private readonly StateRouter _router; + private readonly Thread _thread; + private volatile bool _stopping; + + public int Port { get; } + + public BridgeHttpServer(StateRouter router, int port) + { + _router = router; + Port = port; + _listener = new HttpListener(); + _listener.Prefixes.Add($"http://localhost:{port}/"); + _thread = new Thread(Loop) { IsBackground = true, Name = "RecordingtestBridge" }; + } + + public void Start() + { + try { _listener.Start(); } + catch { return; } + _thread.Start(); + } + + private void Loop() + { + while (!_stopping && _listener.IsListening) + { + HttpListenerContext ctx; + try { ctx = _listener.GetContext(); } + catch { return; } + try + { + var path = ctx.Request.Url?.AbsolutePath ?? "/"; + var (status, body) = _router.Route(path); + var bytes = Encoding.UTF8.GetBytes(body); + ctx.Response.StatusCode = (int)status; + ctx.Response.ContentType = "application/json"; + ctx.Response.ContentLength64 = bytes.LongLength; + ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); + ctx.Response.OutputStream.Close(); + } + catch + { + try { ctx.Response.Abort(); } catch { } + } + } + } + + public void Dispose() + { + _stopping = true; + try { if (_listener.IsListening) _listener.Stop(); } catch { } + try { _listener.Close(); } catch { } + } +} diff --git a/src/Recordingtest.EgPlugin/HmEgBridgePlugin.cs b/src/Recordingtest.EgPlugin/HmEgBridgePlugin.cs new file mode 100644 index 0000000..770ec24 --- /dev/null +++ b/src/Recordingtest.EgPlugin/HmEgBridgePlugin.cs @@ -0,0 +1,48 @@ +using Editor.PluginInterface; + +namespace Recordingtest.EgPlugin; + +/// +/// MEF/PluginLoader-discovered plugin. Inherits the SUT's EditorPlugin +/// abstract base (which itself implements HmEG.IPlugin), and on construction +/// boots a localhost HTTP bridge that exposes HmEG state to recordingtest. +/// +public sealed class HmEgBridgePlugin : EditorPlugin, IDisposable +{ + private BridgeHttpServer? _server; + + public HmEgBridgePlugin() + { + StartBridge(); + } + + public override string Name => "Recordingtest.EgPlugin"; + public override string Description => "recordingtest engine-bridge v2 (HTTP sidecar)"; + + protected override void Initialize() + { + // Construction already started the bridge; Initialize is a no-op safeguard. + } + + private void StartBridge() + { + try + { + var port = PortResolver.Resolve(); + var provider = new ReflectionEngineStateProvider(this); + var router = new StateRouter(provider, port); + _server = new BridgeHttpServer(router, port); + _server.Start(); + } + catch + { + // never throw out of plugin construction; SUT must remain stable. + } + } + + public void Dispose() + { + try { _server?.Dispose(); } catch { } + _server = null; + } +} diff --git a/src/Recordingtest.EgPlugin/IEngineStateProvider.cs b/src/Recordingtest.EgPlugin/IEngineStateProvider.cs new file mode 100644 index 0000000..610d0b4 --- /dev/null +++ b/src/Recordingtest.EgPlugin/IEngineStateProvider.cs @@ -0,0 +1,56 @@ +namespace Recordingtest.EgPlugin; + +public interface IEngineStateProvider +{ + IReadOnlyList GetSelectedIds(); + CameraSnapshot GetCamera(); + SceneSnapshot GetScene(); + bool GetRenderComplete(); +} + +public sealed record CameraSnapshot(double[] Eye, double[] Target, double[] Up, double Fov); +public sealed record SceneSnapshot(int ObjectCount, string? DocumentPath); + +public sealed class NullEngineStateProvider : IEngineStateProvider +{ + public IReadOnlyList GetSelectedIds() => Array.Empty(); + public CameraSnapshot GetCamera() => new(new double[] { 0, 0, 0 }, new double[] { 0, 0, 0 }, new double[] { 0, 0, 1 }, 45.0); + public SceneSnapshot GetScene() => new(0, null); + public bool GetRenderComplete() => true; +} + +/// +/// Skeleton reflection-based provider. v2 returns safe defaults; real HmEG mapping happens in v3 once SUT smoke tests confirm field shapes. +/// +public sealed class ReflectionEngineStateProvider : IEngineStateProvider +{ + private readonly object? _appManager; + + public ReflectionEngineStateProvider(object? appManager) + { + _appManager = appManager; + } + + public IReadOnlyList GetSelectedIds() + { + try { _ = _appManager; return Array.Empty(); } + catch { return Array.Empty(); } + } + + public CameraSnapshot GetCamera() + { + try { return new(new double[] { 0, 0, 0 }, new double[] { 0, 0, 0 }, new double[] { 0, 0, 1 }, 45.0); } + catch { return new(new double[] { 0, 0, 0 }, new double[] { 0, 0, 0 }, new double[] { 0, 0, 1 }, 45.0); } + } + + public SceneSnapshot GetScene() + { + try { return new(0, null); } + catch { return new(0, null); } + } + + public bool GetRenderComplete() + { + try { return true; } catch { return false; } + } +} diff --git a/src/Recordingtest.EgPlugin/PortResolver.cs b/src/Recordingtest.EgPlugin/PortResolver.cs new file mode 100644 index 0000000..2d180ed --- /dev/null +++ b/src/Recordingtest.EgPlugin/PortResolver.cs @@ -0,0 +1,18 @@ +namespace Recordingtest.EgPlugin; + +public static class PortResolver +{ + public const int DefaultPort = 38080; + public const string EnvVarName = "RECORDINGTEST_BRIDGE_PORT"; + + public static int Resolve(Func? envReader = null) + { + envReader ??= Environment.GetEnvironmentVariable; + var raw = envReader(EnvVarName); + if (!string.IsNullOrWhiteSpace(raw) && int.TryParse(raw, out var p) && p > 0 && p < 65536) + { + return p; + } + return DefaultPort; + } +} diff --git a/src/Recordingtest.EgPlugin/Recordingtest.EgPlugin.csproj b/src/Recordingtest.EgPlugin/Recordingtest.EgPlugin.csproj new file mode 100644 index 0000000..02e3324 --- /dev/null +++ b/src/Recordingtest.EgPlugin/Recordingtest.EgPlugin.csproj @@ -0,0 +1,21 @@ + + + net8.0-windows + true + enable + enable + true + Recordingtest.EgPlugin + true + + + + ..\..\EG-BIM Modeler\Editor03.PluginInterface.dll + false + + + ..\..\EG-BIM Modeler\HmEG.dll + false + + + diff --git a/src/Recordingtest.EgPlugin/StateRouter.cs b/src/Recordingtest.EgPlugin/StateRouter.cs new file mode 100644 index 0000000..4ae9ad2 --- /dev/null +++ b/src/Recordingtest.EgPlugin/StateRouter.cs @@ -0,0 +1,116 @@ +using System.Globalization; +using System.Net; +using System.Text; + +namespace Recordingtest.EgPlugin; + +/// +/// Pure logic router: maps a request path to (status, json body). +/// No HttpListener dependency so it can be unit tested cheaply. +/// +public sealed class StateRouter +{ + private readonly IEngineStateProvider _provider; + private readonly int _port; + + public StateRouter(IEngineStateProvider provider, int port) + { + _provider = provider; + _port = port; + } + + public (HttpStatusCode Status, string Body) Route(string path) + { + var p = (path ?? "/").TrimEnd('/'); + if (p.Length == 0) p = "/"; + try + { + return p switch + { + "/health" => (HttpStatusCode.OK, $"{{\"status\":\"ok\",\"port\":{_port}}}"), + "/selection" => (HttpStatusCode.OK, BuildSelection()), + "/camera" => (HttpStatusCode.OK, BuildCamera()), + "/scene" => (HttpStatusCode.OK, BuildScene()), + "/render" => (HttpStatusCode.OK, BuildRender()), + _ => (HttpStatusCode.NotFound, "{\"error\":\"not_found\"}") + }; + } + catch (Exception ex) + { + return (HttpStatusCode.OK, $"{{\"error\":{JsonString(ex.Message)}}}"); + } + } + + private string BuildSelection() + { + var ids = _provider.GetSelectedIds(); + var sb = new StringBuilder(); + sb.Append("{\"selected_ids\":["); + for (int i = 0; i < ids.Count; i++) + { + if (i > 0) sb.Append(','); + sb.Append(JsonString(ids[i])); + } + sb.Append("]}"); + return sb.ToString(); + } + + private string BuildCamera() + { + var c = _provider.GetCamera(); + return "{\"eye\":" + Vec(c.Eye) + ",\"target\":" + Vec(c.Target) + ",\"up\":" + Vec(c.Up) + ",\"fov\":" + Num(c.Fov) + "}"; + } + + private string BuildScene() + { + var s = _provider.GetScene(); + return "{\"object_count\":" + s.ObjectCount.ToString(CultureInfo.InvariantCulture) + + ",\"document_path\":" + (s.DocumentPath is null ? "null" : JsonString(s.DocumentPath)) + "}"; + } + + private string BuildRender() + { + var done = _provider.GetRenderComplete(); + return "{\"complete\":" + (done ? "true" : "false") + "}"; + } + + private static string Vec(double[] v) + { + var sb = new StringBuilder(); + sb.Append('['); + for (int i = 0; i < v.Length; i++) + { + if (i > 0) sb.Append(','); + sb.Append(Num(v[i])); + } + sb.Append(']'); + return sb.ToString(); + } + + private static string Num(double d) => d.ToString("R", CultureInfo.InvariantCulture); + + private static string JsonString(string s) + { + var sb = new StringBuilder(); + sb.Append('"'); + foreach (var ch in s) + { + switch (ch) + { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\b': sb.Append("\\b"); break; + case '\f': sb.Append("\\f"); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + default: + if (ch < 0x20) sb.Append("\\u").Append(((int)ch).ToString("x4", CultureInfo.InvariantCulture)); + else sb.Append(ch); + break; + } + } + sb.Append('"'); + return sb.ToString(); + } +} diff --git a/src/Recordingtest.EngineBridge.Client/EngineBridgeException.cs b/src/Recordingtest.EngineBridge.Client/EngineBridgeException.cs new file mode 100644 index 0000000..57ccdd4 --- /dev/null +++ b/src/Recordingtest.EngineBridge.Client/EngineBridgeException.cs @@ -0,0 +1,11 @@ +namespace Recordingtest.EngineBridge.Client; + +public sealed class EngineBridgeException : Exception +{ + public string Endpoint { get; } + public EngineBridgeException(string endpoint, string message, Exception? inner = null) + : base($"engine-bridge {endpoint}: {message}", inner) + { + Endpoint = endpoint; + } +} diff --git a/src/Recordingtest.EngineBridge.Client/HmEgHttpSnapshot.cs b/src/Recordingtest.EngineBridge.Client/HmEgHttpSnapshot.cs new file mode 100644 index 0000000..a7880b3 --- /dev/null +++ b/src/Recordingtest.EngineBridge.Client/HmEgHttpSnapshot.cs @@ -0,0 +1,121 @@ +using System.Net.Http; +using System.Text.Json; + +namespace Recordingtest.EngineBridge.Client; + +public sealed class HmEgHttpSnapshot : IEngineSnapshot, IDisposable +{ + public const string DefaultBaseUrl = "http://localhost:38080"; + private readonly HttpClient _http; + private readonly bool _ownsClient; + private readonly string _baseUrl; + + public HmEgHttpSnapshot(string baseUrl = DefaultBaseUrl, HttpClient? httpClient = null, TimeSpan? timeout = null) + { + _baseUrl = baseUrl.TrimEnd('/'); + if (httpClient is null) + { + _http = new HttpClient { Timeout = timeout ?? TimeSpan.FromSeconds(2) }; + _ownsClient = true; + } + else + { + _http = httpClient; + if (timeout.HasValue) _http.Timeout = timeout.Value; + _ownsClient = false; + } + } + + public IReadOnlyList SelectedObjectIds + { + get + { + using var doc = Get("/selection"); + var arr = doc.RootElement.GetProperty("selected_ids"); + var list = new List(arr.GetArrayLength()); + foreach (var e in arr.EnumerateArray()) list.Add(e.GetString() ?? string.Empty); + return list; + } + } + + public CameraState Camera + { + get + { + using var doc = Get("/camera"); + var r = doc.RootElement; + return new CameraState( + ToArray(r.GetProperty("eye")), + ToArray(r.GetProperty("target")), + ToArray(r.GetProperty("up")), + r.GetProperty("fov").GetDouble()); + } + } + + public SceneSummary Scene + { + get + { + using var doc = Get("/scene"); + var r = doc.RootElement; + string? path = r.TryGetProperty("document_path", out var dp) && dp.ValueKind == JsonValueKind.String ? dp.GetString() : null; + return new SceneSummary(r.GetProperty("object_count").GetInt32(), path); + } + } + + public bool IsRenderComplete + { + get + { + using var doc = Get("/render"); + return doc.RootElement.GetProperty("complete").GetBoolean(); + } + } + + public bool IsHealthy + { + get + { + try + { + using var doc = Get("/health"); + return doc.RootElement.TryGetProperty("status", out var s) && s.GetString() == "ok"; + } + catch (EngineBridgeException) { return false; } + } + } + + private JsonDocument Get(string endpoint) + { + try + { + using var resp = _http.GetAsync(_baseUrl + endpoint).GetAwaiter().GetResult(); + if (!resp.IsSuccessStatusCode) + throw new EngineBridgeException(endpoint, $"HTTP {(int)resp.StatusCode}"); + var body = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + return JsonDocument.Parse(body); + } + catch (EngineBridgeException) { throw; } + catch (TaskCanceledException ex) + { + throw new EngineBridgeException(endpoint, "timeout", ex); + } + catch (Exception ex) + { + throw new EngineBridgeException(endpoint, ex.Message, ex); + } + } + + private static double[] ToArray(JsonElement e) + { + var arr = new double[e.GetArrayLength()]; + int i = 0; + foreach (var item in e.EnumerateArray()) arr[i++] = item.GetDouble(); + return arr; + } + + public void Dispose() + { + if (_ownsClient) _http.Dispose(); + } +} diff --git a/src/Recordingtest.EngineBridge.Client/Recordingtest.EngineBridge.Client.csproj b/src/Recordingtest.EngineBridge.Client/Recordingtest.EngineBridge.Client.csproj new file mode 100644 index 0000000..ddfd988 --- /dev/null +++ b/src/Recordingtest.EngineBridge.Client/Recordingtest.EngineBridge.Client.csproj @@ -0,0 +1,12 @@ + + + net8.0 + enable + enable + true + Recordingtest.EngineBridge.Client + + + + + diff --git a/tests/Recordingtest.EgPlugin.Tests/Recordingtest.EgPlugin.Tests.csproj b/tests/Recordingtest.EgPlugin.Tests/Recordingtest.EgPlugin.Tests.csproj new file mode 100644 index 0000000..61777e2 --- /dev/null +++ b/tests/Recordingtest.EgPlugin.Tests/Recordingtest.EgPlugin.Tests.csproj @@ -0,0 +1,18 @@ + + + net8.0-windows + enable + enable + true + false + Recordingtest.EgPlugin.Tests + + + + + + + + + + diff --git a/tests/Recordingtest.EgPlugin.Tests/StateRouterTests.cs b/tests/Recordingtest.EgPlugin.Tests/StateRouterTests.cs new file mode 100644 index 0000000..37e416c --- /dev/null +++ b/tests/Recordingtest.EgPlugin.Tests/StateRouterTests.cs @@ -0,0 +1,64 @@ +using System.Net; +using Recordingtest.EgPlugin; +using Xunit; + +namespace Recordingtest.EgPlugin.Tests; + +public class StateRouterTests +{ + private sealed class FixedProvider : IEngineStateProvider + { + public IReadOnlyList GetSelectedIds() => new[] { "x", "y" }; + public CameraSnapshot GetCamera() => new(new double[] { 1, 2, 3 }, new double[] { 0, 0, 0 }, new double[] { 0, 0, 1 }, 45); + public SceneSnapshot GetScene() => new(7, "doc.hmeg"); + public bool GetRenderComplete() => true; + } + + private sealed class FaultyProvider : IEngineStateProvider + { + public IReadOnlyList GetSelectedIds() => throw new InvalidOperationException("boom"); + public CameraSnapshot GetCamera() => throw new InvalidOperationException(); + public SceneSnapshot GetScene() => throw new InvalidOperationException(); + public bool GetRenderComplete() => throw new InvalidOperationException(); + } + + [Fact] + public void StateRouter_SelectionPath_UsesProvider_ReturnsJson() + { + var r = new StateRouter(new FixedProvider(), 38080); + var (status, body) = r.Route("/selection"); + Assert.Equal(HttpStatusCode.OK, status); + Assert.Contains("\"selected_ids\":[\"x\",\"y\"]", body); + } + + [Fact] + public void StateRouter_FaultyProvider_ReturnsErrorPayload() + { + var r = new StateRouter(new FaultyProvider(), 38080); + var (_, body) = r.Route("/selection"); + Assert.Contains("\"error\"", body); + Assert.Contains("boom", body); + } + + [Fact] + public void StateRouter_UnknownPath_Returns404() + { + var r = new StateRouter(new FixedProvider(), 38080); + var (status, _) = r.Route("/nope"); + Assert.Equal(HttpStatusCode.NotFound, status); + } + + [Fact] + public void PortResolver_EnvVarSet_ReturnsEnvPort() + { + var p = PortResolver.Resolve(_ => "45000"); + Assert.Equal(45000, p); + } + + [Fact] + public void PortResolver_EnvVarMissing_ReturnsDefault() + { + var p = PortResolver.Resolve(_ => null); + Assert.Equal(PortResolver.DefaultPort, p); + } +} diff --git a/tests/Recordingtest.EngineBridge.IntegrationTests/FakeBridgeServer.cs b/tests/Recordingtest.EngineBridge.IntegrationTests/FakeBridgeServer.cs new file mode 100644 index 0000000..3abdb74 --- /dev/null +++ b/tests/Recordingtest.EngineBridge.IntegrationTests/FakeBridgeServer.cs @@ -0,0 +1,72 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace Recordingtest.EngineBridge.IntegrationTests; + +public sealed class FakeBridgeServer : IDisposable +{ + public Dictionary Responses { get; } = new(); + public TimeSpan ResponseDelay { get; set; } = TimeSpan.Zero; + + private readonly HttpListener _listener; + private readonly Thread _thread; + private volatile bool _stop; + + public int Port { get; } + public string BaseUrl => $"http://localhost:{Port}"; + + public FakeBridgeServer() + { + Port = FindFreePort(); + _listener = new HttpListener(); + _listener.Prefixes.Add($"http://localhost:{Port}/"); + _listener.Start(); + _thread = new Thread(Loop) { IsBackground = true }; + _thread.Start(); + } + + private static int FindFreePort() + { + var l = new TcpListener(IPAddress.Loopback, 0); + l.Start(); + var p = ((IPEndPoint)l.LocalEndpoint).Port; + l.Stop(); + return p; + } + + private void Loop() + { + while (!_stop && _listener.IsListening) + { + HttpListenerContext ctx; + try { ctx = _listener.GetContext(); } + catch { return; } + try + { + if (ResponseDelay > TimeSpan.Zero) Thread.Sleep(ResponseDelay); + var path = ctx.Request.Url?.AbsolutePath ?? "/"; + if (Responses.TryGetValue(path, out var body)) + { + var bytes = Encoding.UTF8.GetBytes(body); + ctx.Response.StatusCode = 200; + ctx.Response.ContentType = "application/json"; + ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); + } + else + { + ctx.Response.StatusCode = 404; + } + ctx.Response.OutputStream.Close(); + } + catch { try { ctx.Response.Abort(); } catch { } } + } + } + + public void Dispose() + { + _stop = true; + try { _listener.Stop(); } catch { } + try { _listener.Close(); } catch { } + } +} diff --git a/tests/Recordingtest.EngineBridge.IntegrationTests/HmEgHttpSnapshotTests.cs b/tests/Recordingtest.EngineBridge.IntegrationTests/HmEgHttpSnapshotTests.cs new file mode 100644 index 0000000..10fa751 --- /dev/null +++ b/tests/Recordingtest.EngineBridge.IntegrationTests/HmEgHttpSnapshotTests.cs @@ -0,0 +1,66 @@ +using Recordingtest.EngineBridge.Client; +using Xunit; + +namespace Recordingtest.EngineBridge.IntegrationTests; + +public class HmEgHttpSnapshotTests +{ + [Fact] + public void Client_SelectionEndpoint_ReturnsIds() + { + using var srv = new FakeBridgeServer(); + srv.Responses["/selection"] = "{\"selected_ids\":[\"a\",\"b\"]}"; + using var c = new HmEgHttpSnapshot(srv.BaseUrl); + Assert.Equal(new[] { "a", "b" }, c.SelectedObjectIds); + } + + [Fact] + public void Client_CameraEndpoint_ReturnsCameraState() + { + using var srv = new FakeBridgeServer(); + srv.Responses["/camera"] = "{\"eye\":[1,2,3],\"target\":[4,5,6],\"up\":[0,0,1],\"fov\":50}"; + using var c = new HmEgHttpSnapshot(srv.BaseUrl); + var cam = c.Camera; + Assert.Equal(new double[] { 1, 2, 3 }, cam.EyePoint); + Assert.Equal(new double[] { 4, 5, 6 }, cam.Target); + Assert.Equal(50, cam.Fov); + } + + [Fact] + public void Client_SceneEndpoint_ReturnsSceneSummary() + { + using var srv = new FakeBridgeServer(); + srv.Responses["/scene"] = "{\"object_count\":42,\"document_path\":\"C:/m.hmeg\"}"; + using var c = new HmEgHttpSnapshot(srv.BaseUrl); + var s = c.Scene; + Assert.Equal(42, s.ObjectCount); + Assert.Equal("C:/m.hmeg", s.DocumentPath); + } + + [Fact] + public void Client_RenderEndpoint_ReturnsIsComplete() + { + using var srv = new FakeBridgeServer(); + srv.Responses["/render"] = "{\"complete\":true}"; + using var c = new HmEgHttpSnapshot(srv.BaseUrl); + Assert.True(c.IsRenderComplete); + } + + [Fact] + public void Client_HealthEndpoint_ReturnsOk() + { + using var srv = new FakeBridgeServer(); + srv.Responses["/health"] = "{\"status\":\"ok\",\"port\":1}"; + using var c = new HmEgHttpSnapshot(srv.BaseUrl); + Assert.True(c.IsHealthy); + } + + [Fact] + public void Client_Timeout_ThrowsEngineBridgeException() + { + using var srv = new FakeBridgeServer { ResponseDelay = TimeSpan.FromSeconds(5) }; + srv.Responses["/selection"] = "{\"selected_ids\":[]}"; + using var c = new HmEgHttpSnapshot(srv.BaseUrl, timeout: TimeSpan.FromMilliseconds(500)); + Assert.Throws(() => _ = c.SelectedObjectIds); + } +} diff --git a/tests/Recordingtest.EngineBridge.IntegrationTests/Recordingtest.EngineBridge.IntegrationTests.csproj b/tests/Recordingtest.EngineBridge.IntegrationTests/Recordingtest.EngineBridge.IntegrationTests.csproj new file mode 100644 index 0000000..83aaf62 --- /dev/null +++ b/tests/Recordingtest.EngineBridge.IntegrationTests/Recordingtest.EngineBridge.IntegrationTests.csproj @@ -0,0 +1,18 @@ + + + net8.0 + enable + enable + true + false + Recordingtest.EngineBridge.IntegrationTests + + + + + + + + + +