Compare commits
2 Commits
4cee3c2d86
...
a0609f8f0e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0609f8f0e | ||
|
|
b1c2383a54 |
10
PLAN.md
10
PLAN.md
@@ -8,14 +8,10 @@
|
|||||||
1. **훅 동작 검증** — SessionStart/Stop/Guard 3개 shell 스크립트를 실제로 트리거시켜 확인
|
1. **훅 동작 검증** — SessionStart/Stop/Guard 3개 shell 스크립트를 실제로 트리거시켜 확인
|
||||||
- 의존: jq 설치 여부 확인
|
- 의존: jq 설치 여부 확인
|
||||||
|
|
||||||
## P1 — 라이브 검증 & 런타임 엔진 접근
|
## P1 — 라이브 검증 (사용자 환경 필요)
|
||||||
|
|
||||||
4. **라이브 SUT smoke test** — 사용자 환경에서 recorder/player/runner 실제 검증 (E2E)
|
4. **라이브 SUT smoke test 실행** — `docs/guides/smoke-test.md` 따라 수동 수행
|
||||||
- 의존: 없음
|
5. **engine-bridge v3** — ReflectionEngineStateProvider 실매핑 (smoke test 이후)
|
||||||
- 가이드: `docs/guides/smoke-test.md` (작성 필요)
|
|
||||||
5. **engine-bridge v2** — MEF plugin masquerade 구현 (design doc 권고)
|
|
||||||
- 의존: engine-bridge v1 (완료)
|
|
||||||
- 범위: 커스텀 MEF plugin이 HmEG 상태를 로컬 HTTP/named pipe로 노출 → recordingtest가 수집
|
|
||||||
|
|
||||||
## Follow-ups (non-blocking)
|
## Follow-ups (non-blocking)
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,8 @@
|
|||||||
| 2026-04-07 | recorder PoC + Evaluator pass v2 (#6) — drag state machine, focus events, ts/raw_coord | `src/Recordingtest.Recorder/`, `docs/contracts/recorder.evaluation.md` |
|
| 2026-04-07 | recorder PoC + Evaluator pass v2 (#6) — drag state machine, focus events, ts/raw_coord | `src/Recordingtest.Recorder/`, `docs/contracts/recorder.evaluation.md` |
|
||||||
| 2026-04-07 | test-runner PoC + Evaluator pass (#8) — 5-module E2E 파이프라인, 6 tests, DI | `src/Recordingtest.Runner/`, `docs/contracts/test-runner.evaluation.md` |
|
| 2026-04-07 | test-runner PoC + Evaluator pass (#8) — 5-module E2E 파이프라인, 6 tests, DI | `src/Recordingtest.Runner/`, `docs/contracts/test-runner.evaluation.md` |
|
||||||
| 2026-04-07 | engine-bridge PoC v1 + Evaluator pass (#9) — 정적 분석, HmEG 내부 후보 8000+, API 초안 | `src/Recordingtest.EngineBridge*/`, `docs/engine-catalog/`, `docs/engine-bridge-probe-design.md` |
|
| 2026-04-07 | engine-bridge PoC v1 + Evaluator pass (#9) — 정적 분석, HmEG 내부 후보 8000+, API 초안 | `src/Recordingtest.EngineBridge*/`, `docs/engine-catalog/`, `docs/engine-bridge-probe-design.md` |
|
||||||
|
| 2026-04-07 | engine-bridge v2 + Evaluator pass (#10) — MEF plugin masquerade, HttpListener, HmEgHttpSnapshot, 11 tests | `src/Recordingtest.EgPlugin/`, `src/Recordingtest.EngineBridge.Client/`, `docs/guides/engine-bridge-deploy.md` |
|
||||||
|
| 2026-04-07 | 라이브 SUT smoke test 가이드 작성 | `docs/guides/smoke-test.md` |
|
||||||
|
|
||||||
## In progress
|
## In progress
|
||||||
|
|
||||||
|
|||||||
41
docs/contracts/engine-bridge-v2.evaluation.md
Normal file
41
docs/contracts/engine-bridge-v2.evaluation.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# engine-bridge v2 — Evaluation
|
||||||
|
|
||||||
|
**Verdict:** PASS
|
||||||
|
**Generator commit:** b1c2383
|
||||||
|
**Evaluator:** independent eval pass, 2026-04-07
|
||||||
|
**Issue:** #10
|
||||||
|
|
||||||
|
## DoD verdict table
|
||||||
|
|
||||||
|
| # | DoD item | Result | Evidence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `dotnet build recordingtest.sln` green | PASS | 0 warnings, 0 errors, 6.55s |
|
||||||
|
| 2 | Integration tests = 6 pass | PASS | `Recordingtest.EngineBridge.IntegrationTests` 6/6 in 687 ms |
|
||||||
|
| 3 | EgPlugin tests >= 3 pass | PASS | `Recordingtest.EgPlugin.Tests` 5/5 (exceeds claim of 5) |
|
||||||
|
| 4 | csproj: net8.0-windows + HintPath to EG-BIM Modeler/, Private=false, no local copy | PASS | csproj L3, L11-19; no DLL copies under `src/` |
|
||||||
|
| 5 | HmEgBridgePlugin: inherits EditorPlugin, overrides Name/Description/Initialize, ctor boots listener, Dispose closes it | PASS | `HmEgBridgePlugin.cs` L10-47 |
|
||||||
|
| 6 | StateRouter: 5 endpoints, 404 for unknown, try/catch -> `{"error":...}` | PASS | `StateRouter.cs` L28-42 |
|
||||||
|
| 7 | PortResolver: env var `RECORDINGTEST_BRIDGE_PORT`, default 38080, safe parse | PASS | `PortResolver.cs` L5-17 (also bounds-checks 1..65535) |
|
||||||
|
| 8 | HmEgHttpSnapshot: IEngineSnapshot impl, GET per property, 2s default timeout w/ ctor override, throws EngineBridgeException | PASS | `HmEgHttpSnapshot.cs` L13-107 |
|
||||||
|
| 9 | Tests meaningful (FakeBridgeServer + real client; pure-logic plugin tests) | PASS | `FakeBridgeServer.cs`, `HmEgHttpSnapshotTests.cs`, `StateRouterTests.cs` |
|
||||||
|
| 10 | deploy guide: Build/Copy/Env/Launch/Verify/Troubleshoot/Uninstall + SUT-write warning | PASS | `docs/guides/engine-bridge-deploy.md` sec 1-7, warning in sec 2 |
|
||||||
|
| 11 | No writes to `EG-BIM Modeler/` from src | PASS | grep finds only csproj read-only HintPaths |
|
||||||
|
| 12 | No copy of `Editor03.PluginInterface.dll` / `HmEG.dll` in `src/` | PASS | glob returns 0 matches |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `ReflectionEngineStateProvider` is a skeleton returning defaults. Contract
|
||||||
|
explicitly allows this ("진짜 매핑은 smoke test 후 조정"). Graded PASS with
|
||||||
|
note — production mapping deferred to v3 post-smoke.
|
||||||
|
- StateRouter returns HTTP 200 for provider-thrown errors with `{"error":"..."}`
|
||||||
|
payload (per contract spec line 22). Acceptable per DoD wording.
|
||||||
|
- BridgeHttpServer swallows listener errors so SUT stability is preserved
|
||||||
|
(port-conflict path handled).
|
||||||
|
- EgPlugin test count is 5, exceeding the contract minimum of 3.
|
||||||
|
|
||||||
|
## Artifacts
|
||||||
|
|
||||||
|
- src: `src/Recordingtest.EgPlugin/`, `src/Recordingtest.EngineBridge.Client/`
|
||||||
|
- tests: `tests/Recordingtest.EngineBridge.IntegrationTests/`, `tests/Recordingtest.EgPlugin.Tests/`
|
||||||
|
- guide: `docs/guides/engine-bridge-deploy.md`
|
||||||
|
- history: `docs/history/2026-04-07_이슈10-engine-bridge-v2-evaluator.md`
|
||||||
75
docs/contracts/engine-bridge-v2.md
Normal file
75
docs/contracts/engine-bridge-v2.md
Normal file
@@ -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:<port>/{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 철저
|
||||||
97
docs/guides/engine-bridge-deploy.md
Normal file
97
docs/guides/engine-bridge-deploy.md
Normal file
@@ -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
|
||||||
|
`<Private>false</Private>`, 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`.
|
||||||
232
docs/guides/smoke-test.md
Normal file
232
docs/guides/smoke-test.md
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
# 라이브 SUT Smoke Test 가이드
|
||||||
|
|
||||||
|
이 문서는 recordingtest의 5개 PoC 모듈을 **실제 EG-BIM Modeler** 위에서 E2E로 검증하는 수동 절차다. 샌드박스 환경에서는 실행 불가 — 실제 Windows 워크스테이션에서 사용자가 직접 수행한다.
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
다음 PoC의 DoD 중 "라이브 SUT 필수" 항목을 확인한다:
|
||||||
|
- recorder #1: console attach + Win32 hook capture
|
||||||
|
- recorder #7: 60 FPS 성능
|
||||||
|
- player #2: `wait_for` UIA 이벤트 동작
|
||||||
|
- player #7: 동일 시나리오 10회 중 9회 성공
|
||||||
|
- test-runner: 실 시나리오 → normalize → diff 파이프라인
|
||||||
|
- engine-bridge v2: HttpListener 플러그인 로드 + `/health` 응답
|
||||||
|
|
||||||
|
## 사전 준비
|
||||||
|
|
||||||
|
### 1. 환경
|
||||||
|
- Windows 10/11 대화형 세션 (세션 0 불가)
|
||||||
|
- .NET SDK 8 또는 9 (`dotnet --info` 확인)
|
||||||
|
- `EG-BIM Modeler/` 폴더가 repo root 하위에 존재 (git 제외 대상)
|
||||||
|
- 고정 DPI 권장 (100% 또는 150% 일관)
|
||||||
|
- 관리자 권한은 `HttpListener` urlacl 이슈 시에만 필요
|
||||||
|
|
||||||
|
### 2. 빌드
|
||||||
|
```powershell
|
||||||
|
cd d:\MYCLAUDE_PROJECT\recordingtest
|
||||||
|
dotnet build recordingtest.sln
|
||||||
|
dotnet test
|
||||||
|
```
|
||||||
|
**기대**: 전체 green, 42+ 테스트 통과.
|
||||||
|
|
||||||
|
### 3. 시나리오/베이스라인 디렉터리 준비
|
||||||
|
```powershell
|
||||||
|
mkdir scenarios baselines artifacts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1 — recorder 수동 캡처
|
||||||
|
|
||||||
|
### 1a. SUT 실행
|
||||||
|
`EG-BIM Modeler\EG-BIM Modeler.exe` 를 더블클릭하거나:
|
||||||
|
```powershell
|
||||||
|
& ".\EG-BIM Modeler\EG-BIM Modeler.exe"
|
||||||
|
```
|
||||||
|
메인 창이 뜰 때까지 대기.
|
||||||
|
|
||||||
|
### 1b. recorder attach
|
||||||
|
새 터미널에서:
|
||||||
|
```powershell
|
||||||
|
dotnet run --project src\Recordingtest.Recorder -- `
|
||||||
|
--output scenarios\box-create.yaml `
|
||||||
|
--attach "EG-BIM Modeler"
|
||||||
|
```
|
||||||
|
콘솔에 "attached" 메시지 확인.
|
||||||
|
|
||||||
|
### 1c. 수동 테스트 동작
|
||||||
|
메인 창에서 다음 수행:
|
||||||
|
1. `Box` 명령 실행 (리본/툴바/커맨드 라인)
|
||||||
|
2. 첫 번째 코너 클릭
|
||||||
|
3. 두 번째 코너 클릭
|
||||||
|
4. 높이 입력 후 Enter
|
||||||
|
5. 파일 → 저장 → `artifacts\manual-box.hme`
|
||||||
|
|
||||||
|
### 1d. recorder 종료
|
||||||
|
터미널에서 `Ctrl+C`. 요약 출력 확인:
|
||||||
|
- 캡처된 이벤트 수
|
||||||
|
- 미해결 uia_path 수
|
||||||
|
- 소요 시간
|
||||||
|
|
||||||
|
### 1e. 산출물 확인
|
||||||
|
```powershell
|
||||||
|
Get-Content scenarios\box-create.yaml
|
||||||
|
```
|
||||||
|
- 클릭·드래그·키 이벤트가 기록됐는지
|
||||||
|
- `uia_path` 가 유의미한 값인지 (3D 뷰포트 클릭은 호스팅 WPF 컨트롤 경로)
|
||||||
|
- `offset_norm` 이 `[0..1]` 범위
|
||||||
|
- `PasswordBox` 있으면 `<MASKED>` 처리
|
||||||
|
|
||||||
|
**Smoke 체크리스트**:
|
||||||
|
- [ ] recorder가 attach 성공
|
||||||
|
- [ ] Ctrl+C 후 yaml 파일 생성
|
||||||
|
- [ ] 이벤트 수 ≥ 5
|
||||||
|
- [ ] SUT 정상 종료
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2 — player 재생
|
||||||
|
|
||||||
|
### 2a. baseline 저장
|
||||||
|
첫 성공 수행 시 저장된 `manual-box.hme` 를 베이스라인으로 승격:
|
||||||
|
```powershell
|
||||||
|
Copy-Item artifacts\manual-box.hme baselines\box-create.approved.hme
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2b. SUT 재시작 (깨끗한 상태)
|
||||||
|
EG-BIM Modeler 를 완전히 종료 후 다시 실행.
|
||||||
|
|
||||||
|
### 2c. player 실행
|
||||||
|
```powershell
|
||||||
|
dotnet run --project src\Recordingtest.Player -- `
|
||||||
|
--scenario scenarios\box-create.yaml `
|
||||||
|
--output-dir artifacts\replay `
|
||||||
|
--no-launch
|
||||||
|
```
|
||||||
|
|
||||||
|
**Smoke 체크리스트**:
|
||||||
|
- [ ] player가 SUT에 attach
|
||||||
|
- [ ] Box 명령이 실제로 실행되는 것이 화면에 보임
|
||||||
|
- [ ] exit code 0
|
||||||
|
- [ ] `artifacts\replay\` 에 결과 파일 존재
|
||||||
|
- [ ] 고정 sleep 없이 동작 (UIA 대기 기반)
|
||||||
|
|
||||||
|
### 2d. 10회 reliability (player DoD #7)
|
||||||
|
```powershell
|
||||||
|
for ($i=1; $i -le 10; $i++) {
|
||||||
|
dotnet run --project src\Recordingtest.Player -- `
|
||||||
|
--scenario scenarios\box-create.yaml `
|
||||||
|
--output-dir "artifacts\replay-$i" `
|
||||||
|
--no-launch
|
||||||
|
if ($LASTEXITCODE -ne 0) { "Run $i FAILED" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**기대**: 9회 이상 exit 0. 실패 케이스는 `artifacts\replay-N\error.log` 확인.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3 — test-runner E2E 파이프라인
|
||||||
|
|
||||||
|
### 3a. 여러 시나리오 등록
|
||||||
|
Step 1을 반복해 최소 2~3개 yaml 생성 (예: `box-create.yaml`, `circle-draw.yaml`).
|
||||||
|
|
||||||
|
### 3b. 러너 실행
|
||||||
|
```powershell
|
||||||
|
dotnet run --project src\Recordingtest.Runner -- `
|
||||||
|
--scenarios scenarios `
|
||||||
|
--baselines baselines `
|
||||||
|
--out artifacts\regression-run `
|
||||||
|
--profile default
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3c. 리포트 확인
|
||||||
|
```powershell
|
||||||
|
Get-Content artifacts\regression-run\report.md
|
||||||
|
Get-Content artifacts\regression-run\report.json | ConvertFrom-Json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Smoke 체크리스트**:
|
||||||
|
- [ ] 모든 시나리오 `pass`
|
||||||
|
- [ ] `report.json` 스키마 정상 (`runAt`, `total`, `passed`, `failed`, `errored`, `scenarios[]`)
|
||||||
|
- [ ] `report.md` 표 렌더링 정상
|
||||||
|
- [ ] 고의로 baseline 1바이트 바꿔 재실행 → `fail` + hunk 리포트 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4 — engine-bridge v2 플러그인 배포
|
||||||
|
|
||||||
|
### 4a. 플러그인 빌드
|
||||||
|
```powershell
|
||||||
|
dotnet publish src\Recordingtest.EgPlugin -c Release -o publish\EgPlugin
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4b. SUT Plugins 폴더에 복사 (사용자 수동)
|
||||||
|
⚠ `EG-BIM Modeler/` 는 `guard-sut-folder.sh` hook이 Claude의 자동 쓰기를 차단한다. **사용자가 직접** PowerShell로 복사:
|
||||||
|
```powershell
|
||||||
|
$dest = ".\EG-BIM Modeler\Plugins\Recordingtest.EgPlugin"
|
||||||
|
mkdir $dest -Force
|
||||||
|
Copy-Item publish\EgPlugin\Recordingtest.EgPlugin.dll $dest
|
||||||
|
```
|
||||||
|
`Editor03.PluginInterface.dll` 과 `HmEG.dll` 은 복사하지 않는다 (SUT 정본 사용).
|
||||||
|
|
||||||
|
### 4c. 환경변수 (선택)
|
||||||
|
```powershell
|
||||||
|
$env:RECORDINGTEST_BRIDGE_PORT = "38080"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4d. SUT 실행
|
||||||
|
`EG-BIM Modeler.exe` 시작. 플러그인 로더가 `Recordingtest.EgPlugin.dll` 을 자동 발견.
|
||||||
|
|
||||||
|
### 4e. `/health` 확인
|
||||||
|
```powershell
|
||||||
|
curl http://localhost:38080/health
|
||||||
|
```
|
||||||
|
**기대**: `{"status":"ok","port":38080}`
|
||||||
|
|
||||||
|
### 4f. 상태 엔드포인트
|
||||||
|
```powershell
|
||||||
|
curl http://localhost:38080/selection
|
||||||
|
curl http://localhost:38080/camera
|
||||||
|
curl http://localhost:38080/scene
|
||||||
|
curl http://localhost:38080/render
|
||||||
|
```
|
||||||
|
v2는 ReflectionEngineStateProvider가 skeleton이라 기본값 또는 `{"error": ...}` 반환 가능. 이후 **v3에서 실매핑** 예정.
|
||||||
|
|
||||||
|
### 4g. 언인스톨
|
||||||
|
```powershell
|
||||||
|
Remove-Item .\EG-BIM Modeler\Plugins\Recordingtest.EgPlugin -Recurse
|
||||||
|
```
|
||||||
|
|
||||||
|
**Smoke 체크리스트**:
|
||||||
|
- [ ] 플러그인 dll이 SUT에 로드 (로그 확인: `EG-BIM Modeler\hmlogs\` 또는 `Log\`)
|
||||||
|
- [ ] `/health` 200 OK
|
||||||
|
- [ ] 포트 충돌 없음
|
||||||
|
- [ ] SUT 정상 종료 시 listener 해제
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 실패 시 트러블슈팅
|
||||||
|
|
||||||
|
| 증상 | 원인 후보 | 조치 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| recorder attach 실패 | 창 제목 mismatch | `--attach` 값을 정확한 제목 또는 PID로 |
|
||||||
|
| player uia_path 탐색 실패 | 해상도/DPI 변경 | baseline 당시와 동일 DPI 고정 |
|
||||||
|
| 10회 중 실패 다수 | plugin 로드 지연 | scenario 첫 step에 `wait_for` 추가 |
|
||||||
|
| report 바이너리 diff 많음 | normalizer 규칙 미흡 | `docs/contracts/normalizer.md` follow-up 반영 |
|
||||||
|
| 플러그인 미로드 | dll 경로/버전 mismatch | `hmlogs`에서 로더 메시지 확인 |
|
||||||
|
| `/health` 연결 거부 | HttpListener urlacl | 관리자 권한 실행 or `netsh http add urlacl` |
|
||||||
|
|
||||||
|
## 결과 보고
|
||||||
|
|
||||||
|
Smoke test 완료 후:
|
||||||
|
1. 각 체크리스트 결과 수집
|
||||||
|
2. 실패 항목은 `docs/history/YYYY-MM-DD_smoke-test.md` 에 기록 (소요 시간, Context 사용량 포함)
|
||||||
|
3. 10회 재생 reliability 수치 → player DoD #7 update
|
||||||
|
4. 이슈 #2 또는 새 이슈에 리포트 코멘트
|
||||||
|
|
||||||
|
## v3 이후 과제
|
||||||
|
|
||||||
|
- recorder IME 조합 키 처리
|
||||||
|
- player full ancestor chain uia_path resolver
|
||||||
|
- engine-bridge ReflectionEngineStateProvider 실매핑 (HmEG 내부 타입 접근)
|
||||||
|
- normalizer `mask_volatile_settings` JSON-path 스코핑
|
||||||
29
docs/history/2026-04-07_이슈10-engine-bridge-v2-evaluator.md
Normal file
29
docs/history/2026-04-07_이슈10-engine-bridge-v2-evaluator.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# 이슈 #10 — engine-bridge v2 Evaluator
|
||||||
|
|
||||||
|
- 일자: 2026-04-07
|
||||||
|
- 역할: Evaluator (독립 평가)
|
||||||
|
- 대상 commit: b1c2383
|
||||||
|
- 소요 시간: 약 6분 (build 7s + tests 1s + 리뷰)
|
||||||
|
- Context 사용량 힌트: 약 35K 토큰 (대부분 source/test/contract 읽기)
|
||||||
|
- 결과: **PASS**
|
||||||
|
|
||||||
|
## 검증 절차
|
||||||
|
|
||||||
|
1. `dotnet build recordingtest.sln` -> 0 warn / 0 err
|
||||||
|
2. `dotnet test tests/Recordingtest.EngineBridge.IntegrationTests` -> 6/6
|
||||||
|
3. `dotnet test tests/Recordingtest.EgPlugin.Tests` -> 5/5
|
||||||
|
4. csproj 리뷰: net8.0-windows, HintPath `..\..\EG-BIM Modeler\`, `<Private>false</Private>`
|
||||||
|
5. `HmEgBridgePlugin.cs` 리뷰: EditorPlugin 상속, ctor에서 listener boot, Dispose 정리
|
||||||
|
6. `StateRouter.cs` 리뷰: 5 엔드포인트 + 404 + try/catch error payload
|
||||||
|
7. `PortResolver.cs` 리뷰: env `RECORDINGTEST_BRIDGE_PORT`, default 38080, 범위 검증
|
||||||
|
8. `HmEgHttpSnapshot.cs` 리뷰: 2초 timeout, ctor override, EngineBridgeException 변환
|
||||||
|
9. 테스트 진정성: FakeBridgeServer 기반 round-trip + 순수 로직 plugin tests
|
||||||
|
10. deploy 가이드 7개 섹션 + SUT 폴더 쓰기 경고 확인
|
||||||
|
11. grep `EG-BIM Modeler` in src/Recordingtest.EgPlugin -> csproj HintPath만, 쓰기 없음
|
||||||
|
12. `Editor03.PluginInterface.dll` / `HmEG.dll` 가 `src/`에 복사되지 않음 확인
|
||||||
|
|
||||||
|
## 참고
|
||||||
|
|
||||||
|
- ReflectionEngineStateProvider skeleton은 contract에서 명시적으로 허용 → PASS with note
|
||||||
|
- StateRouter가 provider 예외 시 HTTP 200 + `{"error":...}` 페이로드 반환 (contract L22 부합)
|
||||||
|
- 산출물: `docs/contracts/engine-bridge-v2.evaluation.md`
|
||||||
78
docs/history/2026-04-07_이슈10-engine-bridge-v2-generator.md
Normal file
78
docs/history/2026-04-07_이슈10-engine-bridge-v2-generator.md
Normal file
@@ -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`는 `<UseWPF>true</UseWPF>`인
|
||||||
|
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에서 확인 필요.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# 2026-04-07 이슈 #10 — engine-bridge v2 오케스트레이션 + smoke test 가이드
|
||||||
|
|
||||||
|
- **이슈**: #10 (engine-bridge v2)
|
||||||
|
- **소요 시간**: ~15분 (Generator/Evaluator 백그라운드 + 가이드 문서 작성)
|
||||||
|
- **Context 사용량**: ~290k tokens (orchestrator 누적)
|
||||||
|
|
||||||
|
## 사이클
|
||||||
|
|
||||||
|
1. Planner 역할로 `docs/contracts/engine-bridge-v2.md` 작성
|
||||||
|
2. 이슈 #10 생성
|
||||||
|
3. Generator 백그라운드 → commit `b1c2383`
|
||||||
|
4. Evaluator 백그라운드 → **pass** (12/12 DoD)
|
||||||
|
5. `docs/guides/smoke-test.md` 작성 (2단계)
|
||||||
|
6. PROGRESS/PLAN 갱신, 이슈 #10 close
|
||||||
|
|
||||||
|
## 주요 발견 (Editor03 discovery)
|
||||||
|
|
||||||
|
- **SUT가 MEF가 아닌 자체 PluginLoader 사용** — `[Export]` 속성 불필요, 단순 drop-in
|
||||||
|
- `HmEG.IPlugin` 실제 계약, `EditorPlugin` 추상 베이스
|
||||||
|
- `EgBoxPlugin` 샘플로 확인됨 → 배포 난이도 낮음
|
||||||
|
- MetadataLoadContext에 `WindowsDesktop.App.Ref` 팩 필요
|
||||||
|
|
||||||
|
## 산출물
|
||||||
|
|
||||||
|
- `src/Recordingtest.EgPlugin/` — 플러그인 dll (HttpListener + 5 endpoints)
|
||||||
|
- `src/Recordingtest.EngineBridge.Client/` — HmEgHttpSnapshot HTTP 클라이언트
|
||||||
|
- `tests/Recordingtest.EngineBridge.IntegrationTests/` — 6 tests (fake HttpListener)
|
||||||
|
- `tests/Recordingtest.EgPlugin.Tests/` — 5 tests (pure logic)
|
||||||
|
- `docs/guides/engine-bridge-deploy.md` — 플러그인 배포
|
||||||
|
- `docs/guides/smoke-test.md` — 5-모듈 E2E 수동 검증 절차 (Step 1~4)
|
||||||
|
|
||||||
|
## 비용
|
||||||
|
|
||||||
|
Generator ~93k + Evaluator ~38k + Orchestrator ~20k = **~151k**
|
||||||
|
|
||||||
|
## 다음 단계
|
||||||
|
|
||||||
|
3단계(정리)로 진행 — 현재 상태 총괄 리포트.
|
||||||
@@ -33,6 +33,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EngineBridge.
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EngineBridge.Tests", "tests\Recordingtest.EngineBridge.Tests\Recordingtest.EngineBridge.Tests.csproj", "{0811AC32-E2A4-4BFD-A29A-6451F5756F10}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EngineBridge.Tests", "tests\Recordingtest.EngineBridge.Tests\Recordingtest.EngineBridge.Tests.csproj", "{0811AC32-E2A4-4BFD-A29A-6451F5756F10}"
|
||||||
EndProject
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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|x64.Build.0 = Release|Any CPU
|
||||||
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Release|x86.ActiveCfg = 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
|
{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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -242,5 +298,9 @@ Global
|
|||||||
{938D464B-B810-425F-83B6-52877B584DE2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{938D464B-B810-425F-83B6-52877B584DE2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{B1EAD466-9C07-4C07-907C-3D5794F6689D} = {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}
|
{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
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
66
src/Recordingtest.EgPlugin/BridgeHttpServer.cs
Normal file
66
src/Recordingtest.EgPlugin/BridgeHttpServer.cs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Recordingtest.EgPlugin;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hosts an HttpListener that delegates path routing to <see cref="StateRouter"/>.
|
||||||
|
/// Designed to swallow listener errors so it never destabilises the SUT.
|
||||||
|
/// </summary>
|
||||||
|
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 { }
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/Recordingtest.EgPlugin/HmEgBridgePlugin.cs
Normal file
48
src/Recordingtest.EgPlugin/HmEgBridgePlugin.cs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
using Editor.PluginInterface;
|
||||||
|
|
||||||
|
namespace Recordingtest.EgPlugin;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MEF/PluginLoader-discovered plugin. Inherits the SUT's <c>EditorPlugin</c>
|
||||||
|
/// abstract base (which itself implements <c>HmEG.IPlugin</c>), and on construction
|
||||||
|
/// boots a localhost HTTP bridge that exposes HmEG state to recordingtest.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/Recordingtest.EgPlugin/IEngineStateProvider.cs
Normal file
56
src/Recordingtest.EgPlugin/IEngineStateProvider.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
namespace Recordingtest.EgPlugin;
|
||||||
|
|
||||||
|
public interface IEngineStateProvider
|
||||||
|
{
|
||||||
|
IReadOnlyList<string> 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<string> GetSelectedIds() => Array.Empty<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Skeleton reflection-based provider. v2 returns safe defaults; real HmEG mapping happens in v3 once SUT smoke tests confirm field shapes.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReflectionEngineStateProvider : IEngineStateProvider
|
||||||
|
{
|
||||||
|
private readonly object? _appManager;
|
||||||
|
|
||||||
|
public ReflectionEngineStateProvider(object? appManager)
|
||||||
|
{
|
||||||
|
_appManager = appManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<string> GetSelectedIds()
|
||||||
|
{
|
||||||
|
try { _ = _appManager; return Array.Empty<string>(); }
|
||||||
|
catch { return Array.Empty<string>(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/Recordingtest.EgPlugin/PortResolver.cs
Normal file
18
src/Recordingtest.EgPlugin/PortResolver.cs
Normal file
@@ -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<string, string?>? 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/Recordingtest.EgPlugin/Recordingtest.EgPlugin.csproj
Normal file
21
src/Recordingtest.EgPlugin/Recordingtest.EgPlugin.csproj
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
|
<UseWPF>true</UseWPF>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<RootNamespace>Recordingtest.EgPlugin</RootNamespace>
|
||||||
|
<EnableDefaultItems>true</EnableDefaultItems>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="Editor03.PluginInterface">
|
||||||
|
<HintPath>..\..\EG-BIM Modeler\Editor03.PluginInterface.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="HmEG">
|
||||||
|
<HintPath>..\..\EG-BIM Modeler\HmEG.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
116
src/Recordingtest.EgPlugin/StateRouter.cs
Normal file
116
src/Recordingtest.EgPlugin/StateRouter.cs
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Recordingtest.EgPlugin;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure logic router: maps a request path to (status, json body).
|
||||||
|
/// No HttpListener dependency so it can be unit tested cheaply.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/Recordingtest.EngineBridge.Client/HmEgHttpSnapshot.cs
Normal file
121
src/Recordingtest.EngineBridge.Client/HmEgHttpSnapshot.cs
Normal file
@@ -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<string> SelectedObjectIds
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
using var doc = Get("/selection");
|
||||||
|
var arr = doc.RootElement.GetProperty("selected_ids");
|
||||||
|
var list = new List<string>(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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<RootNamespace>Recordingtest.EngineBridge.Client</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Recordingtest.EngineBridge\Recordingtest.EngineBridge.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<RootNamespace>Recordingtest.EgPlugin.Tests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\Recordingtest.EgPlugin\Recordingtest.EgPlugin.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
64
tests/Recordingtest.EgPlugin.Tests/StateRouterTests.cs
Normal file
64
tests/Recordingtest.EgPlugin.Tests/StateRouterTests.cs
Normal file
@@ -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<string> 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<string> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, string> 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 { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<EngineBridgeException>(() => _ = c.SelectedObjectIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<RootNamespace>Recordingtest.EngineBridge.IntegrationTests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\Recordingtest.EngineBridge.Client\Recordingtest.EngineBridge.Client.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
Reference in New Issue
Block a user