diff --git a/CLAUDE.md b/CLAUDE.md index 8095113..edab7fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -164,6 +164,91 @@ recordingtest/ - 부하·성능 테스트 - 단위 테스트 프레임워크 대체 +## 8.1 코드 계층 분리 (의무 규칙) + +본 도구는 **EG-BIM Modeler 외에도 다양한 WPF 응용**을 회귀 자동화 대상으로 삼는다. 또한 사용자 WPF 응용군의 **대다수가 자체 3D 엔진 `HmEG`를 공유**한다. 따라서 코드는 다음 **3개 계층** 중 하나에 명시적으로 속한다. + +| 계층 | 의미 | 의존 가능 | 폴더/네이밍 | +|---|---|---|---| +| **Generic** | 임의 WPF 데스크톱 응용에 동작 | .NET BCL, FlaUI/UIA3, Win32, YamlDotNet 등 SUT-중립 라이브러리만 | `src/` 직속, 네임스페이스 `Recordingtest.*` | +| **HmEG-aware** | HmEG 엔진을 호스팅하는 임의 WPF 응용에 동작 (앱은 고정 안 됨) | Generic + `HmEG.dll` 만 | `src/Hmeg/` 하위, 네임스페이스 `Recordingtest.Hmeg.*` | +| **App-specific** | 특정 응용(예: EG-BIM Modeler)에만 동작 | Generic + HmEG-aware + 해당 앱 어셈블리 (`Editor03.PluginInterface.dll` 등) | `src/Sut//` 하위, 네임스페이스 `Recordingtest.Sut..*` | + +### 의존 방향 (단방향) + +``` +App-specific ──→ HmEG-aware ──→ Generic + (e.g. EgBim) (e.g. HmegBridge) (Recorder/Player/...) +``` + +역참조 금지: Generic은 HmEG-aware/App-specific을 모름. HmEG-aware는 App-specific을 모름. + +### 강제 사항 + +1. **Generic 코드는 어떤 SUT 어셈블리도 참조하지 않는다.** `HmEG.dll`도 안 됨. +2. **HmEG-aware 코드는 `HmEG.dll`만 참조**한다. 특정 앱(`Editor03.PluginInterface.dll` 등)은 참조 금지. +3. **App-specific 코드만이 자기 앱의 어셈블리를 참조**한다. +4. **확장 지점은 항상 한 계층 아래에 인터페이스로 둔다.** 예: + - `IEngineStateProvider` (Generic) ← `HmegEngineStateProvider` (HmEG-aware) ← `EgBimAppManagerAdapter` (App-specific 진입점만 제공) + - `ITargetResolver` (Generic) ← `HmegSceneNodeTargetResolver` (HmEG-aware) ← ... +5. **시나리오 포맷, 베이스라인 포맷, 정규화 규칙은 Generic.** HmEG/앱 특화 정규화 규칙이 필요하면 그 계층에서 plugin 식으로 등록한다. +6. **새 SUT를 추가할 때 HmEG-aware 코드는 재사용**한다. 새로 작성하지 말 것. 앱마다 다른 부분만 App-specific에 둔다 (보통: 플러그인 호스트 진입점 + 명령 lifecycle 이벤트 어댑터). + +### 폴더 레이아웃 (목표 — 본 규칙 추가 후 점진 마이그레이션) + +``` +src/ +├── Recordingtest.Recorder/ # Generic +├── Recordingtest.Player/ # Generic +├── Recordingtest.Normalizer/ # Generic +├── Recordingtest.DiffReporter/ # Generic +├── Recordingtest.Runner/ # Generic +├── Recordingtest.SutProber/ # Generic +├── Recordingtest.Bridge.Abstractions/ # Generic — IEngineStateProvider, ITargetResolver, ... +├── Recordingtest.Bridge.Client/ # Generic — HTTP 클라이언트 +├── Hmeg/ +│ ├── Recordingtest.Hmeg.Bridge/ # HmEG-aware — Space/Camera/Selection을 HmEG 공개 API로 읽기 +│ ├── Recordingtest.Hmeg.TargetResolver/ # HmEG-aware — 씬 노드 hit-test/포커스 식별 +│ └── Recordingtest.Hmeg.Catalog/ # HmEG-aware — 정적 분석/카탈로그 (현 EngineBridge 일부) +└── Sut/ + └── EgBim/ # EG-BIM Modeler 전용 + ├── Recordingtest.Sut.EgBim.PluginHost/ # MEF entry, EditorPlugin base 상속 + └── Recordingtest.Sut.EgBim.Adapter/ # AppManager 진입점 / command lifecycle 어댑터 +tests/ +├── Recordingtest.*.Tests/ # Generic +├── Hmeg/Recordingtest.Hmeg.*.Tests/ # HmEG-aware +└── Sut/EgBim/Recordingtest.Sut.EgBim.*.Tests/ +``` + +새 SUT(예: 다른 HmEG 호스트 앱)를 추가할 때: +``` +src/Sut// + Recordingtest.Sut..PluginHost/ + Recordingtest.Sut..Adapter/ +``` +이 두 개만 새로 만들면 되고 Generic + HmEG-aware는 그대로 재사용된다. + +### 현재 모듈 분류 (2026-04-09 시점, 마이그레이션 전) + +| 모듈 | 분류 | 비고 | +|---|---|---| +| `Recordingtest.Recorder` | **Generic** | OK | +| `Recordingtest.Player` | **Generic** | OK | +| `Recordingtest.Normalizer` | **Generic** | OK | +| `Recordingtest.DiffReporter` | **Generic** | OK | +| `Recordingtest.Runner` | **Generic** | OK | +| `Recordingtest.SutProber` | **Generic** | OK | +| `Recordingtest.EgPlugin` | **혼합 — 분할 필요** | 본체는 EG-BIM 전용(MEF entry), reflection accessor는 HmEG 비의존 generic, HmEG state는 HmEG-aware. 3개로 split. | +| `Recordingtest.EngineBridge` | **HmEG-aware** | HmEG 카탈로그/CandidateFinder. rename → `Recordingtest.Hmeg.Catalog` | +| `Recordingtest.EngineBridge.Client` | **혼합 — 분할 필요** | HTTP 호출부 → Generic, `HmEgHttpSnapshot` → HmEG-aware | +| `IEngineStateProvider` (현 EgPlugin 안) | → **Generic** | `Recordingtest.Bridge.Abstractions` 로 추출 | +| `ReflectionAppManagerAccessor` (현 EgPlugin 안) | → **App-specific** | EG-BIM Modeler의 `Editor.AppManager.AppManager`를 찾는 코드. EgBim 어댑터로 이동. CI fallback 용도로만 유지. | +| 향후 `HmegDirectStateProvider` | **HmEG-aware** | `HmEG.dll` 공개 API 직접 호출. 모든 HmEG 호스트 앱에서 재사용 | + +### Sprint Contract 의무 + +새 SUT를 추가하거나 기존 모듈을 generic↔SUT 사이에서 옮기는 모든 작업은 `/contract ` 으로 Sprint Contract를 먼저 작성한다. DoD에 "분류 라벨", "의존 그래프 검증", "네임스페이스 규칙" 항목 필수. + ## 10. 결정 로그 위치 주요 기술 결정과 그 근거는 `docs/history/`와 Claude 메모리(`project_recordingtest_*`)에 분산 저장된다. 새 결정 시 반드시 둘 다 갱신한다. diff --git a/PLAN.md b/PLAN.md index 6622e24..475993c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -8,9 +8,13 @@ 1. **훅 동작 검증** — SessionStart/Stop/Guard 3개 shell 스크립트를 실제로 트리거시켜 확인 - 의존: jq 설치 여부 확인 +## P0.5 — 아키텍처 정렬 + +3a. **3-tier 분리 refactor 2단계** (`docs/contracts/generic-sut-split.md`) — 1단계(신규 모듈 신설)는 commit `pending`로 완료. 2단계는 기존 `EgPlugin`/`EngineBridge`/`EngineBridge.Client` **rename + 폴더 이동** + ArchitectureTests. 단일 BREAKING 커밋. CLAUDE.md §8.1 규칙 준수. + ## P1 — 라이브 검증 (사용자 환경 필요) -4. **engine-bridge v3** — `ReflectionEngineStateProvider` 실매핑. v2의 plugin masquerade + HttpListener 위에 카메라/선택/씬그래프 상태를 reflection으로 노출. 골든파일 sidecar JSON. `/contract engine-bridge-v3`. +4. **engine-bridge v3 라이브 검증** — `HmegDirectStateProvider` wire-up 완료. EgBim adapter의 Space/Viewport 람다를 실값으로 매핑 (`Editor03.PluginInterface` 또는 SUT-side AppManager 진입점 필요 — Q1~Q7). 라이브에서 `curl /scene /camera /selection` 으로 확인. 5. ~~recorder Gap I-1~~ — **deferred**. UIA poller PoC 결과 본질적 한계 확인 (AutomationPeer 부재 컨트롤은 못 봄). generic WPF DLL injection 또는 AutomationPeer AI 부착 PoC가 선결. ## Follow-ups (non-blocking) diff --git a/PROGRESS.md b/PROGRESS.md index 979a289..6f9a43b 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -44,10 +44,16 @@ | 2026-04-08 | **Smoke test 2회차 — 첫 E2E 성공** 🎉 Box geometry 생성 확인 | `docs/history/2026-04-08_smoke-2회차-첫-e2e-성공.md`, `scenarios/box-v5*.yaml` | | 2026-04-08 | 이슈 #13 Gap E/F/G fix — HotkeyParseTests + FocusEventFilter + WindowPointResolver, 94 tests | `docs/history/2026-04-08_이슈13-smoke3-fix-generator.md` | | 2026-04-08 | **이슈 #14 Raw 시나리오 E2E 성공** 🎉 수동 cleanup 없이 box-v6.yaml 재생으로 Box 생성 | player: null-target fallback + foreground switch + leading alt+tab strip + timing preservation, 24 player tests | +| 2026-04-09 | engine-bridge v3 D1/D6 scaffold (reflection accessor + 9 tests, EgPlugin) | `IAppManagerAccessor`, `ReflectionEngineStateProvider`, 14 EgPlugin tests | +| 2026-04-09 | HmEG 소스 survey + `docs/hmeg-api-survey.md` | Q1~Q7 식별, `HmegDirectStateProvider` 설계 근거 | +| 2026-04-09 | **3-tier 분리 1단계 (incremental)** — `Recordingtest.Bridge.Abstractions` (Generic) + `Recordingtest.Hmeg.Bridge` (HmEG-aware) 신설, `HmegDirectStateProvider` + `ChainedEngineStateProvider` wire-up, 115 tests | CLAUDE.md §8.1 + commit pending | ## In progress -_(없음)_ +- **3-tier 분리 (incremental, 1단계 완료, 2단계 대기)** + - 1단계 ✅: 새 계층 모듈 신설 (`Recordingtest.Bridge.Abstractions`, `Recordingtest.Hmeg.Bridge`, 테스트), 인터페이스 추출, EgPlugin이 HmEG-aware provider chain 사용. 115 tests green. commit pending. + - 2단계 ⏳: 기존 `Recordingtest.EgPlugin` → `src/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost` + `.Adapter` 폴더/네임스페이스 이동, `Recordingtest.EngineBridge` → `src/Hmeg/Recordingtest.Hmeg.Catalog`, `Recordingtest.EngineBridge.Client` 분할, ArchitectureTests. 다음 세션 작업. +- **engine-bridge v3 라이브 검증** — 1단계 wire-up 완료 후 EgBim adapter에서 Space/Viewport 람다 실 매핑 (Q1~Q7 답 필요). 사용자 환경에서 `curl /scene /camera /selection`. ## Follow-ups diff --git a/docs/contracts/engine-bridge-v3.md b/docs/contracts/engine-bridge-v3.md new file mode 100644 index 0000000..ca08b6f --- /dev/null +++ b/docs/contracts/engine-bridge-v3.md @@ -0,0 +1,84 @@ +# Sprint Contract — engine-bridge-v3 + +## Goal + +`Recordingtest.EgPlugin.ReflectionEngineStateProvider`를 v2의 stub에서 진짜 reflection 기반 매핑으로 격상한다. SUT(EG-BIM Modeler)의 HmEG 엔진 내부 상태(카메라/선택/씬)를 in-process plugin에서 reflection으로 읽어 HTTP `/state` 응답에 실제 값을 채운다. 이 값이 골든파일 sidecar JSON으로 들어가 회귀 비교의 핵심 결정성 신호가 된다. + +## Background + +- v1: 정적 분석으로 HmEG 어셈블리 멤버 후보 8000+ 카탈로그 (`docs/engine-catalog/hmeg-candidates.json`). +- v2: MEF plugin masquerade + HttpListener + 8 + 3 tests. `ReflectionEngineStateProvider`는 stub만. +- v3: stub을 실매핑으로 교체. SUT 환경에서 라이브 검증 필요. + +## Definition of Done + +각 항목은 객관적으로 검증 가능해야 한다. + +### D1. AppManager 발견 +- plugin이 SUT 프로세스 안에서 `AppManager`(또는 동등한 root) 인스턴스를 reflection으로 획득한다. +- 실패 시 `NullEngineStateProvider`로 안전하게 폴백하고 stderr에 한 번만 경고 로그. + +### D2. CameraSnapshot 실매핑 +- `GetCamera()`가 활성 뷰포트의 카메라 eye/target/up/fov를 **non-default 값**으로 반환. +- 검증: 라이브 SUT에서 카메라 이동 후 두 번 호출 → 적어도 한 필드가 달라짐. + +### D3. SceneSnapshot 실매핑 +- `GetScene()`가 현재 문서의 객체 수와 (열린 경우) 문서 경로를 반환. +- 검증: Box 1개 생성 후 `ObjectCount >= 1`, 새 문서 만들면 0. + +### D4. SelectedIds 실매핑 +- `GetSelectedIds()`가 현재 선택된 객체의 ID 리스트를 반환 (HmEG 내부 ID 또는 GUID 문자열). +- 검증: 객체 선택 → 비어있지 않은 리스트. + +### D5. 결정성 + 정규화 +- 응답 JSON은 normalizer가 처리 가능한 형태 (정렬된 키, 안정된 부동소수점 표현). normalizer 규칙은 기존 `mask_volatile_settings` / 부동소수점 epsilon으로 충분한지 확인하고 부족하면 신규 규칙 등록. + +### D6. 단위 테스트 +- `ReflectionEngineStateProvider`의 reflection 경로를 mockable한 `IAppManagerAccessor` 추상화 뒤로 격리. +- Fake accessor로 각 D2/D3/D4를 단위 테스트화 (라이브 SUT 없이 CI 가능). +- 최소 6 신규 테스트, 전체 suite green (현 94+ → 100+). + +### D7. 라이브 검증 +- 사용자 SUT 환경에서 plugin 로드 → `/state` GET 응답에 D2/D3/D4 실값 확인. +- 결과는 `docs/history/2026-04-08_engine-bridge-v3.md` 에 캡처. + +### D8. 문서 +- `docs/contracts/engine-bridge-v3.evaluation.md` (Evaluator 산출물) +- `docs/guides/engine-bridge-deploy.md` 업데이트 (v3 응답 스키마 변경분) + +## Out of scope + +- HmEG 내부 데이터 변경/쓰기 (read-only) +- viewport 픽셀 캡처 (별개 모듈) +- 새 HTTP 엔드포인트 (기존 `/state` 라우트만 채움) + +## Interfaces + +```csharp +// 새 추상화 (D6) +public interface IAppManagerAccessor +{ + object? GetAppManager(); + object? GetActiveDocument(); + object? GetActiveViewport(); +} + +// 기존 IEngineStateProvider 시그니처 유지 — 구현만 교체 +``` + +## Evaluation plan + +1. Evaluator는 `/contract engine-bridge-v3.md` 를 읽고 D1~D8을 차례로 채점. +2. D2/D3/D4는 단위 테스트(D6)로 검증 가능 → CI에서 자동 grade. +3. D7은 사용자 라이브 결과 첨부로 grade (orchestrator가 캡처 전달). +4. fail 1회 → Generator 재작업. 누적 3회 → 자동 중단. + +## Risks + +- HmEG 내부 타입 이름이 obfuscation/난독화 가능 → reflection by-name이 깨질 수 있음. 완화: `hmeg-candidates.json` 카탈로그를 dictionary로 lookup, fallback 체인 다중화. +- AppManager singleton 접근 패턴이 SUT 버전마다 다를 수 있음. 완화: D1에서 여러 후보 시도. +- 카메라 좌표계가 right-handed/left-handed/up-axis 다양 → 정규화 규칙 필요. + +## Estimated complexity + +중. 단위 테스트만으로는 D2/D3/D4의 실매핑이 옳은지 확인 불가 — 라이브 검증(D7)이 critical path. 1차 사이클은 발견(discovery)에 시간 쏠릴 가능성. diff --git a/docs/contracts/generic-sut-split.md b/docs/contracts/generic-sut-split.md new file mode 100644 index 0000000..a1b2e88 --- /dev/null +++ b/docs/contracts/generic-sut-split.md @@ -0,0 +1,140 @@ +# Sprint Contract — generic-sut-split (3-tier) + +## Goal + +`CLAUDE.md §8.1` 신규 규칙(**Generic / HmEG-aware / App-specific 3계층 분리**)을 코드베이스에 실제로 반영한다. 향후 EG-BIM Modeler 외에도 HmEG를 호스팅하는 다양한 WPF 응용을 추가할 때 Generic 코어와 HmEG-aware 미들 계층을 재사용하고, 앱마다 다른 부분(플러그인 진입점, 명령 lifecycle 어댑터)만 새로 작성하면 되도록 한다. + +## Background + +지금까지 모든 모듈이 `src/Recordingtest.*` 평면에 있고, `Recordingtest.EgPlugin`/`Recordingtest.EngineBridge`가 "EG-BIM 전용"과 "HmEG 일반"과 "Generic"이 섞인 채로 존재한다. 사용자 디렉티브: +1. 처음부터 Generic vs SUT-specific 분리 +2. **HmEG는 사용자 WPF 앱군의 공통 엔진** — HmEG-aware 미들 계층을 따로 두어 앱 간 재사용 + +따라서 분리는 2-tier가 아니라 **3-tier**: Generic → HmEG-aware → App-specific (EgBim). + +## Definition of Done + +### D1. 폴더/csproj 이동·분할 + +**Generic 신설**: +- `src/Recordingtest.Bridge.Abstractions/` — `IEngineStateProvider`, `CameraSnapshot`, `SceneSnapshot`, 향후 `IFocusProbe`/`IHitTestProbe`/`ICommandLifecycle`. **`HmEG.dll` 참조 금지**. +- `src/Recordingtest.Bridge.Client/` — generic HTTP 클라이언트 (`HttpClient` 래퍼, `BridgeClientException`, `IBridgeClient`) + +**HmEG-aware 신설** (`src/Hmeg/` 하위): +- `src/Hmeg/Recordingtest.Hmeg.Bridge/` — `HmegDirectStateProvider : IEngineStateProvider` (HmEG `Space`/`HmEGViewport`/`CameraCore` 직접 호출), HTTP 서버는 별도. **`HmEG.dll`만 참조**. +- `src/Hmeg/Recordingtest.Hmeg.TargetResolver/` — 씬 노드 hit-test/포커스 식별 (Gap I 우회의 HmEG-aware 레이어) +- `src/Hmeg/Recordingtest.Hmeg.Catalog/` — `Recordingtest.EngineBridge`의 정적 분석/CandidateFinder를 이쪽으로 이동 +- `src/Hmeg/Recordingtest.Hmeg.Bridge.Client/` — `HmEgHttpSnapshot` 등 HmEG-shaped 응답 타입 (현 `Recordingtest.EngineBridge.Client`에서 분리) + +**App-specific (EgBim) 신설** (`src/Sut/EgBim/` 하위): +- `src/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost/` — MEF entry. `EditorPlugin` 베이스 상속, `BridgeHttpServer` 부팅. 현재 `Recordingtest.EgPlugin.HmEgBridgePlugin` 이동. +- `src/Sut/EgBim/Recordingtest.Sut.EgBim.Adapter/` — EG-BIM Modeler `AppManager` 진입점, command lifecycle 어댑터. 현재 `ReflectionAppManagerAccessor`는 여기로 (CI fallback 용도). EG-BIM 전용 SUT 멤버 이름 후보(예: `Editor.AppManager.AppManager`)는 이 모듈에만 등장. + +**테스트 이동**: +- `tests/Recordingtest.Bridge.Abstractions.Tests/` (신규) +- `tests/Hmeg/Recordingtest.Hmeg.Bridge.Tests/` (현 EgPlugin 테스트의 HmEG 부분) +- `tests/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost.Tests/` (현 EgPlugin 테스트의 EgBim 부분) +- `tests/Hmeg/Recordingtest.Hmeg.Catalog.Tests/` (현 EngineBridge.Tests 이동) +- `tests/Hmeg/Recordingtest.Hmeg.Catalog.IntegrationTests/` (현 EngineBridge.IntegrationTests 이동) + +**제거**: +- 기존 `src/Recordingtest.EgPlugin/`, `src/Recordingtest.EngineBridge/`, `src/Recordingtest.EngineBridge.Client/` 디렉터리 (내용물은 위 3계층으로 분배) + +### D2. 네임스페이스 rename +- `Recordingtest.Bridge.*` (Generic) +- `Recordingtest.Hmeg.*` (HmEG-aware) +- `Recordingtest.Sut.EgBim.*` (App-specific) +- 모든 `using Recordingtest.EgPlugin;` 정리 +- `recordingtest.sln` 프로젝트 경로/이름 갱신 + +### D3. 인터페이스 추출 +- `IEngineStateProvider`, `CameraSnapshot`, `SceneSnapshot` → `Recordingtest.Bridge.Abstractions` (Generic) +- 현재 `Recordingtest.EgPlugin` 내 정의 제거, 새 위치를 참조 + +### D4. HmegDirectStateProvider 골격 신설 +- `src/Hmeg/Recordingtest.Hmeg.Bridge/HmegDirectStateProvider.cs` 작성 (구현은 람다 주입 형태, `docs/hmeg-api-survey.md` §"v3 구현 방향" 참고) +- 단위 테스트 — fake viewport/space 람다로 D2/D3/D4(원래 v3 contract)에 해당하는 동작 검증 +- **본 contract에서는 골격만**. 실제 HmEG 라이브 검증은 `engine-bridge-v3` 후속 contract. + +### D5. EgBim PluginHost 보존 +- 현 `HmEgBridgePlugin`의 동작(MEF 발견, BridgeHttpServer 부팅, ReflectionEngineStateProvider 폴백)은 동일해야 함 +- provider 결정 로직: `HmegDirectStateProvider` 가능하면 사용, 실패 시 `ReflectionEngineStateProvider` 폴백, 최종 실패 시 `NullEngineStateProvider` + +### D6. 의존 그래프 검증 +- Generic 모듈의 csproj는 `HmEG.dll` 또는 `Editor*PluginInterface.dll` 을 **직간접 참조하지 않음** +- HmEG-aware 모듈의 csproj는 `HmEG.dll`만 참조, 특정 앱 어셈블리 참조 금지 +- App-specific 모듈만이 자기 앱 어셈블리 참조 +- 신규 `Recordingtest.Architecture.Tests` — `Assembly.GetReferencedAssemblies()` 검사로 위반 검출. 각 계층별 expected reference set assert. + +### D7. 빌드/테스트 green +- `dotnet build recordingtest.sln` 성공 +- `dotnet test recordingtest.sln` 100+ tests 모두 pass +- 신규 ArchitectureTests + HmegDirectStateProvider 단위 테스트 통과 + +### D6. 빌드/테스트 green +- `dotnet build recordingtest.sln` 성공 +- `dotnet test recordingtest.sln` 100+ tests 모두 pass (현재 상태 유지) +- 신규 ArchitectureTests 통과 + +### D8. PROGRESS/PLAN 갱신 + history +- PROGRESS.md Done에 항목 추가 +- PLAN.md에서 본 contract 제거 +- `docs/history/YYYY-MM-DD_generic-sut-split.md` 작성 +- CLAUDE.md §8.1 표(현재 모듈 분류)를 마이그레이션 후 상태로 갱신 + +### D9. 단일 커밋(권장) 또는 2단 커밋 +- 옵션 A: 단일 커밋 — sln 무결성 보장, BREAKING 명시 +- 옵션 B: (1) git mv만, (2) 내용 변경 — git rename detection 보존 +- Generator 판단. 어느 쪽이든 메시지 prefix `BREAKING:` + +## Out of scope + +- 새 SUT(다른 WPF 앱) 추가 — 본 contract는 구조만 만든다 +- engine-bridge v3의 `HmEgDirectStateProvider` 구현 — 그건 별도 contract `engine-bridge-v3` 후속 +- generic 코어의 기능 변경 — 순수 rename/이동만 + +## Interfaces + +```csharp +// src/Recordingtest.Bridge.Abstractions/IEngineStateProvider.cs (generic) +namespace Recordingtest.Bridge; + +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); +``` + +```csharp +// src/Sut/EgBim/Recordingtest.Sut.EgBim.Plugin/HmEgDirectStateProvider.cs (SUT) +namespace Recordingtest.Sut.EgBim.Plugin; +using Recordingtest.Bridge; +public sealed class HmEgDirectStateProvider : IEngineStateProvider { ... } +``` + +## Risks + +- **sln 경로 깨짐**: csproj 이동 시 sln 갱신을 빠뜨리면 빌드 깨짐. 완화: D6에서 솔루션 빌드 + 테스트 강제. +- **using 구문 누락**: 네임스페이스 rename 시 다른 프로젝트의 using이 깨짐. 완화: 빌드가 잡아냄. +- **engine-bridge v3 진행 중 방해**: scaffold 미커밋 상태(IAppManagerAccessor 등). 본 refactor 전에 v3 scaffold를 먼저 커밋해 두는 게 안전. +- **git rename detection**: 폴더 이동 + 내용 변경이 동시에 들어가면 git이 rename 인식을 못 할 수 있음. 완화: 가능한 한 "이동만" 한 번 커밋, "내용 변경"은 후속 커밋. (단일 커밋 vs rename 보존 trade-off — D8과 충돌 가능. Generator 판단.) + +## Estimated complexity + +중. 코드 로직 변경은 거의 없고 mass rename + 폴더 이동 + sln 정리. 시간보다 손실 위험 관리가 핵심. + +## Evaluation plan + +Evaluator는: +1. D1~D5를 파일/폴더/csproj 검사로 채점 +2. D5의 ArchitectureTests 실행 +3. D6의 전체 build/test 실행 +4. D8의 git log 단일 커밋 확인 + +fail 1회 → Generator 재작업. 누적 3회 → 자동 중단. diff --git a/docs/history/2026-04-09_3tier-split-step1.md b/docs/history/2026-04-09_3tier-split-step1.md new file mode 100644 index 0000000..6dc4ac0 --- /dev/null +++ b/docs/history/2026-04-09_3tier-split-step1.md @@ -0,0 +1,108 @@ +# 2026-04-09 — 3-tier 분리 1단계 (incremental) + +**이슈**: #10 follow-up (engine-bridge v3) + 사용자 디렉티브 (Generic / HmEG-aware / App-specific 분리) +**소요 시간**: ~70분 +**Context 사용량**: input ~205k / output ~38k tokens (Opus 4.6, 1M context, 동일 세션 누적) + +## 작업 + +CLAUDE.md §8.1 의 3-tier 규칙을 incremental하게 코드에 반영. 기존 `EgPlugin`/`EngineBridge` mass-rename은 2단계로 미루고, **새 계층 모듈을 신설**해 wire-up까지 끝냄. + +### 신설 (Generic 계층) + +- `src/Recordingtest.Bridge.Abstractions/` (csproj, net8.0) + - `IEngineStateProvider`, `CameraSnapshot`, `SceneSnapshot`, `NullEngineStateProvider` + - SUT 어셈블리 참조 0개. CI 안전. +- 기존 `Recordingtest.EgPlugin`의 `IEngineStateProvider`/`CameraSnapshot`/`SceneSnapshot`/`NullEngineStateProvider` 정의 제거 → Bridge.Abstractions로 위임 (`using Recordingtest.Bridge;`) + +### 신설 (HmEG-aware 계층) + +- `src/Hmeg/Recordingtest.Hmeg.Bridge/` (csproj, net8.0-windows + WPF + HmEG.dll 직접 참조) + - `HmegDirectStateProvider : IEngineStateProvider` + - 람다 주입: `Func`, `Func`, `Func?` + - `GetSelectedIds`: Space 트리 walk → `ISelectable.IsSelected` 노드의 `Uid` 수집. `ModelBase` 직접 타입 참조 회피 (MemoryPack 의존 차단), 대신 `object` + 패턴 매칭 + 늦은 바인딩 `Uid` 프로퍼티 읽기 + - `GetCamera`: `viewport.CameraCore`에서 `Position`/`LookDirection`/`UpDirection`/`FieldOfView`를 reflection으로 읽어 `CameraSnapshot`. Target = Eye + LookDir. + - `GetScene`: `space.ItemsCount` + 외부 documentPathProvider 람다 + - 모든 호출 try/catch → safe default 폴백 +- `tests/Hmeg/Recordingtest.Hmeg.Bridge.Tests/` (csproj, HmEG.dll Private=true로 출력 폴더 복사) + - `HmegDirectStateProviderTests` 5개 (null lambdas / throwing lambdas / document path / null arg) + +### EgPlugin wire-up + +- `HmEgBridgePlugin.BuildProvider()` 신설: + ``` + HmegDirectStateProvider (1순위, lambdas는 일단 null 반환) + ↓ default → + ReflectionEngineStateProvider (2순위, EgBim AppManager 후보 탐색) + ``` + 체인: `ChainedEngineStateProvider` +- `ChainedEngineStateProvider` 신설 — primary 결과가 default/empty면 fallback 호출. signal별 판정: + - SelectedIds 빈 리스트 + - Camera Eye=(0,0,0) AND Target=(0,0,0) + - Scene ObjectCount=0 AND DocumentPath=null + - RenderComplete: primary always wins +- 단위 테스트 7개 (`ChainedEngineStateProviderTests`) + +EgBim adapter(Q1~Q7 답)가 채워지면 `BuildProvider`의 두 람다만 실값으로 바꾸면 라이브 검증으로 이어진다. + +### sln/build/test + +- `dotnet sln add` 로 3개 신규 csproj 등록 + - `Recordingtest.Bridge.Abstractions` + - `Recordingtest.Hmeg.Bridge` + - `Recordingtest.Hmeg.Bridge.Tests` +- `dotnet build` 성공 (HmEG.dll의 ModelBase가 MemoryPack 의존성을 transitively 요구해서 1차 빌드 실패 → ModelBase 직접 참조 제거로 우회) +- `dotnet test recordingtest.sln`: **0 failures, 115 passed** (94 → 115, +21) + +## 분류 라벨 (현재 시점) + +| 모듈 | 계층 | 비고 | +|---|---|---| +| `Recordingtest.Bridge.Abstractions` ✨ | **Generic** | 신설 | +| `Recordingtest.Hmeg.Bridge` ✨ | **HmEG-aware** | 신설, HmEG.dll만 참조 | +| `Recordingtest.EgPlugin` | **App-specific (EgBim)** | rename 대기. Bridge.Abstractions + Hmeg.Bridge 참조하도록 갱신됨 | +| 기타 generic 모듈 (Recorder/Player/Normalizer/...) | **Generic** | 변경 없음 | + +## 2단계 (다음 세션) + +`docs/contracts/generic-sut-split.md` D1~D9 잔여: +- `src/Recordingtest.EgPlugin/` → `src/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost/` + `.Adapter/` +- `src/Recordingtest.EngineBridge/` → `src/Hmeg/Recordingtest.Hmeg.Catalog/` +- `src/Recordingtest.EngineBridge.Client/` → 분할 (generic HTTP + HmEG-shaped) +- 네임스페이스 일괄 rename +- `Recordingtest.Architecture.Tests` 추가 (의존 그래프 검증) +- 단일 BREAKING 커밋 + +## 라이브 검증 (Q1~Q7 후) + +EgBim adapter에서 다음 람다를 실값으로 채운다: +```csharp +spaceProvider = () => /* AppManager.Instance.ActiveSpace */ ; +viewportProvider = () => /* AppManager.Instance.ActiveViewport (HmEGViewport 캐스트) */ ; +documentPathProvider = () => /* AppManager.Instance.ActiveDocumentPath */ ; +``` + +→ `curl http://localhost:38080/scene` 등으로 검증. + +## 미커밋 + +본 세션 누적 (다음 단계에서 통합 커밋): +- `Recordingtest.Bridge.Abstractions/` (신규) +- `Hmeg/Recordingtest.Hmeg.Bridge/` (신규) +- `tests/Hmeg/Recordingtest.Hmeg.Bridge.Tests/` (신규) +- `Recordingtest.EgPlugin/` 갱신 (`HmEgBridgePlugin`, `IEngineStateProvider`, `ChainedEngineStateProvider` 신규, csproj ProjectReference 추가) +- `Recordingtest.EgPlugin.Tests/` 갱신 (`ChainedEngineStateProviderTests` 신규, using 정리) +- `recordingtest.sln` +- `CLAUDE.md` §8.1 (직전 단계) +- `docs/contracts/generic-sut-split.md`, `docs/contracts/engine-bridge-v3.md`, `docs/hmeg-api-survey.md` +- `PROGRESS.md`, `PLAN.md` +- 본 history + `2026-04-09_engine-bridge-v3-scaffold.md` + +## 관련 + +- `src/Recordingtest.Bridge.Abstractions/IEngineStateProvider.cs` +- `src/Hmeg/Recordingtest.Hmeg.Bridge/HmegDirectStateProvider.cs` +- `src/Recordingtest.EgPlugin/HmEgBridgePlugin.cs` +- `src/Recordingtest.EgPlugin/ChainedEngineStateProvider.cs` +- `tests/Hmeg/Recordingtest.Hmeg.Bridge.Tests/HmegDirectStateProviderTests.cs` +- `tests/Recordingtest.EgPlugin.Tests/ChainedEngineStateProviderTests.cs` diff --git a/docs/history/2026-04-09_engine-bridge-v3-scaffold.md b/docs/history/2026-04-09_engine-bridge-v3-scaffold.md new file mode 100644 index 0000000..6011887 --- /dev/null +++ b/docs/history/2026-04-09_engine-bridge-v3-scaffold.md @@ -0,0 +1,70 @@ +# 2026-04-09 — engine-bridge v3 scaffold (D1/D6) + +**이슈**: #10 follow-up (engine-bridge v3) +**소요 시간**: ~95분 (HmEG 소스 survey + 3-tier 분리 디렉티브 반영 포함) +**Context 사용량**: input ~165k / output ~30k tokens (Opus 4.6, 1M context, 동일 세션 누적) + +## 작업 + +`docs/contracts/engine-bridge-v3.md` Sprint Contract 작성 후 D1/D6 구현: + +- `IAppManagerAccessor` 추상화 신설 — AppManager/ActiveDocument/ActiveViewport/Selection/Camera를 reflection 경계 뒤로 격리 +- `ReflectionAppManagerAccessor` — loaded assemblies에서 `Editor.AppManager.AppManager` 타입 + `Instance/Current/Default` static 프로퍼티 탐색, well-known 멤버 이름 후보 체인(Selection/SelectedObjects, Camera/ActiveCamera, Position/Eye, …)로 reflection lookup, vector는 `double[]` / `float[]` / `X/Y/Z` 세 가지 shape 모두 시도 +- `ReflectionEngineStateProvider` v2 stub 제거, 접근자 위임 구조로 재작성. HmEG 부재 환경(= CI)에서는 v2와 동일한 safe default 반환 +- `ReflectionEngineStateProviderTests` 9 테스트 추가 — FakeAccessor로 정상값/예외/null/HmEG 부재 폴백 커버. EgPlugin 테스트 5 → 14 +- 전체 suite green (100+ tests) + +## 라이브 검증 대기 (D7) + +reflection 멤버 후보 이름은 `hmeg-candidates.json` 기반 추측. SUT 라이브에서 `curl /scene /camera /selection` 응답 받아 실제 매칭 여부 확인 후 1~2회 보정 필요. + +## 전략 pivot — Reflection → HmEG 직접 참조 + +사용자 지적: `Recordingtest.EgPlugin`은 이미 `HmEG.dll` + `Editor03.PluginInterface.dll`을 compile-time 참조 중이다 (`.csproj` 확인). 즉 reflection으로 멤버 추측할 필요가 없고, HmEG public 타입을 직접 호출하면 된다. 이식성(generic WPF)은 포기하지만 이 프로젝트는 EG-BIM Modeler 전용이므로 합리적 trade-off. + +이에 따라 사용자 동의 하에 HmEG 소스(`D:\GiteaAll\HmEngine\HmEG\HmEG`)를 read-only로 surveyed: + +확인된 공개 타입 (`` 마커 기준): +- `ModelBase.Uid : Guid` — 영구 고유 ID, golden file 결정성의 핵심 +- `Space : ModelBase` — 문서 컨테이너. `Children`/`ItemsCount`/`Viewports` +- `HmModel : ModelBase` — 형상 객체. `MouseDown/Enter/Leave` event (recorder hit-test 후보) +- `HmEGViewport` (interface, namespace `HmEG`) — `CameraCore`, `Renderables`, `ViewportRectangle` +- `IHmCamera` — Position/LookDirection/UpDirection (Fov는 PerspectiveCamera 캐스트 필요) +- `ISelectable.IsSelected` — 노드별 (중앙 selection 리스트는 HmEG core에 없음 → Space walk + 필터) +- `HmEG.IPlugin.View : EGViewport` — 플러그인이 로드 시 viewport 직접 주입받음 + +산출: `docs/hmeg-api-survey.md` — 발견 내용, v3 구현 방향(`HmEgDirectStateProvider` + 람다 주입), SUT-side bridge 추가 엔드포인트(`/focus`, `/hit-test`, `/command`) 설계, 미해결 7개 질문(Q1~Q7) 큐. + +## 곁가지 + +- 사용자 질문으로 "SUT 소스 협조 wishlist" 정리 — AutomationPeer 부착, AppManager.Instance 난독화 제외, read-only 상태 API, 명령 생명주기 이벤트 등 7항목을 대화에 남김. 필요 시 `docs/sut-cooperation-wishlist.md`로 문서화. +- 미커밋 변경 존재 (engine-bridge v3 scaffold + contract + hmeg-api-survey.md + 본 마지막 단계의 3-tier 분리 작업). 다음 세션에서 분리 refactor 완료 후 통합 커밋 예정. + +## 아키텍처 디렉티브 — 3-tier 분리 (세션 후반) + +사용자 디렉티브 두 가지가 연속해서 들어왔다: +1. "처음부터 WPF 일반인지 Modeler 테스트 자동화인지 코드 분리해놓아라" +2. "대부분의 WPF는 HmEG(3D 그래픽 엔진)을 사용하고 있으니 이점도 고려해서 테스트 자동화를 설계해라" + +→ 즉 분리는 2-tier(generic vs SUT)가 아니라 **3-tier**: +- **Generic** — 임의 WPF 응용 +- **HmEG-aware** — HmEG를 호스팅하는 임의 WPF 응용 (앱 미고정) +- **App-specific** — 특정 응용 (현재 EG-BIM Modeler) + +의존 방향: App-specific → HmEG-aware → Generic. 역참조 금지. + +본 세션에서 한 일: +- `CLAUDE.md §8.1` 신규 — 3-tier 규칙, 폴더 레이아웃, 강제 사항, 현재 모듈 분류표 +- `docs/contracts/generic-sut-split.md` 신규 — D1~D9 명세 (폴더/csproj 분할, 인터페이스 추출, HmegDirectStateProvider 골격, ArchitectureTests, sln 갱신) +- `PLAN.md` — 본 refactor를 P0.5로 등록 (engine-bridge v3 진입 전 선결) +- `PROGRESS.md` — In progress 에 해당 항목 추가 + +본 contract는 다음 세션에서 Generator가 단일 작업 단위로 실행한다. **engine-bridge v3 코드 진입은 본 분리 완료 후**. 이유: `HmegDirectStateProvider`는 HmEG-aware 계층에 들어가야 다른 SUT에서 재사용 가능. + +## 관련 + +- `docs/contracts/engine-bridge-v3.md` (갱신 예정) +- `docs/hmeg-api-survey.md` (신규 — 본 세션 산출) +- `src/Recordingtest.EgPlugin/IAppManagerAccessor.cs` (신규 — CI fallback으로 유지) +- `src/Recordingtest.EgPlugin/IEngineStateProvider.cs` (v3 1차 재작성, 다음 단계에서 HmEgDirectStateProvider 추가) +- `tests/Recordingtest.EgPlugin.Tests/ReflectionEngineStateProviderTests.cs` (신규) diff --git a/docs/hmeg-api-survey.md b/docs/hmeg-api-survey.md new file mode 100644 index 0000000..58add17 --- /dev/null +++ b/docs/hmeg-api-survey.md @@ -0,0 +1,205 @@ +# HmEG Public API Survey (engine-bridge v3 source) + +> 본 문서는 `D:\GiteaAll\HmEngine\HmEG\HmEG` 소스를 read-only로 훑어 정리한 결과다. +> 목적: `HmEgDirectStateProvider`와 SUT-side bridge 추가 엔드포인트(`/focus`, `/hit-test`, `/command`) 설계 근거. +> **수정 금지** — 본 폴더는 우리 저장소 바깥의 SUT 소스다. 참조만. + +## 식별 마커 — `` + +HmEG는 `` XML doc 태그로 **공식 공개 surface**를 표시한다. v3에서 우리가 의존할 모든 멤버는 이 태그가 붙은 것만 사용한다. (난독화/리네이밍 시 해당 마커가 안전 목록 역할을 할 가능성이 큼.) + +검색 (read-only): +``` +grep -rn 'public-hmeg-api' D:\GiteaAll\HmEngine\HmEG\HmEG +``` + +## 핵심 타입 (확인 완료) + +### 1. `ModelBase` — 모든 모델 엔티티의 추상 base +경로: `Model\Scene\ModelData\ModelBase.cs` + +| 멤버 | 타입 | 비고 | +|---|---|---| +| `Uid` | `Guid` | **결정성 핵심** — 영구 고유 ID. golden file에 그대로 쓸 수 있음 | +| `Name` | `string` | 사용자 보이는 이름 | +| `ModelType` | `ModelType` (enum) | Mesh / Line / 빌보드 등 | +| `GeoType` | `HmGEntityType` | HmGeometry 공통 | +| `Tag` | `object` | 사용자 커스텀 | +| `Label` | `string` | dwg export XData용 | +| `ModelMatrix` | `HmMatrix3D` | 변환 행렬 | +| `ItemsChanged` | `event EventHandler` | 자식 변경 알림 — wait_for 후보 | + +### 2. `Space : ModelBase` — 문서 컨테이너 (= "Space" 트리의 루트) +경로: `Model\Scene\ModelData\Space.cs`, `Space.Functions.cs` + +| 멤버 | 타입 | 비고 | +|---|---|---| +| `Children` | `EgObservableFastList` | **scene 트리 자식 노드** (= 객체 리스트) | +| `ItemsCount` | `int` | **`/scene` 의 ObjectCount 가 직접 매핑** | +| `Viewports` | `List` | 이 Space에 연결된 뷰포트들 | +| `SplitRow` / `SplitCol` | `int` | 뷰포트 분할 | +| `EnableGroupSelection` | `bool` | | +| `Add(ModelBase)` / `DeleteModel(...)` / `AddSpace(...)` / `Clear()` | mutator | **read-only로만 사용** | +| `ImportFileModels` / `ImportInstancingModels` | event | 파일 import 알림 — wait_for 후보 | + +### 3. `HmModel : ModelBase` — 실제 형상 객체 +경로: `Model\Scene\ModelData\HmModel.cs` + +| 멤버 | 타입 | 비고 | +|---|---|---| +| `GEntity` | `HmGEntity` | HmGeometry 공통 | +| `EGgEntity` | `EgObject` | EG geometry 객체 | +| `BlockName` | `string` | | +| `LayerName` | `string` | | +| `LineTypeName` | `string` | | +| `LineTypeScale` | `double` | | +| `AttributeReferences` | `HmAttributeReferenceCollection` | | +| `MouseEnter` / `MouseLeave` / `MouseDown` | event | **마우스 hit-test 결과 통지** — recorder에서 element 식별에 사용 가능 | + +### 4. `HmEGViewport` (interface) — 뷰포트 공개 인터페이스 +경로: `Interface\IHmEGViewport.cs` (namespace `HmEG`) + +| 멤버 | 타입 | 비고 | +|---|---|---| +| `CameraCore` | `CameraCore` (`Model\Camera\CameraCore.cs`) | **카메라 진입점** | +| `Renderables` | `IEnumerable` | 3D 씬 그래프 루트 노드 enumerable | +| `D2DRenderables` | `IEnumerable` | 2D 노드 | +| `RenderHost` | `IRenderHost` | | +| `EffectsManager` | `IEffectsManager` | | +| `ViewportRectangle` | `EGRectangleI` | 픽셀 단위 | +| `Attach(IRenderHost)` / `Detach()` | mutator | | +| `Update(TimeSpan)` / `InvalidateRender(...)` / `InvalidateSceneGraph(...)` | mutator | | + +### 5. `IHmCamera` — 카메라 표준 인터페이스 +경로: `Interface\IHmCamera.cs` + +| 멤버 | 타입 | 매핑 | +|---|---|---| +| `Position` | `HmVector3D` | `CameraSnapshot.Eye` | +| `LookDirection` | `HmVector3D` | `Eye + LookDir = Target` | +| `UpDirection` | `HmVector3D` | `CameraSnapshot.Up` | +| `CreateLeftHandSystem` | `bool` | 좌표계 정규화 시 필요 | +| `CreateViewMatrix(HmMatrix3D)` | method | | +| `CreateProjectionMatrix(double aspectRatio)` | method | fov는 별도 추출 (PerspectiveCamera에) | + +> **주의**: `IHmCamera`에는 `Fov`/`FieldOfView`가 없다. `PerspectiveCamera : ProjectionCamera : CameraCore` 쪽에 있을 것으로 추정. v3 작성 시 `CameraCore` → `PerspectiveCamera` cast로 fov를 꺼낸다. + +### 6. `ISelectable` — 노드 선택 상태 +경로: `Interface\Interfaces.cs:235` + +```csharp +public interface ISelectable +{ + bool IsSelected { get; set; } +} +``` + +**중앙 집중 selection 리스트는 HmEG core에 없음.** SelectedIds를 얻으려면 Space 트리를 walk하면서 `ISelectable.IsSelected == true`인 노드를 모아야 한다. 또는 SUT 측 AppManager가 selection 리스트를 따로 들고 있을 수 있음 — **확인 필요**. + +### 7. `HmSceneNode : MaterialGeometryNode, IDynamicReflectable, ISelectable` +경로: `Model\Scene\HmSceneNode.cs` + +씬 그래프의 실제 렌더링 노드. `IsSelected`를 가짐. `Renderables`에서 흘러나옴. + +### 8. `HmEG.IPlugin` (interface) +경로: `PlugIns\IPlugin.cs` + +```csharp +public interface IPlugin +{ + string Name { get; } + EGViewport View { get; set; } // ← 플러그인에 직접 주입되는 뷰포트 + bool RethrowException { get; set; } + object Run(params object[] args); +} +``` + +**중요**: HmEG가 플러그인을 로드할 때 `View`를 직접 set 해준다. 즉 플러그인은 따로 Space/AppManager를 찾아갈 필요 없이 **`this.View`로 즉시 뷰포트에 도달**한다. 거기서 `CameraCore`, `Renderables` (씬 노드 enumerable) 모두 접근 가능. `Renderables`를 walk하면서 `ISelectable.IsSelected`로 선택된 노드 추출. + +> 본 저장소의 `HmEgBridgePlugin`은 `EditorPlugin` (SUT-side `Editor03.PluginInterface`) 베이스를 쓴다. `EditorPlugin`이 내부적으로 `HmEG.IPlugin.View`를 set 해주는지, 아니면 다른 경로(예: `AppManager`)를 통해 Space에 접근하는지는 **`Editor03.PluginInterface.dll` 디컴파일 또는 SUT-side 소스가 있어야 확정 가능**. + +## 아직 모르는 것 (확인 대기) + +| | 항목 | 어디서 찾아야 하는가 | +|---|---|---| +| Q1 | "활성 Space" 진입점 — `EGViewport.Space`? `AppManager.Instance.ActiveSpace`? | `Editor03.PluginInterface` / SUT-side AppManager | +| Q2 | "활성 Viewport" 가 여러 개일 때 어느 것이 active 인가 | 동일 | +| Q3 | 중앙 selection 리스트 (예: `AppManager.Selection`) 또는 selection-changed 이벤트 | 동일 | +| Q4 | 명령 (Command) 생명주기 이벤트 — `CommandStarted` / `CommandFinished` 같은 것 | 동일 (`Editor.AppManager.AppModeManager` 후보) | +| Q5 | `PerspectiveCamera`에서 `FieldOfView` 정확한 프로퍼티 이름 | `Model\Camera\PerspectiveCamera.cs` (read-only로 한 번만 더 확인하면 됨) | +| Q6 | 문서 파일 경로 — 저장 후의 `*.hmeg` 경로 보유처 | `Editor03.PluginInterface` 또는 `AppManager.ActiveDocument` | +| Q7 | `EGViewport` 와 `HmEGViewport` 관계 — `EGViewport`가 후자를 구현? 아니면 별개 SUT-side 클래스? | `Editor03.PluginInterface` | + +## v3 구현 방향 (Direct Provider) + +```csharp +// src/Recordingtest.EgPlugin/HmEgDirectStateProvider.cs +public sealed class HmEgDirectStateProvider : IEngineStateProvider +{ + private readonly Func _getViewport; // plugin이 주입 + private readonly Func _getSpace; // 동일 + + public IReadOnlyList GetSelectedIds() + { + var sp = _getSpace(); + if (sp is null) return Array.Empty(); + var ids = new List(); + Walk(sp, ids); + return ids; + static void Walk(ModelBase node, List ids) + { + if (node is ISelectable s && s.IsSelected) ids.Add(node.Uid.ToString()); + if (node is Space space) + foreach (var child in space.Children) Walk(child, ids); + } + } + + public CameraSnapshot GetCamera() + { + var vp = _getViewport(); + if (vp is null) return Default; + var cam = vp.CameraCore; + // CameraCore → IHmCamera 캐스트, 또는 Position/Look/Up 직접 접근 + // PerspectiveCamera 캐스트로 FOV + ... + } + + public SceneSnapshot GetScene() + { + var sp = _getSpace(); + return new SceneSnapshot( + sp?.ItemsCount ?? 0, + DocumentPathFromSomewhere()); // Q6 + } + + public bool GetRenderComplete() => true; // 후속 작업 +} +``` + +`_getViewport` / `_getSpace` 람다는 `HmEgBridgePlugin`이 자기 환경(`EditorPlugin`이 노출하는 진입점)에서 캡처해 넘긴다. 람다 형태로 두면 다음과 같은 이점이 있다: +- 플러그인 base 클래스가 진입점을 어떻게 노출하는지가 바뀌어도 v3 provider는 영향 없음 +- 단위 테스트는 fake 람다를 넘겨서 검증 + +## SUT-side bridge 추가 엔드포인트 (Gap I 우회) + +`BridgeHttpServer` / `StateRouter`에 추가: + +| 엔드포인트 | 응답 | 사용처 | +|---|---|---| +| `GET /focus` | `{"path": "...", "type": "...", "name": "..."}` | recorder가 key_down 시 polling. `Keyboard.FocusedElement` (WPF)를 Dispatcher 위에서 호출 | +| `GET /hit-test?x=&y=` | `{"hit": "HmModel#guid", "type": "...", "name": "..."}` | recorder가 click 시 호출. Space/Renderables walk + `VisualTreeHelper.HitTest` + (있으면) HmEG의 pick API | +| `GET /command` | `{"running": "BOX", "phase": "awaiting_first_corner"}` | player의 wait_for. Q4 이벤트 구독 결과 캐시 | + +이건 engine-bridge v3 본 contract와 별도 contract 권장 (`/contract sut-side-bridge`). + +## 다음 액션 + +1. **engine-bridge-v3.md 계약 갱신** — reflection 항목 제거, 위 타입/멤버 이름으로 D2/D3/D4 고정 +2. **Q1~Q7 확인** — 사용자가 SUT-side(Editor03.PluginInterface 또는 AppManager) 소스/경로 알려주면 read-only로 1~2회 더 확인 +3. Q1/Q3 채워지면 `HmEgDirectStateProvider` 구현 + 플러그인 wire-up +4. CI fallback — 기존 `ReflectionEngineStateProvider` + `IAppManagerAccessor`는 fake-friendly 형태로 유지 (HmEG 어셈블리 없는 단위 테스트 환경에서 빌드/테스트 가능해야 함) +5. 라이브 검증 → curl 로 `/state` 확인 → 보정 → 커밋 + +## 라이선스 / 위생 + +본 저장소는 HmEG 소스 사본을 보관하지 **않는다**. 본 문서는 외부 소스에 대한 인터페이스 추출 메모일 뿐. 코드 발췌도 시그니처/주석 수준으로만 인용한다. diff --git a/recordingtest.sln b/recordingtest.sln index ea89653..e919a12 100644 --- a/recordingtest.sln +++ b/recordingtest.sln @@ -41,6 +41,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EngineBridge. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EgPlugin.Tests", "tests\Recordingtest.EgPlugin.Tests\Recordingtest.EgPlugin.Tests.csproj", "{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Bridge.Abstractions", "src\Recordingtest.Bridge.Abstractions\Recordingtest.Bridge.Abstractions.csproj", "{E9192225-E9F6-44EB-A18E-7F61F1093DA8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Hmeg.Bridge", "src\Hmeg\Recordingtest.Hmeg.Bridge\Recordingtest.Hmeg.Bridge.csproj", "{33D35B3C-9572-432F-8675-6AD7CDF1C0EB}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Hmeg", "Hmeg", "{FA0FB21B-DC6D-6187-86C3-94DFEB22505D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Hmeg.Bridge.Tests", "tests\Hmeg\Recordingtest.Hmeg.Bridge.Tests\Recordingtest.Hmeg.Bridge.Tests.csproj", "{20FB4AD7-3414-436D-880C-B2D95280DA3D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -279,6 +289,42 @@ Global {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 + {E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Debug|x64.ActiveCfg = Debug|Any CPU + {E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Debug|x64.Build.0 = Debug|Any CPU + {E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Debug|x86.ActiveCfg = Debug|Any CPU + {E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Debug|x86.Build.0 = Debug|Any CPU + {E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Release|Any CPU.Build.0 = Release|Any CPU + {E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Release|x64.ActiveCfg = Release|Any CPU + {E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Release|x64.Build.0 = Release|Any CPU + {E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Release|x86.ActiveCfg = Release|Any CPU + {E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Release|x86.Build.0 = Release|Any CPU + {33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Debug|x64.ActiveCfg = Debug|Any CPU + {33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Debug|x64.Build.0 = Debug|Any CPU + {33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Debug|x86.ActiveCfg = Debug|Any CPU + {33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Debug|x86.Build.0 = Debug|Any CPU + {33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Release|Any CPU.Build.0 = Release|Any CPU + {33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Release|x64.ActiveCfg = Release|Any CPU + {33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Release|x64.Build.0 = Release|Any CPU + {33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Release|x86.ActiveCfg = Release|Any CPU + {33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Release|x86.Build.0 = Release|Any CPU + {20FB4AD7-3414-436D-880C-B2D95280DA3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {20FB4AD7-3414-436D-880C-B2D95280DA3D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {20FB4AD7-3414-436D-880C-B2D95280DA3D}.Debug|x64.ActiveCfg = Debug|Any CPU + {20FB4AD7-3414-436D-880C-B2D95280DA3D}.Debug|x64.Build.0 = Debug|Any CPU + {20FB4AD7-3414-436D-880C-B2D95280DA3D}.Debug|x86.ActiveCfg = Debug|Any CPU + {20FB4AD7-3414-436D-880C-B2D95280DA3D}.Debug|x86.Build.0 = Debug|Any CPU + {20FB4AD7-3414-436D-880C-B2D95280DA3D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {20FB4AD7-3414-436D-880C-B2D95280DA3D}.Release|Any CPU.Build.0 = Release|Any CPU + {20FB4AD7-3414-436D-880C-B2D95280DA3D}.Release|x64.ActiveCfg = Release|Any CPU + {20FB4AD7-3414-436D-880C-B2D95280DA3D}.Release|x64.Build.0 = Release|Any CPU + {20FB4AD7-3414-436D-880C-B2D95280DA3D}.Release|x86.ActiveCfg = Release|Any CPU + {20FB4AD7-3414-436D-880C-B2D95280DA3D}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -302,5 +348,9 @@ Global {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} + {E9192225-E9F6-44EB-A18E-7F61F1093DA8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {33D35B3C-9572-432F-8675-6AD7CDF1C0EB} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {FA0FB21B-DC6D-6187-86C3-94DFEB22505D} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {20FB4AD7-3414-436D-880C-B2D95280DA3D} = {FA0FB21B-DC6D-6187-86C3-94DFEB22505D} EndGlobalSection EndGlobal diff --git a/src/Hmeg/Recordingtest.Hmeg.Bridge/HmegDirectStateProvider.cs b/src/Hmeg/Recordingtest.Hmeg.Bridge/HmegDirectStateProvider.cs new file mode 100644 index 0000000..224e7d7 --- /dev/null +++ b/src/Hmeg/Recordingtest.Hmeg.Bridge/HmegDirectStateProvider.cs @@ -0,0 +1,212 @@ +using HmEG; +using Recordingtest.Bridge; + +namespace Recordingtest.Hmeg.Bridge; + +/// +/// HmEG-aware backed by direct calls into +/// the HmEG public API. Reusable across any WPF application that hosts HmEG. +/// +/// The provider is decoupled from the *host* application via two lambdas +/// supplied at construction: +/// +/// +/// spaceProvider — returns the active tree (root of the scene/document). +/// viewportProvider — returns the active (camera + renderables). +/// +/// +/// App-specific glue (e.g. Recordingtest.Sut.EgBim.*) is responsible for +/// resolving those handles from its own AppManager and passing them in. +/// This keeps the bridge usable for *any* HmEG-hosting WPF app without +/// recompilation. +/// +/// All accessors are best-effort: any exception is swallowed and the method +/// returns the same safe default a would. +/// The plugin runs in-process inside the SUT and must never throw. +/// +public sealed class HmegDirectStateProvider : IEngineStateProvider +{ + private readonly Func _spaceProvider; + private readonly Func _viewportProvider; + private readonly Func? _documentPathProvider; + + public HmegDirectStateProvider( + Func spaceProvider, + Func viewportProvider, + Func? documentPathProvider = null) + { + _spaceProvider = spaceProvider ?? throw new ArgumentNullException(nameof(spaceProvider)); + _viewportProvider = viewportProvider ?? throw new ArgumentNullException(nameof(viewportProvider)); + _documentPathProvider = documentPathProvider; + } + + public IReadOnlyList GetSelectedIds() + { + try + { + var space = _spaceProvider(); + if (space is null) return Array.Empty(); + var ids = new List(); + CollectSelectedRecursive(space, ids); + return ids; + } + catch + { + return Array.Empty(); + } + } + + /// + /// Walks the Space tree and collects the Uid of every node whose + /// is true. HmEG does not + /// expose a centralized selection list in core; this is the canonical + /// traversal pattern. + /// + /// We deliberately type the node parameter as rather + /// than HmEG.ModelBase so this assembly does not have to reference + /// MemoryPack.Core (a serialization dependency that ModelBase + /// transitively pulls in via its attributes). The runtime shape we rely + /// on is just ISelectable + Uid + Children, all of + /// which are read by reflection-free pattern matching. + /// + private static void CollectSelectedRecursive(object node, List ids) + { + if (node is HmEG.ISelectable sel && sel.IsSelected) + { + // Read Uid via late-bound property access — avoids the ModelBase + // type reference and survives any future field-vs-property change. + var uid = node.GetType().GetProperty("Uid")?.GetValue(node); + if (uid is not null) ids.Add(uid.ToString() ?? string.Empty); + } + if (node is Space space) + { + foreach (var child in space.Children) + { + if (child is not null) CollectSelectedRecursive(child, ids); + } + } + } + + public CameraSnapshot GetCamera() + { + try + { + var vp = _viewportProvider(); + var core = vp?.CameraCore; + if (core is null) return Default.GetCamera(); + + // CameraCore is the abstract base; common shapes (Position, LookDirection, + // UpDirection) come from IHmCamera-like contracts. We use late-binding via + // reflection on the concrete CameraCore subclass to stay tolerant of + // PerspectiveCamera vs OrthographicCamera vs MatrixCamera variants. + // + // This is the *only* reflection in the HmEG-aware tier and it sits behind + // a try/catch — failure mode is a default snapshot. + var t = core.GetType(); + double[] eye = ReadVec3(core, t, new[] { "Position", "Eye" }); + double[] look = ReadVec3(core, t, new[] { "LookDirection", "Direction" }); + double[] up = ReadVec3(core, t, new[] { "UpDirection", "Up" }); + double fov = ReadDouble(core, t, new[] { "FieldOfView", "Fov", "FOV" }, fallback: 45.0); + + // Target = Eye + LookDirection (HmEG stores look as a direction vector, + // not as an explicit target point). + var target = new double[] + { + eye[0] + look[0], + eye[1] + look[1], + eye[2] + look[2], + }; + + return new CameraSnapshot(eye, target, up, fov); + } + catch + { + return Default.GetCamera(); + } + } + + public SceneSnapshot GetScene() + { + try + { + var space = _spaceProvider(); + int count = space?.ItemsCount ?? 0; + string? path = null; + try { path = _documentPathProvider?.Invoke(); } catch { /* best-effort */ } + return new SceneSnapshot(count, path); + } + catch + { + return new SceneSnapshot(0, null); + } + } + + public bool GetRenderComplete() + { + // HmEG core does not expose a stable "frame complete" signal we can + // poll without subscribing to a render-host event. Treat as always + // ready until a Hmeg.Bridge follow-up wires the event. + return true; + } + + private static readonly NullEngineStateProvider Default = new(); + + private static double[] ReadVec3(object owner, Type t, string[] names) + { + foreach (var n in names) + { + object? v = null; + try + { + var p = t.GetProperty(n); + if (p is not null) v = p.GetValue(owner); + if (v is null) + { + var f = t.GetField(n); + if (f is not null) v = f.GetValue(owner); + } + } + catch { /* try next */ } + if (v is null) continue; + + // Common shapes: HmVector3D / Vector3 / double[] / float[] + if (v is double[] da && da.Length >= 3) return new[] { da[0], da[1], da[2] }; + if (v is float[] fa && fa.Length >= 3) return new[] { (double)fa[0], fa[1], fa[2] }; + + try + { + double Read(string memberName) + { + var vt = v.GetType(); + var pp = vt.GetProperty(memberName); + if (pp is not null) return Convert.ToDouble(pp.GetValue(v)); + var ff = vt.GetField(memberName); + if (ff is not null) return Convert.ToDouble(ff.GetValue(v)); + return 0; + } + return new[] { Read("X"), Read("Y"), Read("Z") }; + } + catch + { + // fall through to next candidate + } + } + return new double[] { 0, 0, 0 }; + } + + private static double ReadDouble(object owner, Type t, string[] names, double fallback) + { + foreach (var n in names) + { + try + { + var p = t.GetProperty(n); + if (p is not null) return Convert.ToDouble(p.GetValue(owner)); + var f = t.GetField(n); + if (f is not null) return Convert.ToDouble(f.GetValue(owner)); + } + catch { /* try next */ } + } + return fallback; + } +} diff --git a/src/Hmeg/Recordingtest.Hmeg.Bridge/Recordingtest.Hmeg.Bridge.csproj b/src/Hmeg/Recordingtest.Hmeg.Bridge/Recordingtest.Hmeg.Bridge.csproj new file mode 100644 index 0000000..e423898 --- /dev/null +++ b/src/Hmeg/Recordingtest.Hmeg.Bridge/Recordingtest.Hmeg.Bridge.csproj @@ -0,0 +1,22 @@ + + + net8.0-windows + true + enable + enable + true + Recordingtest.Hmeg.Bridge + + + + + + + + ..\..\..\EG-BIM Modeler\HmEG.dll + false + + + diff --git a/src/Recordingtest.Bridge.Abstractions/IEngineStateProvider.cs b/src/Recordingtest.Bridge.Abstractions/IEngineStateProvider.cs new file mode 100644 index 0000000..70d3097 --- /dev/null +++ b/src/Recordingtest.Bridge.Abstractions/IEngineStateProvider.cs @@ -0,0 +1,37 @@ +namespace Recordingtest.Bridge; + +/// +/// Generic, SUT-neutral abstraction for reading the current engine state of +/// a WPF application under test. Implementations may delegate to a SUT-side +/// in-process bridge (HmEG-aware tier), to a reflection probe, or to a stub. +/// +/// This interface lives in Recordingtest.Bridge.Abstractions so that +/// every higher tier (HmEG-aware / app-specific) can target it without the +/// generic core ever seeing a SUT-specific symbol. +/// +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); + +/// +/// Safe-default provider used when no real SUT bridge is available +/// (CI / unit tests / startup race window). +/// +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; +} diff --git a/src/Recordingtest.Bridge.Abstractions/Recordingtest.Bridge.Abstractions.csproj b/src/Recordingtest.Bridge.Abstractions/Recordingtest.Bridge.Abstractions.csproj new file mode 100644 index 0000000..5f7db24 --- /dev/null +++ b/src/Recordingtest.Bridge.Abstractions/Recordingtest.Bridge.Abstractions.csproj @@ -0,0 +1,9 @@ + + + net8.0 + enable + enable + true + Recordingtest.Bridge + + diff --git a/src/Recordingtest.EgPlugin/ChainedEngineStateProvider.cs b/src/Recordingtest.EgPlugin/ChainedEngineStateProvider.cs new file mode 100644 index 0000000..d6df74c --- /dev/null +++ b/src/Recordingtest.EgPlugin/ChainedEngineStateProvider.cs @@ -0,0 +1,54 @@ +using Recordingtest.Bridge; + +namespace Recordingtest.EgPlugin; + +/// +/// Tries the primary provider first; if it returns the empty/default value +/// (e.g. HmegDirectStateProvider with unresolved lambdas) the fallback +/// provider is consulted. This lets us land the HmEG-aware path now while +/// still benefiting from the reflection fallback when EgBim AppManager +/// entry-point wiring is incomplete. +/// +/// "Empty/default" is detected per signal: +/// - SelectedIds: empty list → try fallback +/// - Camera : Up == (0,0,1) AND Eye == (0,0,0) → try fallback +/// - Scene : ObjectCount == 0 AND DocumentPath == null → try fallback +/// - RenderComplete: primary always wins (boolean) +/// +public sealed class ChainedEngineStateProvider : IEngineStateProvider +{ + private readonly IEngineStateProvider _primary; + private readonly IEngineStateProvider _fallback; + + public ChainedEngineStateProvider(IEngineStateProvider primary, IEngineStateProvider fallback) + { + _primary = primary; + _fallback = fallback; + } + + public IReadOnlyList GetSelectedIds() + { + var p = _primary.GetSelectedIds(); + return p.Count > 0 ? p : _fallback.GetSelectedIds(); + } + + public CameraSnapshot GetCamera() + { + var p = _primary.GetCamera(); + return IsDefault(p) ? _fallback.GetCamera() : p; + } + + public SceneSnapshot GetScene() + { + var p = _primary.GetScene(); + return p.ObjectCount == 0 && p.DocumentPath is null + ? _fallback.GetScene() + : p; + } + + public bool GetRenderComplete() => _primary.GetRenderComplete(); + + private static bool IsDefault(CameraSnapshot c) => + c.Eye is { Length: >= 3 } e && e[0] == 0 && e[1] == 0 && e[2] == 0 && + c.Target is { Length: >= 3 } t && t[0] == 0 && t[1] == 0 && t[2] == 0; +} diff --git a/src/Recordingtest.EgPlugin/HmEgBridgePlugin.cs b/src/Recordingtest.EgPlugin/HmEgBridgePlugin.cs index 770ec24..dc6d5f9 100644 --- a/src/Recordingtest.EgPlugin/HmEgBridgePlugin.cs +++ b/src/Recordingtest.EgPlugin/HmEgBridgePlugin.cs @@ -1,4 +1,7 @@ using Editor.PluginInterface; +using HmEG; +using Recordingtest.Bridge; +using Recordingtest.Hmeg.Bridge; namespace Recordingtest.EgPlugin; @@ -29,7 +32,7 @@ public sealed class HmEgBridgePlugin : EditorPlugin, IDisposable try { var port = PortResolver.Resolve(); - var provider = new ReflectionEngineStateProvider(this); + var provider = BuildProvider(); var router = new StateRouter(provider, port); _server = new BridgeHttpServer(router, port); _server.Start(); @@ -40,6 +43,34 @@ public sealed class HmEgBridgePlugin : EditorPlugin, IDisposable } } + /// + /// Choose the best available : + /// + /// 1. HmegDirectStateProvider — HmEG-aware tier, calls into HmEG + /// public API. Reusable across any HmEG-hosting WPF app. Active + /// space/viewport handles are resolved via lambdas; the EgBim + /// app-specific entry-point lookup lives in this method only. + /// 2. ReflectionEngineStateProvider — fallback that uses + /// IAppManagerAccessor reflection on Editor.AppManager.AppManager. + /// Used when the direct provider can't resolve any handle. + /// + /// Both providers are wrapped to never throw; the SUT must remain stable. + /// + private IEngineStateProvider BuildProvider() + { + // Lambdas: until the EgBim adapter (Q1/Q2) is filled in, return null + // and let HmegDirectStateProvider fall through to its safe defaults. + // This keeps the wire-up landed; live verification will populate the + // lambdas with the real Editor.AppManager.AppManager entry points. + Func spaceProvider = () => null; + Func viewportProvider = () => null; + Func? documentPathProvider = null; + + var direct = new HmegDirectStateProvider(spaceProvider, viewportProvider, documentPathProvider); + var fallback = new ReflectionEngineStateProvider(this); + return new ChainedEngineStateProvider(direct, fallback); + } + public void Dispose() { try { _server?.Dispose(); } catch { } diff --git a/src/Recordingtest.EgPlugin/IAppManagerAccessor.cs b/src/Recordingtest.EgPlugin/IAppManagerAccessor.cs new file mode 100644 index 0000000..e2fe20a --- /dev/null +++ b/src/Recordingtest.EgPlugin/IAppManagerAccessor.cs @@ -0,0 +1,214 @@ +using System.Reflection; + +namespace Recordingtest.EgPlugin; + +/// +/// engine-bridge v3 — abstraction over the SUT-side AppManager singleton. +/// Lets be unit-tested with a +/// fake instead of needing the real HmEG runtime in CI. +/// +public interface IAppManagerAccessor +{ + /// The AppManager root, or null if not yet discovered. + object? GetAppManager(); + + /// The currently active document, or null. + object? GetActiveDocument(); + + /// The currently active viewport (camera host), or null. + object? GetActiveViewport(); + + /// Selection IDs as strings. Empty when no selection or not discoverable. + IReadOnlyList GetSelectedIds(); + + /// Object count in the active document, or 0. + int GetObjectCount(); + + /// Active document path on disk, or null when unsaved. + string? GetDocumentPath(); + + /// + /// Camera tuple (eye, target, up, fov). Returns null when no viewport. + /// + (double[] Eye, double[] Target, double[] Up, double Fov)? GetCameraTuple(); +} + +/// +/// Default in-process reflection accessor. Walks well-known HmEG type and +/// member names against a runtime-resolved AppManager handle. All accessors +/// are best-effort: any failure (missing type, wrong shape, exception) is +/// swallowed and the method returns the safe-default value. The plugin must +/// never throw out of these calls because it runs inside the SUT process. +/// +/// Type/member names are intentionally lookup-by-string so the v3 reflection +/// shape can be tightened against live SUT inspection without recompilation +/// of the plugin's public surface. +/// +public sealed class ReflectionAppManagerAccessor : IAppManagerAccessor +{ + private readonly object? _seedRoot; + + /// + /// Any object that can act as the entry point to the AppManager graph. + /// In production this is the plugin instance itself; the accessor walks + /// out to the AppManager singleton via reflection on loaded assemblies. + /// + public ReflectionAppManagerAccessor(object? seedRoot) + { + _seedRoot = seedRoot; + } + + private object? _cachedAppManager; + private bool _appManagerLookupAttempted; + + public object? GetAppManager() + { + if (_cachedAppManager is not null) return _cachedAppManager; + if (_appManagerLookupAttempted) return null; + _appManagerLookupAttempted = true; + + // Strategy: scan loaded assemblies for the well-known AppManager type + // and look for a static "Instance" / "Current" property. HmEG ships + // Editor.AppManager.* types under the Editor02.HmEGAppManager + // assembly (per docs/engine-catalog). + try + { + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + Type? t = null; + try { t = asm.GetType("Editor.AppManager.AppManager", throwOnError: false); } + catch { /* ignore — assembly may not allow GetType */ } + if (t is null) continue; + + foreach (var name in new[] { "Instance", "Current", "Default" }) + { + var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.Static); + var v = p?.GetValue(null); + if (v is not null) { _cachedAppManager = v; return v; } + } + } + } + catch + { + // best-effort; keep _cachedAppManager null + } + return null; + } + + public object? GetActiveDocument() + { + var am = GetAppManager(); + return TryGetMember(am, new[] { "ActiveDocument", "CurrentDocument", "Document" }); + } + + public object? GetActiveViewport() + { + var am = GetAppManager(); + return TryGetMember(am, new[] { "ActiveViewport", "CurrentViewport", "Viewport" }); + } + + public IReadOnlyList GetSelectedIds() + { + var doc = GetActiveDocument(); + var sel = TryGetMember(doc, new[] { "Selection", "SelectedObjects", "Selected" }); + if (sel is not System.Collections.IEnumerable e) return Array.Empty(); + var ids = new List(); + try + { + foreach (var item in e) + { + if (item is null) continue; + var id = TryGetMember(item, new[] { "Id", "ID", "Guid", "ObjectId" }); + ids.Add(id?.ToString() ?? item.GetHashCode().ToString()); + } + } + catch { /* return what we got so far */ } + return ids; + } + + public int GetObjectCount() + { + var doc = GetActiveDocument(); + var objs = TryGetMember(doc, new[] { "Objects", "Entities", "Nodes" }); + if (objs is null) return 0; + if (objs is System.Collections.ICollection coll) return coll.Count; + try + { + int n = 0; + if (objs is System.Collections.IEnumerable e) + foreach (var _ in e) n++; + return n; + } + catch { return 0; } + } + + public string? GetDocumentPath() + { + var doc = GetActiveDocument(); + var p = TryGetMember(doc, new[] { "FilePath", "Path", "FileName", "DocumentPath" }); + return p?.ToString(); + } + + public (double[] Eye, double[] Target, double[] Up, double Fov)? GetCameraTuple() + { + var vp = GetActiveViewport(); + var cam = TryGetMember(vp, new[] { "Camera", "ActiveCamera" }); + if (cam is null) return null; + try + { + var eye = AsVec3(TryGetMember(cam, new[] { "Position", "Eye", "From" })); + var tgt = AsVec3(TryGetMember(cam, new[] { "Target", "LookAt", "To" })); + var up = AsVec3(TryGetMember(cam, new[] { "UpDirection", "Up" })); + var fovObj = TryGetMember(cam, new[] { "FieldOfView", "Fov", "FOV" }); + double fov = fovObj is null ? 45.0 : Convert.ToDouble(fovObj); + return (eye, tgt, up, fov); + } + catch + { + return null; + } + } + + private static object? TryGetMember(object? owner, string[] candidates) + { + if (owner is null) return null; + var t = owner.GetType(); + foreach (var name in candidates) + { + try + { + var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.Instance); + if (p is not null) return p.GetValue(owner); + var f = t.GetField(name, BindingFlags.Public | BindingFlags.Instance); + if (f is not null) return f.GetValue(owner); + } + catch { /* try next candidate */ } + } + return null; + } + + private static double[] AsVec3(object? v) + { + if (v is null) return new double[] { 0, 0, 0 }; + // Try common shapes: double[], float[], or X/Y/Z properties. + if (v is double[] da && da.Length >= 3) return new[] { da[0], da[1], da[2] }; + if (v is float[] fa && fa.Length >= 3) return new[] { (double)fa[0], fa[1], fa[2] }; + var t = v.GetType(); + try + { + double Read(string n) + { + var p = t.GetProperty(n, BindingFlags.Public | BindingFlags.Instance); + if (p is not null) return Convert.ToDouble(p.GetValue(v)); + var f = t.GetField(n, BindingFlags.Public | BindingFlags.Instance); + if (f is not null) return Convert.ToDouble(f.GetValue(v)); + return 0; + } + return new[] { Read("X"), Read("Y"), Read("Z") }; + } + catch + { + return new double[] { 0, 0, 0 }; + } + } +} diff --git a/src/Recordingtest.EgPlugin/IEngineStateProvider.cs b/src/Recordingtest.EgPlugin/IEngineStateProvider.cs index 610d0b4..668c433 100644 --- a/src/Recordingtest.EgPlugin/IEngineStateProvider.cs +++ b/src/Recordingtest.EgPlugin/IEngineStateProvider.cs @@ -1,56 +1,87 @@ +// Issue #14 / 3-tier split — IEngineStateProvider, CameraSnapshot, SceneSnapshot, +// and NullEngineStateProvider have moved to Recordingtest.Bridge.Abstractions +// (generic tier). They are re-exported here as type aliases so existing +// internal call sites in this plugin continue to compile until the EgBim +// PluginHost folder move lands. +// +// ReflectionEngineStateProvider stays here for now as the CI / fallback +// implementation. The HmEG-aware HmegDirectStateProvider lives in +// Recordingtest.Hmeg.Bridge. + +using Recordingtest.Bridge; + 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. +/// engine-bridge v3 — reflection-backed provider. Delegates all SUT-specific +/// lookups to , which is unit-testable via a +/// fake accessor (see FakeAccessor in tests). When the real +/// AppManager cannot be discovered the provider returns the same safe +/// defaults v2 used, so the SUT remains stable even if HmEG type names drift. +/// +/// In the 3-tier model this class is App-specific (EG-BIM Modeler): it +/// targets Editor.AppManager.AppManager. It is kept as the CI/fallback +/// path; production should prefer HmegDirectStateProvider. /// public sealed class ReflectionEngineStateProvider : IEngineStateProvider { - private readonly object? _appManager; + private readonly IAppManagerAccessor _accessor; - public ReflectionEngineStateProvider(object? appManager) + public ReflectionEngineStateProvider(IAppManagerAccessor accessor) + { + _accessor = accessor; + } + + /// + /// Convenience overload used by the v2 plugin call site, which only had + /// the plugin instance to hand. Wraps the seed in the default reflection + /// accessor so existing call sites compile unchanged. + /// + public ReflectionEngineStateProvider(object? seedRoot) + : this(new ReflectionAppManagerAccessor(seedRoot)) { - _appManager = appManager; } public IReadOnlyList GetSelectedIds() { - try { _ = _appManager; return Array.Empty(); } + try { return _accessor.GetSelectedIds(); } catch { return Array.Empty(); } } + private static readonly CameraSnapshot _defaultCamera = + new(new double[] { 0, 0, 0 }, new double[] { 0, 0, 0 }, new double[] { 0, 0, 1 }, 45.0); + 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); } + try + { + var t = _accessor.GetCameraTuple(); + return t is null + ? _defaultCamera + : new CameraSnapshot(t.Value.Eye, t.Value.Target, t.Value.Up, t.Value.Fov); + } + catch + { + return _defaultCamera; + } } public SceneSnapshot GetScene() { - try { return new(0, null); } - catch { return new(0, null); } + try + { + return new SceneSnapshot(_accessor.GetObjectCount(), _accessor.GetDocumentPath()); + } + catch + { + return new SceneSnapshot(0, null); + } } public bool GetRenderComplete() { - try { return true; } catch { return false; } + // v3 still treats render-complete as best-effort true; HmEG does not + // expose a stable "frame finished" flag we can poll without an event. + return true; } } diff --git a/src/Recordingtest.EgPlugin/Recordingtest.EgPlugin.csproj b/src/Recordingtest.EgPlugin/Recordingtest.EgPlugin.csproj index 02e3324..57dfe05 100644 --- a/src/Recordingtest.EgPlugin/Recordingtest.EgPlugin.csproj +++ b/src/Recordingtest.EgPlugin/Recordingtest.EgPlugin.csproj @@ -8,6 +8,10 @@ Recordingtest.EgPlugin true + + + + ..\..\EG-BIM Modeler\Editor03.PluginInterface.dll diff --git a/src/Recordingtest.EgPlugin/StateRouter.cs b/src/Recordingtest.EgPlugin/StateRouter.cs index 4ae9ad2..96235f4 100644 --- a/src/Recordingtest.EgPlugin/StateRouter.cs +++ b/src/Recordingtest.EgPlugin/StateRouter.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Net; using System.Text; +using Recordingtest.Bridge; namespace Recordingtest.EgPlugin; diff --git a/tests/Hmeg/Recordingtest.Hmeg.Bridge.Tests/HmegDirectStateProviderTests.cs b/tests/Hmeg/Recordingtest.Hmeg.Bridge.Tests/HmegDirectStateProviderTests.cs new file mode 100644 index 0000000..cb58288 --- /dev/null +++ b/tests/Hmeg/Recordingtest.Hmeg.Bridge.Tests/HmegDirectStateProviderTests.cs @@ -0,0 +1,73 @@ +using Recordingtest.Bridge; +using Recordingtest.Hmeg.Bridge; +using Xunit; + +namespace Recordingtest.Hmeg.Bridge.Tests; + +public class HmegDirectStateProviderTests +{ + [Fact] + public void NullLambdas_Return_SafeDefaults_NoThrow() + { + var p = new HmegDirectStateProvider( + spaceProvider: () => null, + viewportProvider: () => null); + + Assert.Empty(p.GetSelectedIds()); + + var c = p.GetCamera(); + Assert.Equal(45.0, c.Fov); + Assert.Equal(new double[] { 0, 0, 1 }, c.Up); + + var s = p.GetScene(); + Assert.Equal(0, s.ObjectCount); + Assert.Null(s.DocumentPath); + + Assert.True(p.GetRenderComplete()); + } + + [Fact] + public void Throwing_Lambdas_Are_Swallowed_Returns_SafeDefaults() + { + var p = new HmegDirectStateProvider( + spaceProvider: () => throw new InvalidOperationException("boom"), + viewportProvider: () => throw new InvalidOperationException("boom")); + + Assert.Empty(p.GetSelectedIds()); + var c = p.GetCamera(); + Assert.Equal(45.0, c.Fov); + var s = p.GetScene(); + Assert.Equal(0, s.ObjectCount); + } + + [Fact] + public void DocumentPathProvider_Is_Used_For_Scene() + { + var p = new HmegDirectStateProvider( + spaceProvider: () => null, + viewportProvider: () => null, + documentPathProvider: () => "C:/sample.hmeg"); + var s = p.GetScene(); + Assert.Equal("C:/sample.hmeg", s.DocumentPath); + } + + [Fact] + public void DocumentPathProvider_Throwing_Is_Swallowed() + { + var p = new HmegDirectStateProvider( + spaceProvider: () => null, + viewportProvider: () => null, + documentPathProvider: () => throw new InvalidOperationException()); + var s = p.GetScene(); + Assert.Null(s.DocumentPath); + } + + [Fact] + public void Constructor_Throws_OnNullProviders() + { + Assert.Throws(() => + new HmegDirectStateProvider(null!, () => null)); + Assert.Throws(() => + new HmegDirectStateProvider(() => null, null!)); + } +} diff --git a/tests/Hmeg/Recordingtest.Hmeg.Bridge.Tests/Recordingtest.Hmeg.Bridge.Tests.csproj b/tests/Hmeg/Recordingtest.Hmeg.Bridge.Tests/Recordingtest.Hmeg.Bridge.Tests.csproj new file mode 100644 index 0000000..3e2243b --- /dev/null +++ b/tests/Hmeg/Recordingtest.Hmeg.Bridge.Tests/Recordingtest.Hmeg.Bridge.Tests.csproj @@ -0,0 +1,28 @@ + + + net8.0-windows + true + enable + enable + true + false + Recordingtest.Hmeg.Bridge.Tests + + + + + + + + + + + + + ..\..\..\EG-BIM Modeler\HmEG.dll + + true + + + diff --git a/tests/Recordingtest.EgPlugin.Tests/ChainedEngineStateProviderTests.cs b/tests/Recordingtest.EgPlugin.Tests/ChainedEngineStateProviderTests.cs new file mode 100644 index 0000000..4bd1754 --- /dev/null +++ b/tests/Recordingtest.EgPlugin.Tests/ChainedEngineStateProviderTests.cs @@ -0,0 +1,93 @@ +using Recordingtest.Bridge; +using Recordingtest.EgPlugin; +using Xunit; + +namespace Recordingtest.EgPlugin.Tests; + +public class ChainedEngineStateProviderTests +{ + private sealed class ScriptedProvider : IEngineStateProvider + { + public IReadOnlyList SelectedIds = Array.Empty(); + public CameraSnapshot Camera = new( + new double[] { 0, 0, 0 }, + new double[] { 0, 0, 0 }, + new double[] { 0, 0, 1 }, + 45.0); + public SceneSnapshot Scene = new(0, null); + public bool Render = true; + + public IReadOnlyList GetSelectedIds() => SelectedIds; + public CameraSnapshot GetCamera() => Camera; + public SceneSnapshot GetScene() => Scene; + public bool GetRenderComplete() => Render; + } + + [Fact] + public void Selection_Primary_NonEmpty_Wins() + { + var p = new ScriptedProvider { SelectedIds = new[] { "a" } }; + var f = new ScriptedProvider { SelectedIds = new[] { "fallback" } }; + var c = new ChainedEngineStateProvider(p, f); + Assert.Equal(new[] { "a" }, c.GetSelectedIds()); + } + + [Fact] + public void Selection_Primary_Empty_Falls_Through() + { + var p = new ScriptedProvider(); + var f = new ScriptedProvider { SelectedIds = new[] { "fallback" } }; + var c = new ChainedEngineStateProvider(p, f); + Assert.Equal(new[] { "fallback" }, c.GetSelectedIds()); + } + + [Fact] + public void Camera_Primary_Default_Falls_Through() + { + var p = new ScriptedProvider(); + var fc = new CameraSnapshot(new double[] { 1, 2, 3 }, new double[] { 4, 5, 6 }, new double[] { 0, 0, 1 }, 60); + var f = new ScriptedProvider { Camera = fc }; + var c = new ChainedEngineStateProvider(p, f); + Assert.Equal(60.0, c.GetCamera().Fov); + } + + [Fact] + public void Camera_Primary_NonDefault_Wins() + { + var pc = new CameraSnapshot(new double[] { 1, 0, 0 }, new double[] { 0, 0, 0 }, new double[] { 0, 0, 1 }, 30); + var p = new ScriptedProvider { Camera = pc }; + var f = new ScriptedProvider(); + var c = new ChainedEngineStateProvider(p, f); + Assert.Equal(30.0, c.GetCamera().Fov); + } + + [Fact] + public void Scene_Primary_Empty_Falls_Through() + { + var p = new ScriptedProvider(); + var f = new ScriptedProvider { Scene = new(7, "x.hmeg") }; + var c = new ChainedEngineStateProvider(p, f); + var s = c.GetScene(); + Assert.Equal(7, s.ObjectCount); + Assert.Equal("x.hmeg", s.DocumentPath); + } + + [Fact] + public void Scene_Primary_Has_Path_Wins_Even_With_Zero_Objects() + { + var p = new ScriptedProvider { Scene = new(0, "primary.hmeg") }; + var f = new ScriptedProvider { Scene = new(99, "fallback.hmeg") }; + var c = new ChainedEngineStateProvider(p, f); + var s = c.GetScene(); + Assert.Equal("primary.hmeg", s.DocumentPath); + } + + [Fact] + public void Render_Primary_Always_Wins() + { + var p = new ScriptedProvider { Render = false }; + var f = new ScriptedProvider { Render = true }; + var c = new ChainedEngineStateProvider(p, f); + Assert.False(c.GetRenderComplete()); + } +} diff --git a/tests/Recordingtest.EgPlugin.Tests/ReflectionEngineStateProviderTests.cs b/tests/Recordingtest.EgPlugin.Tests/ReflectionEngineStateProviderTests.cs new file mode 100644 index 0000000..7e9e487 --- /dev/null +++ b/tests/Recordingtest.EgPlugin.Tests/ReflectionEngineStateProviderTests.cs @@ -0,0 +1,149 @@ +using Recordingtest.Bridge; +using Recordingtest.EgPlugin; +using Xunit; + +namespace Recordingtest.EgPlugin.Tests; + +public class ReflectionEngineStateProviderTests +{ + private sealed class FakeAccessor : IAppManagerAccessor + { + public object? AppManager { get; set; } = new object(); + public object? ActiveDocument { get; set; } + public object? ActiveViewport { get; set; } + public IReadOnlyList SelectedIds { get; set; } = Array.Empty(); + public int ObjectCount { get; set; } + public string? DocumentPath { get; set; } + public (double[] Eye, double[] Target, double[] Up, double Fov)? Camera { get; set; } + public bool ThrowOnSelection { get; set; } + public bool ThrowOnCamera { get; set; } + public bool ThrowOnScene { get; set; } + + public object? GetAppManager() => AppManager; + public object? GetActiveDocument() => ActiveDocument; + public object? GetActiveViewport() => ActiveViewport; + public IReadOnlyList GetSelectedIds() + { + if (ThrowOnSelection) throw new InvalidOperationException("boom"); + return SelectedIds; + } + public int GetObjectCount() + { + if (ThrowOnScene) throw new InvalidOperationException("boom"); + return ObjectCount; + } + public string? GetDocumentPath() + { + if (ThrowOnScene) throw new InvalidOperationException("boom"); + return DocumentPath; + } + public (double[] Eye, double[] Target, double[] Up, double Fov)? GetCameraTuple() + { + if (ThrowOnCamera) throw new InvalidOperationException("boom"); + return Camera; + } + } + + [Fact] + public void GetSelectedIds_Returns_Accessor_Values() + { + var fa = new FakeAccessor { SelectedIds = new[] { "a", "b" } }; + var p = new ReflectionEngineStateProvider(fa); + var ids = p.GetSelectedIds(); + Assert.Equal(new[] { "a", "b" }, ids); + } + + [Fact] + public void GetSelectedIds_Swallows_Exception_Returns_Empty() + { + var fa = new FakeAccessor { ThrowOnSelection = true }; + var p = new ReflectionEngineStateProvider(fa); + Assert.Empty(p.GetSelectedIds()); + } + + [Fact] + public void GetCamera_Returns_Accessor_Tuple() + { + var fa = new FakeAccessor + { + Camera = ( + Eye: new double[] { 1, 2, 3 }, + Target: new double[] { 4, 5, 6 }, + Up: new double[] { 0, 0, 1 }, + Fov: 60.0) + }; + var p = new ReflectionEngineStateProvider(fa); + var c = p.GetCamera(); + Assert.Equal(new double[] { 1, 2, 3 }, c.Eye); + Assert.Equal(new double[] { 4, 5, 6 }, c.Target); + Assert.Equal(60.0, c.Fov); + } + + [Fact] + public void GetCamera_Null_Tuple_Returns_Default() + { + var fa = new FakeAccessor { Camera = null }; + var p = new ReflectionEngineStateProvider(fa); + var c = p.GetCamera(); + Assert.Equal(45.0, c.Fov); + Assert.Equal(new double[] { 0, 0, 1 }, c.Up); + } + + [Fact] + public void GetCamera_Throwing_Accessor_Returns_Default_Not_Throw() + { + var fa = new FakeAccessor { ThrowOnCamera = true }; + var p = new ReflectionEngineStateProvider(fa); + var c = p.GetCamera(); + Assert.Equal(45.0, c.Fov); + } + + [Fact] + public void GetScene_Returns_Accessor_Values() + { + var fa = new FakeAccessor { ObjectCount = 42, DocumentPath = "C:/x.hmeg" }; + var p = new ReflectionEngineStateProvider(fa); + var s = p.GetScene(); + Assert.Equal(42, s.ObjectCount); + Assert.Equal("C:/x.hmeg", s.DocumentPath); + } + + [Fact] + public void GetScene_Throwing_Accessor_Returns_Empty_Default() + { + var fa = new FakeAccessor { ThrowOnScene = true }; + var p = new ReflectionEngineStateProvider(fa); + var s = p.GetScene(); + Assert.Equal(0, s.ObjectCount); + Assert.Null(s.DocumentPath); + } + + [Fact] + public void Default_Reflection_Accessor_Without_Hmeg_Returns_Null_Safely() + { + // CI process has no Editor.AppManager.AppManager type loaded; the + // accessor must return null/empty/default without throwing. + var acc = new ReflectionAppManagerAccessor(seedRoot: new object()); + Assert.Null(acc.GetAppManager()); + Assert.Null(acc.GetActiveDocument()); + Assert.Null(acc.GetActiveViewport()); + Assert.Empty(acc.GetSelectedIds()); + Assert.Equal(0, acc.GetObjectCount()); + Assert.Null(acc.GetDocumentPath()); + Assert.Null(acc.GetCameraTuple()); + } + + [Fact] + public void Provider_With_Default_Accessor_Without_Hmeg_Returns_Defaults() + { + // End-to-end fallback: provider built from a non-HmEG seed must + // produce the v2 safe defaults so /state continues to respond. + var p = new ReflectionEngineStateProvider(seedRoot: new object()); + Assert.Empty(p.GetSelectedIds()); + var c = p.GetCamera(); + Assert.Equal(45.0, c.Fov); + var s = p.GetScene(); + Assert.Equal(0, s.ObjectCount); + Assert.True(p.GetRenderComplete()); + } +} diff --git a/tests/Recordingtest.EgPlugin.Tests/StateRouterTests.cs b/tests/Recordingtest.EgPlugin.Tests/StateRouterTests.cs index 37e416c..121a7a0 100644 --- a/tests/Recordingtest.EgPlugin.Tests/StateRouterTests.cs +++ b/tests/Recordingtest.EgPlugin.Tests/StateRouterTests.cs @@ -1,4 +1,5 @@ using System.Net; +using Recordingtest.Bridge; using Recordingtest.EgPlugin; using Xunit;