Compare commits

...

6 Commits

Author SHA1 Message Date
minsung
9fe053619f docs: refresh README for 3-tier architecture
- 3-tier dependency direction diagram + architecture rule
- Module table reorganized by tier (Generic / HmEG-aware / EgBim)
- Milestones: first E2E, raw scenario E2E (#14), 3-tier split, 126 tests
- Gap status (A~H done, Gap I deferred with Player fallback strategy)
- Directory tree updated to reflect post-refactor layout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:06:50 +09:00
minsung
03fb504eea BREAKING: 3-tier split step 2 + engine-bridge v3 EgBim lambdas wired
Completes the Generic / HmEG-aware / App-specific separation started in
f6b6e74. The legacy EgPlugin / EngineBridge / EngineBridge.Client /
EngineBridge.Probe modules are moved into their proper tiers, namespaces
and csproj/sln entries are renamed, and the HmegDirectStateProvider
lambdas are finally populated with real handles from the EgBim plugin
host. A new Recordingtest.Architecture.Tests project enforces the tier
rule at build time.

Moves (git mv + csproj/RootNamespace/AssemblyName rename + sln):

  src/Recordingtest.EgPlugin
    -> src/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost
  src/Recordingtest.EngineBridge
    -> src/Hmeg/Recordingtest.Hmeg.Catalog
  src/Recordingtest.EngineBridge.Client
    -> src/Hmeg/Recordingtest.Hmeg.Bridge.Client
  src/Recordingtest.EngineBridge.Probe
    -> src/Hmeg/Recordingtest.Hmeg.Catalog.Probe

  tests/Recordingtest.EgPlugin.Tests
    -> tests/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost.Tests
  tests/Recordingtest.EngineBridge.Tests
    -> tests/Hmeg/Recordingtest.Hmeg.Catalog.Tests
  tests/Recordingtest.EngineBridge.IntegrationTests
    -> tests/Hmeg/Recordingtest.Hmeg.Catalog.IntegrationTests

Namespace rename applied across all .cs files and csproj RootNamespace:

  Recordingtest.EgPlugin           -> Recordingtest.Sut.EgBim.PluginHost
  Recordingtest.EngineBridge       -> Recordingtest.Hmeg.Catalog
  Recordingtest.EngineBridge.Client -> Recordingtest.Hmeg.Bridge.Client
  Recordingtest.EngineBridge.Probe -> Recordingtest.Hmeg.Catalog.Probe

New: tests/Recordingtest.Architecture.Tests/

  DependencyGraphTests walks Assembly.GetReferencedAssemblies() for each
  tier and fails if a forbidden reference leaks in:
    - Generic modules must not reference HmEG or any app-specific DLL
    - HmEG-aware modules must not reference app-specific DLLs
    - Recordingtest.Hmeg.Bridge must reference HmEG (positive check)
  11 tests, all passing. Prevents future drift from CLAUDE.md §8.1.

Engine-bridge v3 wire-up (HmEgBridgePlugin.BuildProvider):

  Previously the HmegDirectStateProvider lambdas returned null and the
  chain fell through to reflection. They now call directly into the
  EditorPlugin base class that HmEgBridgePlugin inherits:

    spaceProvider    = () => RootSpace
                           // AppManager.ViewportManager.RootSpace
    viewportProvider = () => View
                           // EGViewport : Control, HmEGViewport
    documentPathProvider = () => AppManager?.FileManager?.CurrentFile

  Every lambda is wrapped in try/catch so plugin construction still
  cannot throw back into the SUT. Editor02.HmEGAppManager.dll added as
  a reference on Recordingtest.Sut.EgBim.PluginHost.csproj — app-
  specific tier, which is allowed by the architecture tests.

Entry points were confirmed from read-only review of the SUT sources at
  D:\GiteaAll\EG-BIM_Modeler\EditorPluginInterface\EditorPlugin.cs
  D:\GiteaAll\EG-BIM_Modeler\HmEGApplicationManagementLibrary\HmEGAppManager.cs
  D:\GiteaAll\EG-BIM_Modeler\HmEGApplicationManagementLibrary\SubManager\FileManager.cs

closing out Q1/Q2/Q6/Q7 from docs/hmeg-api-survey.md.

Tests: 115 -> 126 (+11 Architecture), 0 failures.

Next step: live verification of /scene /camera /selection with a real
SUT session; any discrepancy in HmegDirectStateProvider reflection will
be tightened after observing real HmEG camera field names.

Ref: #10 follow-up, #14 follow-up, docs/contracts/generic-sut-split.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:39:13 +09:00
minsung
f6b6e7449e 3-tier split (step 1) + engine-bridge v3 scaffold + HmegDirectStateProvider
Lays down the Generic / HmEG-aware / App-specific separation that lets us
target other HmEG-hosting WPF applications later, and lands the v3 engine
state provider on top of it.

Architecture rule (CLAUDE.md §8.1, new): every module belongs to exactly one
of three tiers — Generic / HmEG-aware / App-specific (e.g. EgBim). Dependency
direction is strictly App-specific → HmEG-aware → Generic. Generic must not
reference HmEG.dll; HmEG-aware must not reference any per-app assembly.

This commit is the first incremental step:

  + src/Recordingtest.Bridge.Abstractions/  (Generic, new csproj)
      IEngineStateProvider, CameraSnapshot, SceneSnapshot,
      NullEngineStateProvider — extracted from EgPlugin so the generic core
      owns the contract. Zero SUT references.

  + src/Hmeg/Recordingtest.Hmeg.Bridge/      (HmEG-aware, new csproj)
      HmegDirectStateProvider — IEngineStateProvider implemented against
      the HmEG public API (Space, HmEGViewport, ISelectable, ModelBase.Uid).
      Decoupled from any specific host app via Func<Space?>/Func<HmEGViewport?>
      lambdas; the EgBim plugin host supplies them. Reusable for any other
      WPF application that hosts HmEG.

      Selection traversal walks Space.Children and collects ModelBase.Uid
      for nodes whose ISelectable.IsSelected is true. We deliberately type
      nodes as object + late-bound Uid lookup to avoid pulling MemoryPack
      into the dependency graph.

  + tests/Hmeg/Recordingtest.Hmeg.Bridge.Tests/
      5 unit tests covering null lambdas, throwing lambdas, document path
      provider, and constructor null arg validation.

  + src/Recordingtest.EgPlugin/ChainedEngineStateProvider.cs
      Wraps two providers; falls back from Hmeg.Direct to the existing
      Reflection accessor when the primary returns empty/default. Lets us
      land the new wire-up before the EgBim adapter Q1~Q7 lookups are
      filled in. 7 new tests.

  + src/Recordingtest.EgPlugin/IAppManagerAccessor.cs
      Reflection accessor abstraction (preserved as the v3 fallback). Looks
      up Editor.AppManager.AppManager via well-known Instance/Current
      property names. Unit-testable through a fake.

  ~ src/Recordingtest.EgPlugin/IEngineStateProvider.cs
      Type definitions removed (now in Bridge.Abstractions); only the
      reflection-based provider remains. ReflectionEngineStateProvider
      delegates everything to IAppManagerAccessor.

  ~ src/Recordingtest.EgPlugin/HmEgBridgePlugin.cs
      BuildProvider() picks ChainedEngineStateProvider(Hmeg.Direct,
      Reflection). The HmEG-aware lambdas are stubs (return null) until the
      next step wires the EgBim adapter; the chain falls through to the
      reflection path so behaviour matches v2 for now.

  + docs/contracts/engine-bridge-v3.md       — Sprint Contract
  + docs/contracts/generic-sut-split.md      — Sprint Contract for the
      remaining mass-rename / folder move (step 2, deferred).
  + docs/hmeg-api-survey.md                  — Read-only survey of the HmEG
      public API (Space, ModelBase, HmEGViewport, IHmCamera, IPlugin) used
      to design HmegDirectStateProvider. Open Q1~Q7 listed.

Tests: 94 → 115 passing, 0 failing. The new HmEG-aware test project copies
HmEG.dll next to its output (Private=true) since it runs out-of-process.

Step 2 (deferred to next session): mass-rename
  src/Recordingtest.EgPlugin → src/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost + .Adapter
  src/Recordingtest.EngineBridge → src/Hmeg/Recordingtest.Hmeg.Catalog
  src/Recordingtest.EngineBridge.Client → split (Generic + Hmeg)
plus Recordingtest.Architecture.Tests to enforce the §8.1 dependency rule.

Ref: #10 follow-up, #14 follow-up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:53:27 +09:00
minsung
a771352bcb recorder: focus poller PoC for Gap I-1 (deferred, #14)
Adds a background focus poller that periodically calls
Automation.FocusedElement() and stamps the path onto key_down RawEvents,
so DragCollapser can fill type-step targets without relying on the stale
post-hoc Resolve() pass. Plumbing:

  Program.cs   — focus poller Task + diagnostic counters
  LowLevelHook — volatile CurrentFocusedPath, stamped on key_down
  RawEvent     — FocusedElementPath already existed (focus_change)
  DragCollapser— typeFocusPath captured at first printable key_down,
                 takes precedence over lastFocusPath/lastMousePath

Result on box-v7.yaml live recording: null_target_steps unchanged (13).
Root cause: EG-BIM Modeler's CommandBox and similar input controls lack
AutomationPeer, so UIA-based focus tracking — from any external process —
cannot see them. The WPF-internal Keyboard.FocusedElement is in-process
only and unreachable from the recorder.

Deferred. The plumbing stays in place because the same stamping path can
be reused by a future generic WPF DLL-injection probe. Player's existing
null-target fallback (Type→OS focus, Click→raw_coord) remains the official
strategy and successfully replays box-v7 end-to-end.

See docs/history/2026-04-08_gap-i1-deferred.md for analysis and future
options (generic WPF injection / AutomationPeer AI attachment).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:49:08 +09:00
minsung
98d801442b player: active foreground wait replaces fixed 600ms sleep (#14)
BringSutToForeground() now polls GetForegroundWindow() == SUT hwnd at 25ms
intervals up to 2s, followed by a 100ms tail settle, instead of the brittle
fixed 600ms sleep. First-attempt replay of box-v6.yaml is now reliable
(previously dropped the opening "BOX" keystrokes on a cold start).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:39:05 +09:00
minsung
70bf5703b3 player: raw scenario replay without manual cleanup (#14)
First time box-v6.yaml (raw recorder output, 676 lines) replays end-to-end
and actually creates a Box in the SUT — no AI post-editing of target paths
or offsets required. This is the counterpart to #13's recorder-side fixes:
the player now absorbs the remaining record→replay gaps instead of demanding
a hand-cleaned scenario.

Changes (all in Recordingtest.Player):

- PlayerEngine: null-target fallbacks
  - Type with null target → host.Type() against current focus
  - Click with null target + raw_coord → click at screen-absolute raw_coord
  - Other null targets still skipped
- PlayerEngine: strip leading alt+tab hotkey steps (recording-startup noise
  that fights the player's own foreground switch)
- PlayerEngine: preserve recorded inter-step timing, clamped 150ms–3s,
  routed through new IPlayerHost.Delay so the engine itself stays Sleep-free
  (keeps the existing "no fixed sleep" DoD test passing)
- PlayerEngine: per-step console log for live debugging
- UiaPlayerHost: BringSutToForeground() — SetForeground + Focus + 600ms
  settle, called from Program.cs before engine.Run
- Step model: add RawCoord (int[]) and Ts (long?) fields, auto-mapped from
  YAML raw_coord / ts keys

Tests updated:
- PlayerEngine_NullTarget_SkipsWithoutCalling → _Fallback_Issue14
  (verifies the new Click-with-raw_coord and Type behavior)
- FakePlayerHost (both player.tests and runner.tests) implement Delay

Live smoke: box-v6.yaml raw replay produced the expected Box geometry on
the 2nd attempt; 1st attempt dropped the initial "BOX" keystrokes, tracked
as a follow-up (foreground settle is still threshold-sensitive at 600ms).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:26:41 +09:00
66 changed files with 2792 additions and 299 deletions

View File

@@ -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/<AppName>/` 하위, 네임스페이스 `Recordingtest.Sut.<AppName>.*` |
### 의존 방향 (단방향)
```
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/<NewApp>/
Recordingtest.Sut.<NewApp>.PluginHost/
Recordingtest.Sut.<NewApp>.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 <name>` 으로 Sprint Contract를 먼저 작성한다. DoD에 "분류 라벨", "의존 그래프 검증", "네임스페이스 규칙" 항목 필수.
## 10. 결정 로그 위치 ## 10. 결정 로그 위치
주요 기술 결정과 그 근거는 `docs/history/`와 Claude 메모리(`project_recordingtest_*`)에 분산 저장된다. 새 결정 시 반드시 둘 다 갱신한다. 주요 기술 결정과 그 근거는 `docs/history/`와 Claude 메모리(`project_recordingtest_*`)에 분산 저장된다. 새 결정 시 반드시 둘 다 갱신한다.

View File

@@ -10,8 +10,8 @@
## P1 — 라이브 검증 (사용자 환경 필요) ## P1 — 라이브 검증 (사용자 환경 필요)
4. **라이브 SUT smoke test 실행**`docs/guides/smoke-test.md` 따라 수동 수행 4. **engine-bridge v3 라이브 검증** — 코드 쪽 완료 (3-tier 분리 2단계 + EgBim 람다 실 매핑). SUT 환경에서 plugin 배치 후 `curl http://localhost:38080/scene /camera /selection`로 실값 확인. PerspectiveCamera cast로 Fov 추출 여부 검증.
5. **engine-bridge v3** — ReflectionEngineStateProvider 실매핑 (smoke test 이후) 5. ~~recorder Gap I-1~~**deferred**. UIA poller PoC 결과 본질적 한계 확인 (AutomationPeer 부재 컨트롤은 못 봄). generic WPF DLL injection 또는 AutomationPeer AI 부착 PoC가 선결.
## Follow-ups (non-blocking) ## Follow-ups (non-blocking)

View File

@@ -43,14 +43,16 @@
| 2026-04-07 | normalizer follow-ups + Evaluator pass — float epsilon 구성화 + JSON-path 마스크 스코핑, 77 tests | commit `eeee3c2` | | 2026-04-07 | normalizer follow-ups + Evaluator pass — float epsilon 구성화 + JSON-path 마스크 스코핑, 77 tests | commit `eeee3c2` |
| 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 | **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 | 이슈 #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 | commit `f6b6e74` |
| 2026-04-09 | **3-tier 분리 2단계**`EgPlugin``Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost`, `EngineBridge``Hmeg/Recordingtest.Hmeg.Catalog`, `EngineBridge.Client``Hmeg/Recordingtest.Hmeg.Bridge.Client`, `EngineBridge.Probe``Hmeg/Recordingtest.Hmeg.Catalog.Probe`, 테스트 동일. `Recordingtest.Architecture.Tests` 11건 추가 (의존 그래프 강제). 126 tests | commit pending |
| 2026-04-09 | **engine-bridge v3 EgBim 람다 wire-up**`EditorPlugin` base 직접 사용: `RootSpace`, `View(EGViewport:HmEGViewport)`, `AppManager.FileManager.CurrentFile`. `HmegDirectStateProvider` 이제 실값 가능. `Editor02.HmEGAppManager.dll` 참조 추가. 라이브 검증 대기 | commit pending |
## In progress ## In progress
_(없음 — Smoke 2회차 라이브 검증 대기)_ - **engine-bridge v3 라이브 검증 대기** — 코드 쪽은 완료 (`EditorPlugin.RootSpace`/`View`/`AppManager.FileManager.CurrentFile` 실 매핑). 사용자 환경에서 `curl /scene /camera /selection`로 실값 확인 필요.
## In progress
_(없음)_
## Follow-ups ## Follow-ups
@@ -62,6 +64,8 @@ _(없음)_
- [ ] player: `wait_for` UIA 이벤트 매핑 강화 (현재 host passthrough). - [ ] player: `wait_for` UIA 이벤트 매핑 강화 (현재 host passthrough).
- [ ] player: `UiaPlayerHost` uia_path resolver가 마지막 `@AutomationId`만 사용 — 전체 ancestor chain 지원 필요. - [ ] player: `UiaPlayerHost` uia_path resolver가 마지막 `@AutomationId`만 사용 — 전체 ancestor chain 지원 필요.
- [ ] recorder: IME 조합 키 처리 (contract risks). - [ ] recorder: IME 조합 키 처리 (contract risks).
- [x] ~~player: foreground settle 안정화~~ — 능동 대기(`GetForegroundWindow` polling 2s + 100ms settle)로 전환, 1차 재생 성공 확인
- [~] recorder Gap I-1 — UIA `Automation.FocusedElement` 폴링 PoC 시도(commit pending). 결과: SUT의 CommandBox 등 AutomationPeer 미부착 컨트롤은 UIA 외부에서 본질적으로 못 봄. **deferred** — generic WPF DLL injection 또는 SUT-side AutomationPeer 부착 PoC가 필요. 현재는 Player fallback(null target → OS 키 입력 / raw_coord 클릭)이 공식 전략.
## Blocked ## Blocked

View File

@@ -12,18 +12,45 @@
[회귀 시점] → 입력 리플레이 → 결과 파일 B → normalize → diff(A, B) [회귀 시점] → 입력 리플레이 → 결과 파일 B → normalize → diff(A, B)
``` ```
## 3계층 아키텍처
본 도구는 **EG-BIM Modeler 외 다양한 WPF 응용**을 대상으로 하고, 사용자 WPF 응용군 대다수가 자체 3D 엔진 **HmEG**를 공유한다. 따라서 코드는 엄격히 3계층으로 분리된다 (의존 방향 단방향, 자세한 규칙은 [CLAUDE.md §8.1](CLAUDE.md)):
```
App-specific (e.g. EgBim) ──→ HmEG-aware ──→ Generic
(특정 앱만) (HmEG 호스팅 앱 공통) (임의 WPF 앱)
```
`Recordingtest.Architecture.Tests``Assembly.GetReferencedAssemblies()` 검사로 빌드 시점에 규칙을 강제한다. 새 SUT를 추가할 때는 App-specific 계층에 플러그인 진입점 + 어댑터만 작성하고 Generic + HmEG-aware 전부 재사용.
## 모듈 구성 ## 모듈 구성
### Generic tier — 임의 WPF 응용
| 모듈 | 책임 | 상태 | | 모듈 | 책임 | 상태 |
|------|------|------| |------|------|------|
| [Recordingtest.Bridge.Abstractions](src/Recordingtest.Bridge.Abstractions/) | `IEngineStateProvider`/`CameraSnapshot`/`SceneSnapshot` 등 SUT-중립 인터페이스 | ✓ |
| [Recordingtest.SutProber](src/Recordingtest.SutProber/) | SUT 정적 probe (plugin/Json/assembly 카탈로그) | PoC pass | | [Recordingtest.SutProber](src/Recordingtest.SutProber/) | SUT 정적 probe (plugin/Json/assembly 카탈로그) | PoC pass |
| [Recordingtest.Recorder](src/Recordingtest.Recorder/) | 입력 캡처 (UIA element path + offset + 키/마우스/포커스) | PoC pass | | [Recordingtest.Recorder](src/Recordingtest.Recorder/) | 입력 캡처 (UIA element path + offset + 키/마우스/포커스) | PoC pass |
| [Recordingtest.Player](src/Recordingtest.Player/) | 시나리오 재생, 비동기 동기화 | PoC pass | | [Recordingtest.Player](src/Recordingtest.Player/) | 시나리오 재생, 비동기 동기화, null-target fallback | PoC pass |
| [Recordingtest.Normalizer](src/Recordingtest.Normalizer/) | 결과 파일 정규화 (timestamp/GUID/path/float/order) | PoC pass | | [Recordingtest.Normalizer](src/Recordingtest.Normalizer/) | 결과 파일 정규화 (timestamp/GUID/path/float/order) | PoC pass |
| [Recordingtest.DiffReporter](src/Recordingtest.DiffReporter/) | approved vs received diff 리포트 | PoC pass | | [Recordingtest.DiffReporter](src/Recordingtest.DiffReporter/) | approved vs received diff 리포트 | PoC pass |
| [Recordingtest.EngineBridge.Client](src/Recordingtest.EngineBridge.Client/) + [Recordingtest.EgPlugin](src/Recordingtest.EgPlugin/) | HmEG 내부 상태 sidecar (MEF plugin masquerade + HttpListener) | v2 pass |
| [Recordingtest.Runner](src/Recordingtest.Runner/) | 5-모듈 E2E 파이프라인 + 실패 triage | PoC pass | | [Recordingtest.Runner](src/Recordingtest.Runner/) | 5-모듈 E2E 파이프라인 + 실패 triage | PoC pass |
### HmEG-aware tier — HmEG 호스팅 앱 공통
| 모듈 | 책임 | 상태 |
|------|------|------|
| [Recordingtest.Hmeg.Bridge](src/Hmeg/Recordingtest.Hmeg.Bridge/) | `HmegDirectStateProvider` — HmEG 공개 API(Space/HmEGViewport/ISelectable)로 상태 읽기. 앱에 람다로 진입점 주입 | v3 wired |
| [Recordingtest.Hmeg.Catalog](src/Hmeg/Recordingtest.Hmeg.Catalog/) | HmEG 정적 분석 카탈로그 | PoC pass |
| [Recordingtest.Hmeg.Bridge.Client](src/Hmeg/Recordingtest.Hmeg.Bridge.Client/) | `/scene` `/camera` `/selection` HTTP 클라이언트 | v2 pass |
### App-specific tier — 현재 SUT: EG-BIM Modeler
| 모듈 | 책임 | 상태 |
|------|------|------|
| [Recordingtest.Sut.EgBim.PluginHost](src/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost/) | MEF 진입점(`EditorPlugin` 상속), `BridgeHttpServer` 부팅, `HmegDirectStateProvider` 람다에 `RootSpace`/`View`/`AppManager.FileManager.CurrentFile` 주입 | v3 wired |
## 작업 사이클 — Planner / Generator / Evaluator ## 작업 사이클 — Planner / Generator / Evaluator
Anthropic harness design 원칙을 채택. 같은 에이전트가 생성과 평가를 겸하지 않는다. Anthropic harness design 원칙을 채택. 같은 에이전트가 생성과 평가를 겸하지 않는다.
@@ -39,20 +66,52 @@ Anthropic harness design 원칙을 채택. 같은 에이전트가 생성과 평
- **시나리오 포맷**: YAML/JSON (git diff 친화적) - **시나리오 포맷**: YAML/JSON (git diff 친화적)
- **베이스라인**: `*.approved.{ext}` / `*.received.{ext}` - **베이스라인**: `*.approved.{ext}` / `*.received.{ext}`
## 주요 이정표
- **2026-04-08 — 첫 E2E 성공** 🎉 Smoke 2회차에서 수동 테스트 시나리오 → 재생 → SUT에 Box geometry 생성 확인 (box-v5-clean.yaml).
- **2026-04-08 — Raw 시나리오 E2E 성공** 🎉 수동 cleanup 없이 recorder 원본 `box-v6.yaml`이 그대로 재생되어 Box 생성 (이슈 #14). Player에 null-target fallback, SUT foreground 능동 대기, 선두 alt+tab 노이즈 자동 skip, 스텝 간 타이밍 보존 추가.
- **2026-04-09 — 3-tier 분리 완료** — Generic / HmEG-aware / App-specific 물리적 분리, `Recordingtest.Architecture.Tests`가 의존 그래프 강제. 같은 날 engine-bridge v3 EgBim 람다 실 wire-up (코드 쪽 완료, 라이브 검증 대기).
- **126 단위 테스트** green (Recorder/Player/Normalizer/DiffReporter/Runner/Hmeg.* /Sut.EgBim.* /Architecture)
자세한 과정은 [docs/history/](docs/history/), 결정 근거는 [docs/contracts/](docs/contracts/) + [docs/hmeg-api-survey.md](docs/hmeg-api-survey.md) 참고.
## Gap 현황 (주요)
- **Gap A~H** (smoke 1~2회차) — 전부 fix (#11/#12/#13/#14)
- **Gap I (recorder root-cause)** — EG-BIM Modeler의 CommandBox 등 핵심 입력 컨트롤이 AutomationPeer를 노출하지 않아 UIA 외부에서 본질적으로 식별 불가. **deferred**. 현재는 Player null-target fallback(Type→OS 포커스 / Click→raw_coord)이 공식 우회 전략. 근본 해소는 generic WPF DLL injection 또는 SUT-side AutomationPeer 부착 PoC가 선결.
## 디렉터리 ## 디렉터리
``` ```
recordingtest/ recordingtest/
├── src/ # 모듈별 C# 프로젝트 ├── src/
├── scenarios/ # 시나리오 YAML │ ├── Recordingtest.Bridge.Abstractions/ # Generic — 인터페이스
│ ├── Recordingtest.Recorder/ # Generic
│ ├── Recordingtest.Player/ # Generic
│ ├── Recordingtest.Normalizer/ # Generic
│ ├── Recordingtest.DiffReporter/ # Generic
│ ├── Recordingtest.Runner/ # Generic
│ ├── Recordingtest.SutProber/ # Generic
│ ├── Hmeg/
│ │ ├── Recordingtest.Hmeg.Bridge/ # HmEG-aware
│ │ ├── Recordingtest.Hmeg.Catalog/ # HmEG-aware
│ │ ├── Recordingtest.Hmeg.Bridge.Client/ # HmEG-aware
│ │ └── Recordingtest.Hmeg.Catalog.Probe/ # HmEG-aware CLI
│ └── Sut/
│ └── EgBim/
│ └── Recordingtest.Sut.EgBim.PluginHost/ # App-specific
├── tests/ # 같은 계층 구조로 미러링 + Architecture.Tests
├── scenarios/ # 시나리오 YAML (box-v*.yaml)
├── docs/ ├── docs/
│ ├── contracts/ # Sprint Contracts + evaluations │ ├── contracts/ # Sprint Contracts + evaluations
│ ├── history/ # 작업 히스토리 │ ├── history/ # 작업 히스토리
│ ├── sut-catalog/ # sut-prober 산출물 │ ├── sut-catalog/ # sut-prober 산출물
── guides/ # smoke test, deploy 가이드 ── engine-catalog/ # HmEG 후보 카탈로그 (정적 분석)
├── CLAUDE.md # 에이전트 운영 지침 │ ├── hmeg-api-survey.md # HmEG public API 조사 메모
├── PROGRESS.md # 완료 상태 │ └── guides/ # smoke test, deploy 가이드
── PLAN.md # 우선순위 큐 ── CLAUDE.md # 에이전트 운영 지침 + §8.1 3-tier 규칙
├── PROGRESS.md # 완료 상태 (세션 간 공유 메모리)
└── PLAN.md # 우선순위 큐
``` ```
## 저장소 ## 저장소

View File

@@ -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)에 시간 쏠릴 가능성.

View File

@@ -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<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);
```
```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회 → 자동 중단.

View File

@@ -0,0 +1,44 @@
# 2026-04-08 — Gap I-1 (recorder focus poller) deferred
**소요 시간**: ~45분
**Context 사용량**: ~25k tokens (Opus 4.6, 동일 세션 누적)
## 시도
issue #14의 recorder Gap I-1: type 스텝 target이 항상 null로 남는 문제를 root에서 풀기 위해 시도.
접근:
1. 백그라운드 Task가 100ms 주기로 `automation.FocusedElement()` 폴링
2. 결과 path를 `LowLevelHook.CurrentFocusedPath` volatile 필드에 저장
3. `KeyboardProc`가 key_down RawEvent 생성 시 이 필드를 `FocusedElementPath`에 stamp
4. `DragCollapser`가 type burst 시작 시 `typeFocusPath` 로컬에 캡처, FlushType fallback에서 우선 사용
5. 진단 카운터 추가 (success/null_focus/wrong_pid/errors/last_path)
## 결과
box-v7.yaml 녹화 → `null_target_steps=13` (이전 12와 사실상 동일). type 스텝의 `target:` 여전히 비어있음.
## 원인
UIA `FocusedElement()`**AutomationPeer가 부착된 컨트롤만** 보고할 수 있다. EG-BIM Modeler의 CommandBox 등 핵심 입력 컨트롤은 AutomationPeer가 없어서 UIA 트리에 의미 있게 노출되지 않음. 외부 프로세스에서 어떤 UIA API를 호출해도 동일한 한계.
WPF의 진짜 포커스(`Keyboard.FocusedElement`)는 **in-process API**라 외부 recorder에서는 직접 호출 불가.
## 결정
**Gap I-1 deferred.** 현재는 Player fallback이 공식 전략:
- Type with null target → OS 레벨 키보드 입력 (SUT의 WPF가 자기 포커스 컨트롤로 라우팅)
- Click with null target + raw_coord → 화면 절대좌표 클릭
이 fallback으로 box-v6/v7 모두 E2E 재생 성공 확인됨. 결정성/진단성은 떨어지지만 실행 자체엔 충분.
## 향후 옵션 (선결 PoC 필요)
1. **Generic WPF DLL injection** — CreateRemoteThread + LoadLibrary로 임의 WPF 프로세스에 probe DLL 주입, Dispatcher 위에서 `Keyboard.FocusedElement` 읽어 named pipe로 노출. 권한 이슈 있음.
2. **AutomationPeer AI 부착 PoC** (메모리 `project_recordingtest_automationpeer_ai.md`) — SUT fork에 AI로 AutomationPeer 자동 부착하는 별도 PR 트랙. SUT 협조 필요.
둘 다 본 이슈 범위를 벗어나므로 별도 트랙. PLAN.md에서 제거됨.
## 남긴 코드
진단 가능한 형태로 commit (revert하지 않음). 향후 generic injection PoC가 들어올 때 같은 stamping 메커니즘(`LowLevelHook.CurrentFocusedPath``RawEvent.FocusedElementPath``DragCollapser.typeFocusPath`) 그대로 재사용 가능.

View File

@@ -0,0 +1,20 @@
# Gitea MCP 접근 경로 Q&A
**소요 시간**: 5분
**Context 사용량**: input ~30k / output ~1k tokens
**이슈**: #0
## 요약
- 사용자 질문: "gitea 엑세스 어떻게 하고 있지. 다른 프로젝트에서는 gitea mcp가 안 된다."
- 확인 결과: Gitea MCP는 글로벌 user scope (`C:\Users\nbright\.claude.json`의 최상위 `mcpServers.gitea`)에 등록됨.
- command: `C:/Users/nbright/bin/gitea-mcp.exe -t stdio`
- env: `GITEA_HOST=https://gitea.hmac.kr`, `GITEA_ACCESS_TOKEN=6f6147...`
- 본 프로젝트에서 바로 쓸 수 있었던 이유: `.claude/settings.json``permissions.allow``mcp__gitea__*` 명시.
## 다른 프로젝트에서 안 되는 원인 후보
1. 프로젝트별 MCP 승인 미처리 (`/mcp`로 enable 필요)
2. `disabledMcpjsonServers`에 gitea 포함 또는 `enableAllProjectMcpServers: false`
3. 프로젝트 로컬 `.mcp.json`이 잘못된 gitea 정의로 덮어씀
4. `permissions.allow``mcp__gitea__*` 누락 → 매번 허용 프롬프트

View File

@@ -0,0 +1,70 @@
# 2026-04-08 — 이슈 #14 Raw 시나리오 E2E 성공
**이슈**: #14 Raw 레코딩 시나리오를 수동 cleanup 없이 재생 가능하게
**소요 시간**: ~90분
**Context 사용량**: ~60k tokens (Opus 4.6)
## 결과
🎉 **`scenarios/box-v6.yaml` 원본(AI 후처리 없음)** → Player 재생 → **SUT에 Box geometry 생성 확인**.
이전 box-v5-clean.yaml E2E 성공은 AI가 focus 이벤트 40+개 제거, target UIA 경로 리타기팅(ItemsControl→CommandBox), 뷰포트 offset 박음질 등 수작업 후편집의 결과였다. 이번 작업으로 **수작업 없이 recorder 원본을 그대로 재생**하는 경로가 처음으로 열렸다.
## 문제 분해
레코더 원본은 다음 4가지 이유로 재생 불가였다:
1. **Type/Click null target** — recorder가 key/mouse 이벤트 발생 시 focused element를 resolve 못 해 `target: null`로 저장. Player는 "#11에서 null skip" 정책이라 아예 실행 안 함.
2. **Player 실행 시 포커스**`dotnet run` 한 PowerShell이 foreground라 첫 type("BOX")이 PowerShell로 들어감.
3. **선두 alt+tab 노이즈** — 녹화 시작 시 사용자가 에디터→SUT로 전환하려던 alt+tab 2개가 재생 시 SUT를 오히려 off-foreground로 보냄.
4. **스텝 간 타이밍 없음** — 엔진이 즉시 연속 실행 → SUT가 BOX 명령 → 모서리 픽 전환할 틈 없음.
## 구현 (Player 쪽 포스트프로세싱)
### 1. Null-target fallback (PlayerEngine)
- `Type + null target` → 현재 포커스로 그대로 `host.Type()` (SUT CommandBox 포커스 가정)
- `Click + null target + raw_coord` → screen-absolute 좌표로 직접 `host.Click()`
- 기타 null target → 여전히 skip (안전)
- `Step.RawCoord: int[]?` 추가, YAML `raw_coord` 자동 매핑
### 2. SUT foreground 강제 (UiaPlayerHost.BringSutToForeground)
- `_app.GetMainWindow().SetForeground() + Focus()`
- 600ms settle (150 → 600으로 상향; 초기 char 드롭 관찰 후)
- Program.cs가 engine.Run 이전에 1회 호출
### 3. 선두 alt+tab 자동 skip (PlayerEngine.Run)
- 녹화 startup 노이즈 제거. SUT가 이미 foreground인 상태에서 alt+tab 실행은 오히려 유해.
### 4. 스텝 간 타이밍 복원 (PlayerEngine + IPlayerHost.Delay)
- `Step.Ts: long?` 추가
- `PlayerEngineOptions.PreserveTiming` (기본 on)
- `ts_i - ts_{i-1}` 를 150ms~3s로 클램프해 host.Delay 호출
- **엔진 내부 `Thread.Sleep` 금지 DoD를 유지하기 위해** 딜레이는 `IPlayerHost.Delay`로 위임. `UiaPlayerHost`만 실제 sleep, `FakePlayerHost`는 기록만.
- 첫 스텝도 MinStepDelay 받도록 prevTs 시드
### 5. 스텝 로그
- `[player] step {i} kind={kind} value={value}` — 라이브 디버깅용
## 테스트
- `PlayerEngine_NullTarget_SkipsWithoutCalling``PlayerEngine_NullTarget_Fallback_Issue14`로 교체
- click(null+raw_coord) → clicks[0] 검증, type(null+value) → types[0] 검증
- click(null, no raw_coord), drag(null) → 여전히 skip
- 전체 suite green: 94+ tests (Player 24, Runner 6, Recorder 26, Normalizer 16, ...)
## 라이브 검증 (사용자 환경)
```
dotnet run --project src\Recordingtest.Player -- --scenario scenarios\box-v6.yaml --output-dir artifacts\replay-v6-raw --no-launch
```
첫 시도는 BOX 타이핑이 누락 (`[player] step 2 kind=Type value=BOX` 로그는 찍혔지만 SUT command box에 안 들어감). 두 번째 시도에서 Box geometry 생성 성공.
## 남은 과제 (PLAN.md P1에 등록)
- **foreground settle 경계선 문제** — 600ms가 가끔 부족. `SetForegroundWindow` 후 능동 대기(`GetForegroundWindow == sut_hwnd` polling) 또는 첫 type 이전에 Keyboard warm-up 필요.
- **recorder Gap I-1** — null_target_steps를 근본적으로 줄이려면 recorder가 key_down 시점에 `Automation.FocusedElement`를 직접 쿼리해서 typeRes를 채워야 함. 현재는 player fallback으로 우회 중.
## 관련 커밋
- (pending) Player null-target fallback + foreground + alt+tab strip + timing preservation

View File

@@ -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<HmEG.Space?>`, `Func<HmEG.HmEGViewport?>`, `Func<string?>?`
- `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`

View File

@@ -0,0 +1,138 @@
# 2026-04-09 — 3-tier 분리 2단계 + engine-bridge v3 EgBim 람다 wire-up
**이슈**: #10 follow-up (engine-bridge v3) + `docs/contracts/generic-sut-split.md`
**소요 시간**: ~110분 (새 세션 / 동일 날짜 두 번째 블록)
**Context 사용량**: input ~80k / output ~18k tokens (Opus 4.6, 1M context, 새 세션)
## 작업
### 1. 3-tier 분리 2단계 (mass-rename + move)
기존 EgPlugin/EngineBridge 모듈을 새 계층 폴더로 이동하고 네임스페이스를 일괄 rename.
**소스 이동 (git mv)**:
| 원본 | 대상 | 계층 |
|---|---|---|
| `src/Recordingtest.EgPlugin/` | `src/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost/` | App-specific |
| `src/Recordingtest.EngineBridge/` | `src/Hmeg/Recordingtest.Hmeg.Catalog/` | HmEG-aware |
| `src/Recordingtest.EngineBridge.Client/` | `src/Hmeg/Recordingtest.Hmeg.Bridge.Client/` | HmEG-aware |
| `src/Recordingtest.EngineBridge.Probe/` | `src/Hmeg/Recordingtest.Hmeg.Catalog.Probe/` | HmEG-aware |
| `tests/Recordingtest.EgPlugin.Tests/` | `tests/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost.Tests/` | App-specific |
| `tests/Recordingtest.EngineBridge.Tests/` | `tests/Hmeg/Recordingtest.Hmeg.Catalog.Tests/` | HmEG-aware |
| `tests/Recordingtest.EngineBridge.IntegrationTests/` | `tests/Hmeg/Recordingtest.Hmeg.Catalog.IntegrationTests/` | HmEG-aware |
**네임스페이스 rename**:
- `Recordingtest.EgPlugin``Recordingtest.Sut.EgBim.PluginHost`
- `Recordingtest.EngineBridge``Recordingtest.Hmeg.Catalog`
- `Recordingtest.EngineBridge.Client``Recordingtest.Hmeg.Bridge.Client`
- `Recordingtest.EngineBridge.Probe``Recordingtest.Hmeg.Catalog.Probe`
**csproj rename** + `<RootNamespace>` / `<AssemblyName>` + ProjectReference 경로 갱신 + `recordingtest.sln` 에서 remove/add.
**문제/해결**:
- `git mv`가 bin/obj 폴더 때문에 일부 실패 → 해당 폴더 삭제 후 재시도
- `EngineBridge.Client` 전체 폴더 이동이 계속 permission denied → 파일 단위로 `git mv`해서 해결
- `Directory.Build.props`의 자동 RootNamespace와 csproj의 수동 RootNamespace 정리 (Sut.EgBim.PluginHost는 수동, Hmeg.Catalog는 수동 덮어쓰기)
### 2. Architecture Tests 추가
`tests/Recordingtest.Architecture.Tests/` — 3-tier 규칙 강제.
- Generic 모듈 (`Bridge.Abstractions`, `Recorder`, `Player`, `Normalizer`, `DiffReporter`, `Runner`, `SutProber`) 각각이 `HmEG.dll` 또는 `Editor03.PluginInterface` / `Editor02.HmEGAppManager` / `EditorCore`**참조하지 않음** (Theory, 7건)
- HmEG-aware 모듈 (`Hmeg.Bridge`, `Hmeg.Catalog`, `Hmeg.Bridge.Client`) 각각이 app-specific DLL을 **참조하지 않음** (3건)
- `Hmeg.Bridge``HmEG.dll`**참조함** (positive check, 1건)
총 11건, 모두 pass. 향후 누가 실수로 generic 모듈에 HmEG 참조를 추가하면 여기서 red.
### 3. engine-bridge v3 EgBim 람다 wire-up (Q1~Q7 답)
사용자가 `D:\GiteaAll\EG-BIM_Modeler\EditorPluginInterface` 경로 공유 → read-only survey.
**확정 (single-file find)**: `EditorPluginInterface/EditorPlugin.cs`
```csharp
public HmEGAppManager AppManager { get => TriggerStateService.AppManager; set; }
public Space RootSpace { get => AppManager.ViewportManager.RootSpace; }
public ViewportManager ViewportManager { get => AppManager.ViewportManager; }
public EGViewport View { get; set; } // deprecated but still usable
```
**HmEGAppManager** (`D:\GiteaAll\EG-BIM_Modeler\HmEGApplicationManagementLibrary\HmEGAppManager.cs`):
- `ViewportManager` — RootSpace 진입점
- `SelectionManager` — 중앙 선택 상태 (필요 시 hook, 현재는 walk)
- `FileManager` — 저장 파일 경로
- `AppModeManager` — 명령 lifecycle (Q4 후속)
**`EGViewport : Control, HmEGViewport`** (`HmEG/Controls/HmEGViewport.cs:43`) — 그대로 HmEG-aware provider에 넘길 수 있음.
**`FileManager.CurrentFile : string`** — 저장 문서 경로.
**Q 답 매핑**:
- Q1 Space: `this.RootSpace`
- Q2 Viewport: `this.View` (EGViewport: HmEGViewport) ✅
- Q3 Selection: 중앙 리스트 없음. Space walk + `ISelectable.IsSelected` (이미 구현됨)
- Q4 Command lifecycle: `AppModeManager`, `TriggerStateService.TriggerEnded` (별도 contract)
- Q5 Fov: `PerspectiveCamera` cast, `HmegDirectStateProvider.GetCamera`의 reflection `FieldOfView` 후보 chain이 이미 잡음
- Q6 DocumentPath: `AppManager.FileManager.CurrentFile`
- Q7 EGViewport↔HmEGViewport: `EGViewport : Control, HmEGViewport`
**구현**: `HmEgBridgePlugin.BuildProvider()`가 이제 실 람다 주입:
```csharp
Func<Space?> spaceProvider = () => { try { return RootSpace; } catch { return null; } };
Func<HmEGViewport?> viewportProvider = () => { try { return View; } catch { return null; } };
Func<string?> documentPathProvider = () =>
{
try { var p = AppManager?.FileManager?.CurrentFile; return string.IsNullOrEmpty(p) ? null : p; }
catch { return null; }
};
```
**csproj 추가 참조**: `Editor02.HmEGAppManager.dll``HmEGAppManager.FileManager.CurrentFile` 접근을 위해. app-specific tier이므로 허용 (ArchitectureTests는 이 tier를 검사하지 않음).
### 테스트
- 전체 suite: **126 tests pass** (115 → 126, +11 ArchitectureTests)
- 구성: Recorder 26 / Player 24 / Sut.EgBim.PluginHost 21 / Normalizer 16 / Architecture 11 / DiffReporter 5 / Runner 6 / Hmeg.Catalog 6 / Hmeg.Catalog.Integration 6 / Hmeg.Bridge 5
- 빌드/테스트 0 failures
### 분류 라벨 (2단계 완료 후 현재)
| 경로 | 계층 |
|---|---|
| `src/Recordingtest.Bridge.Abstractions/` | Generic |
| `src/Recordingtest.Recorder/`, `.Player/`, `.Normalizer/`, `.DiffReporter/`, `.Runner/`, `.SutProber/`, `.DiffReporter.Cli/` | Generic |
| `src/Hmeg/Recordingtest.Hmeg.Bridge/`, `.Catalog/`, `.Catalog.Probe/`, `.Bridge.Client/` | HmEG-aware |
| `src/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost/` | App-specific (EgBim) |
## 라이브 검증 (대기)
다음 세션의 P1:
1. 본 작업물을 빌드해 `EG-BIM Modeler/Plugins/Recordingtest.Sut.EgBim.PluginHost/` 아래 배포
2. SUT 실행, 이미 열린 .hmeg 문서가 있으면 Box 등 몇 개 객체 생성 + 선택
3. 셸에서:
```
curl http://localhost:38080/health
curl http://localhost:38080/scene # object_count, document_path
curl http://localhost:38080/camera # eye/target/up/fov 실값
curl http://localhost:38080/selection # selected_ids
```
4. 실값이 기대와 다르면 `HmegDirectStateProvider.GetCamera`의 reflection 멤버 후보 또는 selection walk 로직 보정 1~2회 반복
## 미커밋 (다음 커밋에 통합)
- 3-tier 분리 2단계 전체 (수많은 파일 rename/move)
- `Recordingtest.Architecture.Tests` 신규
- `HmEgBridgePlugin.BuildProvider` 실 람다
- `Editor02.HmEGAppManager.dll` 참조 추가
- `recordingtest.sln`, `PROGRESS.md`, `PLAN.md`, 본 history
## 관련
- `CLAUDE.md §8.1`
- `docs/contracts/generic-sut-split.md`
- `docs/hmeg-api-survey.md`
- `D:\GiteaAll\EG-BIM_Modeler\EditorPluginInterface\EditorPlugin.cs` (read-only)
- `D:\GiteaAll\EG-BIM_Modeler\HmEGApplicationManagementLibrary\HmEGAppManager.cs` (read-only)
- `D:\GiteaAll\EG-BIM_Modeler\HmEGApplicationManagementLibrary\SubManager\FileManager.cs` (read-only)

View File

@@ -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:
확인된 공개 타입 (`<public-hmeg-api />` 마커 기준):
- `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` (신규)

View File

@@ -0,0 +1,24 @@
# 2026-04-09 — README 갱신 + 누적 커밋 push
**이슈**: 없음 (세션 마무리 문서 작업)
**소요 시간**: ~5분
**Context 사용량**: input ~15k / output ~3k tokens (Opus 4.6)
## 작업
- `README.md`를 3-tier 아키텍처 반영해 전면 갱신
- 3계층(Generic / HmEG-aware / App-specific) 의존 방향 다이어그램 + 강제 규칙
- 모듈 표를 계층별로 재구성 (12개 csproj, 이동 후 경로)
- 주요 이정표 섹션: 2026-04-08 첫 E2E, Raw 시나리오 E2E, 2026-04-09 3-tier 분리, 126 tests
- Gap 현황 (A~H 완료, Gap I deferred + Player fallback 전략)
- 디렉터리 트리를 실제 3-tier 구조로 갱신
- 누적 5개 커밋을 origin으로 push:
- `70bf570` player: raw scenario replay without manual cleanup (#14)
- `98d8014` player: active foreground wait
- `a771352` recorder: focus poller PoC for Gap I-1 (deferred)
- `f6b6e74` 3-tier split (step 1) + engine-bridge v3 scaffold
- `03fb504` BREAKING: 3-tier split step 2 + engine-bridge v3 EgBim lambdas wired
## 관련
- `README.md`

205
docs/hmeg-api-survey.md Normal file
View File

@@ -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 소스다. 참조만.
## 식별 마커 — `<public-hmeg-api />`
HmEG는 `<public-hmeg-api />` 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<OnChildModelChangedArgs>` | 자식 변경 알림 — wait_for 후보 |
### 2. `Space : ModelBase` — 문서 컨테이너 (= "Space" 트리의 루트)
경로: `Model\Scene\ModelData\Space.cs`, `Space.Functions.cs`
| 멤버 | 타입 | 비고 |
|---|---|---|
| `Children` | `EgObservableFastList<ModelBase>` | **scene 트리 자식 노드** (= 객체 리스트) |
| `ItemsCount` | `int` | **`/scene` 의 ObjectCount 가 직접 매핑** |
| `Viewports` | `List<EGViewport>` | 이 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<SceneNode>` | 3D 씬 그래프 루트 노드 enumerable |
| `D2DRenderables` | `IEnumerable<SceneNode2D>` | 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<HmEG.HmEGViewport?> _getViewport; // plugin이 주입
private readonly Func<Space?> _getSpace; // 동일
public IReadOnlyList<string> GetSelectedIds()
{
var sp = _getSpace();
if (sp is null) return Array.Empty<string>();
var ids = new List<string>();
Walk(sp, ids);
return ids;
static void Walk(ModelBase node, List<string> 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 소스 사본을 보관하지 **않는다**. 본 문서는 외부 소스에 대한 인터페이스 추출 메모일 뿐. 코드 발췌도 시그니처/주석 수준으로만 인용한다.

View File

@@ -27,19 +27,35 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Runner", "src
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Runner.Tests", "tests\Recordingtest.Runner.Tests\Recordingtest.Runner.Tests.csproj", "{6F9973EA-977A-4185-AF24-4E76D9D851C8}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Runner.Tests", "tests\Recordingtest.Runner.Tests\Recordingtest.Runner.Tests.csproj", "{6F9973EA-977A-4185-AF24-4E76D9D851C8}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EngineBridge", "src\Recordingtest.EngineBridge\Recordingtest.EngineBridge.csproj", "{938D464B-B810-425F-83B6-52877B584DE2}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Bridge.Abstractions", "src\Recordingtest.Bridge.Abstractions\Recordingtest.Bridge.Abstractions.csproj", "{E9192225-E9F6-44EB-A18E-7F61F1093DA8}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EngineBridge.Probe", "src\Recordingtest.EngineBridge.Probe\Recordingtest.EngineBridge.Probe.csproj", "{B1EAD466-9C07-4C07-907C-3D5794F6689D}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Hmeg.Bridge", "src\Hmeg\Recordingtest.Hmeg.Bridge\Recordingtest.Hmeg.Bridge.csproj", "{33D35B3C-9572-432F-8675-6AD7CDF1C0EB}"
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("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EgPlugin", "src\Recordingtest.EgPlugin\Recordingtest.EgPlugin.csproj", "{51D7B803-5F6E-4B78-9A5D-326F28CD934F}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Hmeg", "Hmeg", "{FA0FB21B-DC6D-6187-86C3-94DFEB22505D}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EngineBridge.Client", "src\Recordingtest.EngineBridge.Client\Recordingtest.EngineBridge.Client.csproj", "{45D80D0C-A8A1-4173-B28C-68F0628EE346}" 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 EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EngineBridge.IntegrationTests", "tests\Recordingtest.EngineBridge.IntegrationTests\Recordingtest.EngineBridge.IntegrationTests.csproj", "{BA346F72-6F9C-4D68-9CDD-DD05F9687095}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Sut", "Sut", "{79DA188A-9C91-3DBA-2827-6072BD5E3D4F}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EgPlugin.Tests", "tests\Recordingtest.EgPlugin.Tests\Recordingtest.EgPlugin.Tests.csproj", "{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EgBim", "EgBim", "{7CC28442-33DD-D811-CEDA-9CC787317768}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Sut.EgBim.PluginHost", "src\Sut\EgBim\Recordingtest.Sut.EgBim.PluginHost\Recordingtest.Sut.EgBim.PluginHost.csproj", "{0A800F25-64B6-4F05-BB8E-68E317862CED}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Hmeg.Catalog", "src\Hmeg\Recordingtest.Hmeg.Catalog\Recordingtest.Hmeg.Catalog.csproj", "{23D628DC-D98D-427A-B0C0-470E70CC6DD2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Hmeg.Bridge.Client", "src\Hmeg\Recordingtest.Hmeg.Bridge.Client\Recordingtest.Hmeg.Bridge.Client.csproj", "{4E0274C5-39C2-436E-90AA-87DD1C675B4C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Hmeg.Catalog.Probe", "src\Hmeg\Recordingtest.Hmeg.Catalog.Probe\Recordingtest.Hmeg.Catalog.Probe.csproj", "{A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Sut.EgBim.PluginHost.Tests", "tests\Sut\EgBim\Recordingtest.Sut.EgBim.PluginHost.Tests\Recordingtest.Sut.EgBim.PluginHost.Tests.csproj", "{5D5C57B2-D9BC-4E27-8EB1-49FE2FD78207}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Hmeg.Catalog.Tests", "tests\Hmeg\Recordingtest.Hmeg.Catalog.Tests\Recordingtest.Hmeg.Catalog.Tests.csproj", "{A9894277-E1F3-4B86-AAE4-041116FBBE1D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Hmeg.Catalog.IntegrationTests", "tests\Hmeg\Recordingtest.Hmeg.Catalog.IntegrationTests\Recordingtest.Hmeg.Catalog.IntegrationTests.csproj", "{3D981C63-0D1E-466C-9BD6-3DAF46936A45}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Architecture.Tests", "tests\Recordingtest.Architecture.Tests\Recordingtest.Architecture.Tests.csproj", "{D35B233B-267B-40DB-87EF-689AEE5C9399}"
EndProject EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -195,90 +211,138 @@ Global
{6F9973EA-977A-4185-AF24-4E76D9D851C8}.Release|x64.Build.0 = Release|Any CPU {6F9973EA-977A-4185-AF24-4E76D9D851C8}.Release|x64.Build.0 = Release|Any CPU
{6F9973EA-977A-4185-AF24-4E76D9D851C8}.Release|x86.ActiveCfg = Release|Any CPU {6F9973EA-977A-4185-AF24-4E76D9D851C8}.Release|x86.ActiveCfg = Release|Any CPU
{6F9973EA-977A-4185-AF24-4E76D9D851C8}.Release|x86.Build.0 = Release|Any CPU {6F9973EA-977A-4185-AF24-4E76D9D851C8}.Release|x86.Build.0 = Release|Any CPU
{938D464B-B810-425F-83B6-52877B584DE2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{938D464B-B810-425F-83B6-52877B584DE2}.Debug|Any CPU.Build.0 = Debug|Any CPU {E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{938D464B-B810-425F-83B6-52877B584DE2}.Debug|x64.ActiveCfg = Debug|Any CPU {E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Debug|x64.ActiveCfg = Debug|Any CPU
{938D464B-B810-425F-83B6-52877B584DE2}.Debug|x64.Build.0 = Debug|Any CPU {E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Debug|x64.Build.0 = Debug|Any CPU
{938D464B-B810-425F-83B6-52877B584DE2}.Debug|x86.ActiveCfg = Debug|Any CPU {E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Debug|x86.ActiveCfg = Debug|Any CPU
{938D464B-B810-425F-83B6-52877B584DE2}.Debug|x86.Build.0 = Debug|Any CPU {E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Debug|x86.Build.0 = Debug|Any CPU
{938D464B-B810-425F-83B6-52877B584DE2}.Release|Any CPU.ActiveCfg = Release|Any CPU {E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{938D464B-B810-425F-83B6-52877B584DE2}.Release|Any CPU.Build.0 = Release|Any CPU {E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Release|Any CPU.Build.0 = Release|Any CPU
{938D464B-B810-425F-83B6-52877B584DE2}.Release|x64.ActiveCfg = Release|Any CPU {E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Release|x64.ActiveCfg = Release|Any CPU
{938D464B-B810-425F-83B6-52877B584DE2}.Release|x64.Build.0 = Release|Any CPU {E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Release|x64.Build.0 = Release|Any CPU
{938D464B-B810-425F-83B6-52877B584DE2}.Release|x86.ActiveCfg = Release|Any CPU {E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Release|x86.ActiveCfg = Release|Any CPU
{938D464B-B810-425F-83B6-52877B584DE2}.Release|x86.Build.0 = Release|Any CPU {E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Release|x86.Build.0 = Release|Any CPU
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Debug|Any CPU.Build.0 = Debug|Any CPU {33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Debug|x64.ActiveCfg = Debug|Any CPU {33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Debug|x64.ActiveCfg = Debug|Any CPU
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Debug|x64.Build.0 = Debug|Any CPU {33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Debug|x64.Build.0 = Debug|Any CPU
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Debug|x86.ActiveCfg = Debug|Any CPU {33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Debug|x86.ActiveCfg = Debug|Any CPU
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Debug|x86.Build.0 = Debug|Any CPU {33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Debug|x86.Build.0 = Debug|Any CPU
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Release|Any CPU.ActiveCfg = Release|Any CPU {33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Release|Any CPU.Build.0 = Release|Any CPU {33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Release|Any CPU.Build.0 = Release|Any CPU
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Release|x64.ActiveCfg = Release|Any CPU {33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Release|x64.ActiveCfg = Release|Any CPU
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Release|x64.Build.0 = Release|Any CPU {33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Release|x64.Build.0 = Release|Any CPU
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Release|x86.ActiveCfg = Release|Any CPU {33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Release|x86.ActiveCfg = Release|Any CPU
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Release|x86.Build.0 = Release|Any CPU {33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Release|x86.Build.0 = Release|Any CPU
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {20FB4AD7-3414-436D-880C-B2D95280DA3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Debug|Any CPU.Build.0 = Debug|Any CPU {20FB4AD7-3414-436D-880C-B2D95280DA3D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Debug|x64.ActiveCfg = Debug|Any CPU {20FB4AD7-3414-436D-880C-B2D95280DA3D}.Debug|x64.ActiveCfg = Debug|Any CPU
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Debug|x64.Build.0 = Debug|Any CPU {20FB4AD7-3414-436D-880C-B2D95280DA3D}.Debug|x64.Build.0 = Debug|Any CPU
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Debug|x86.ActiveCfg = Debug|Any CPU {20FB4AD7-3414-436D-880C-B2D95280DA3D}.Debug|x86.ActiveCfg = Debug|Any CPU
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Debug|x86.Build.0 = Debug|Any CPU {20FB4AD7-3414-436D-880C-B2D95280DA3D}.Debug|x86.Build.0 = Debug|Any CPU
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Release|Any CPU.ActiveCfg = Release|Any CPU {20FB4AD7-3414-436D-880C-B2D95280DA3D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Release|Any CPU.Build.0 = Release|Any CPU {20FB4AD7-3414-436D-880C-B2D95280DA3D}.Release|Any CPU.Build.0 = Release|Any CPU
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Release|x64.ActiveCfg = Release|Any CPU {20FB4AD7-3414-436D-880C-B2D95280DA3D}.Release|x64.ActiveCfg = Release|Any CPU
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Release|x64.Build.0 = Release|Any CPU {20FB4AD7-3414-436D-880C-B2D95280DA3D}.Release|x64.Build.0 = Release|Any CPU
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Release|x86.ActiveCfg = Release|Any CPU {20FB4AD7-3414-436D-880C-B2D95280DA3D}.Release|x86.ActiveCfg = Release|Any CPU
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Release|x86.Build.0 = Release|Any CPU {20FB4AD7-3414-436D-880C-B2D95280DA3D}.Release|x86.Build.0 = Release|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0A800F25-64B6-4F05-BB8E-68E317862CED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|Any CPU.Build.0 = Debug|Any CPU {0A800F25-64B6-4F05-BB8E-68E317862CED}.Debug|Any CPU.Build.0 = Debug|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|x64.ActiveCfg = Debug|Any CPU {0A800F25-64B6-4F05-BB8E-68E317862CED}.Debug|x64.ActiveCfg = Debug|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|x64.Build.0 = Debug|Any CPU {0A800F25-64B6-4F05-BB8E-68E317862CED}.Debug|x64.Build.0 = Debug|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|x86.ActiveCfg = Debug|Any CPU {0A800F25-64B6-4F05-BB8E-68E317862CED}.Debug|x86.ActiveCfg = Debug|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|x86.Build.0 = Debug|Any CPU {0A800F25-64B6-4F05-BB8E-68E317862CED}.Debug|x86.Build.0 = Debug|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|Any CPU.ActiveCfg = Release|Any CPU {0A800F25-64B6-4F05-BB8E-68E317862CED}.Release|Any CPU.ActiveCfg = Release|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|Any CPU.Build.0 = Release|Any CPU {0A800F25-64B6-4F05-BB8E-68E317862CED}.Release|Any CPU.Build.0 = Release|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|x64.ActiveCfg = Release|Any CPU {0A800F25-64B6-4F05-BB8E-68E317862CED}.Release|x64.ActiveCfg = Release|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|x64.Build.0 = Release|Any CPU {0A800F25-64B6-4F05-BB8E-68E317862CED}.Release|x64.Build.0 = Release|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|x86.ActiveCfg = Release|Any CPU {0A800F25-64B6-4F05-BB8E-68E317862CED}.Release|x86.ActiveCfg = Release|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|x86.Build.0 = Release|Any CPU {0A800F25-64B6-4F05-BB8E-68E317862CED}.Release|x86.Build.0 = Release|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|Any CPU.Build.0 = Debug|Any CPU {23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|x64.ActiveCfg = Debug|Any CPU {23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Debug|x64.ActiveCfg = Debug|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|x64.Build.0 = Debug|Any CPU {23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Debug|x64.Build.0 = Debug|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|x86.ActiveCfg = Debug|Any CPU {23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Debug|x86.ActiveCfg = Debug|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|x86.Build.0 = Debug|Any CPU {23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Debug|x86.Build.0 = Debug|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|Any CPU.ActiveCfg = Release|Any CPU {23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|Any CPU.Build.0 = Release|Any CPU {23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Release|Any CPU.Build.0 = Release|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|x64.ActiveCfg = Release|Any CPU {23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Release|x64.ActiveCfg = Release|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|x64.Build.0 = Release|Any CPU {23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Release|x64.Build.0 = Release|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|x86.ActiveCfg = Release|Any CPU {23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Release|x86.ActiveCfg = Release|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|x86.Build.0 = Release|Any CPU {23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Release|x86.Build.0 = Release|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|Any CPU.Build.0 = Debug|Any CPU {4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|x64.ActiveCfg = Debug|Any CPU {4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Debug|x64.ActiveCfg = Debug|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|x64.Build.0 = Debug|Any CPU {4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Debug|x64.Build.0 = Debug|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|x86.ActiveCfg = Debug|Any CPU {4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Debug|x86.ActiveCfg = Debug|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|x86.Build.0 = Debug|Any CPU {4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Debug|x86.Build.0 = Debug|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|Any CPU.ActiveCfg = Release|Any CPU {4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|Any CPU.Build.0 = Release|Any CPU {4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Release|Any CPU.Build.0 = Release|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|x64.ActiveCfg = Release|Any CPU {4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Release|x64.ActiveCfg = Release|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|x64.Build.0 = Release|Any CPU {4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Release|x64.Build.0 = Release|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|x86.ActiveCfg = Release|Any CPU {4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Release|x86.ActiveCfg = Release|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|x86.Build.0 = Release|Any CPU {4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Release|x86.Build.0 = Release|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|Any CPU.Build.0 = Debug|Any CPU {A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Debug|Any CPU.Build.0 = Debug|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|x64.ActiveCfg = Debug|Any CPU {A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Debug|x64.ActiveCfg = Debug|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|x64.Build.0 = Debug|Any CPU {A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Debug|x64.Build.0 = Debug|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|x86.ActiveCfg = Debug|Any CPU {A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Debug|x86.ActiveCfg = Debug|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|x86.Build.0 = Debug|Any CPU {A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Debug|x86.Build.0 = Debug|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|Any CPU.ActiveCfg = Release|Any CPU {A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Release|Any CPU.ActiveCfg = Release|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|Any CPU.Build.0 = Release|Any CPU {A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Release|Any CPU.Build.0 = Release|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|x64.ActiveCfg = Release|Any CPU {A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Release|x64.ActiveCfg = Release|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|x64.Build.0 = Release|Any CPU {A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Release|x64.Build.0 = Release|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|x86.ActiveCfg = Release|Any CPU {A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Release|x86.ActiveCfg = Release|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|x86.Build.0 = Release|Any CPU {A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Release|x86.Build.0 = Release|Any CPU
{5D5C57B2-D9BC-4E27-8EB1-49FE2FD78207}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5D5C57B2-D9BC-4E27-8EB1-49FE2FD78207}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5D5C57B2-D9BC-4E27-8EB1-49FE2FD78207}.Debug|x64.ActiveCfg = Debug|Any CPU
{5D5C57B2-D9BC-4E27-8EB1-49FE2FD78207}.Debug|x64.Build.0 = Debug|Any CPU
{5D5C57B2-D9BC-4E27-8EB1-49FE2FD78207}.Debug|x86.ActiveCfg = Debug|Any CPU
{5D5C57B2-D9BC-4E27-8EB1-49FE2FD78207}.Debug|x86.Build.0 = Debug|Any CPU
{5D5C57B2-D9BC-4E27-8EB1-49FE2FD78207}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5D5C57B2-D9BC-4E27-8EB1-49FE2FD78207}.Release|Any CPU.Build.0 = Release|Any CPU
{5D5C57B2-D9BC-4E27-8EB1-49FE2FD78207}.Release|x64.ActiveCfg = Release|Any CPU
{5D5C57B2-D9BC-4E27-8EB1-49FE2FD78207}.Release|x64.Build.0 = Release|Any CPU
{5D5C57B2-D9BC-4E27-8EB1-49FE2FD78207}.Release|x86.ActiveCfg = Release|Any CPU
{5D5C57B2-D9BC-4E27-8EB1-49FE2FD78207}.Release|x86.Build.0 = Release|Any CPU
{A9894277-E1F3-4B86-AAE4-041116FBBE1D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A9894277-E1F3-4B86-AAE4-041116FBBE1D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A9894277-E1F3-4B86-AAE4-041116FBBE1D}.Debug|x64.ActiveCfg = Debug|Any CPU
{A9894277-E1F3-4B86-AAE4-041116FBBE1D}.Debug|x64.Build.0 = Debug|Any CPU
{A9894277-E1F3-4B86-AAE4-041116FBBE1D}.Debug|x86.ActiveCfg = Debug|Any CPU
{A9894277-E1F3-4B86-AAE4-041116FBBE1D}.Debug|x86.Build.0 = Debug|Any CPU
{A9894277-E1F3-4B86-AAE4-041116FBBE1D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A9894277-E1F3-4B86-AAE4-041116FBBE1D}.Release|Any CPU.Build.0 = Release|Any CPU
{A9894277-E1F3-4B86-AAE4-041116FBBE1D}.Release|x64.ActiveCfg = Release|Any CPU
{A9894277-E1F3-4B86-AAE4-041116FBBE1D}.Release|x64.Build.0 = Release|Any CPU
{A9894277-E1F3-4B86-AAE4-041116FBBE1D}.Release|x86.ActiveCfg = Release|Any CPU
{A9894277-E1F3-4B86-AAE4-041116FBBE1D}.Release|x86.Build.0 = Release|Any CPU
{3D981C63-0D1E-466C-9BD6-3DAF46936A45}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3D981C63-0D1E-466C-9BD6-3DAF46936A45}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3D981C63-0D1E-466C-9BD6-3DAF46936A45}.Debug|x64.ActiveCfg = Debug|Any CPU
{3D981C63-0D1E-466C-9BD6-3DAF46936A45}.Debug|x64.Build.0 = Debug|Any CPU
{3D981C63-0D1E-466C-9BD6-3DAF46936A45}.Debug|x86.ActiveCfg = Debug|Any CPU
{3D981C63-0D1E-466C-9BD6-3DAF46936A45}.Debug|x86.Build.0 = Debug|Any CPU
{3D981C63-0D1E-466C-9BD6-3DAF46936A45}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3D981C63-0D1E-466C-9BD6-3DAF46936A45}.Release|Any CPU.Build.0 = Release|Any CPU
{3D981C63-0D1E-466C-9BD6-3DAF46936A45}.Release|x64.ActiveCfg = Release|Any CPU
{3D981C63-0D1E-466C-9BD6-3DAF46936A45}.Release|x64.Build.0 = Release|Any CPU
{3D981C63-0D1E-466C-9BD6-3DAF46936A45}.Release|x86.ActiveCfg = Release|Any CPU
{3D981C63-0D1E-466C-9BD6-3DAF46936A45}.Release|x86.Build.0 = Release|Any CPU
{D35B233B-267B-40DB-87EF-689AEE5C9399}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D35B233B-267B-40DB-87EF-689AEE5C9399}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D35B233B-267B-40DB-87EF-689AEE5C9399}.Debug|x64.ActiveCfg = Debug|Any CPU
{D35B233B-267B-40DB-87EF-689AEE5C9399}.Debug|x64.Build.0 = Debug|Any CPU
{D35B233B-267B-40DB-87EF-689AEE5C9399}.Debug|x86.ActiveCfg = Debug|Any CPU
{D35B233B-267B-40DB-87EF-689AEE5C9399}.Debug|x86.Build.0 = Debug|Any CPU
{D35B233B-267B-40DB-87EF-689AEE5C9399}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D35B233B-267B-40DB-87EF-689AEE5C9399}.Release|Any CPU.Build.0 = Release|Any CPU
{D35B233B-267B-40DB-87EF-689AEE5C9399}.Release|x64.ActiveCfg = Release|Any CPU
{D35B233B-267B-40DB-87EF-689AEE5C9399}.Release|x64.Build.0 = Release|Any CPU
{D35B233B-267B-40DB-87EF-689AEE5C9399}.Release|x86.ActiveCfg = Release|Any CPU
{D35B233B-267B-40DB-87EF-689AEE5C9399}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -295,12 +359,19 @@ Global
{7A5C0D53-BDFC-4AF6-8F4D-49E7EB8245F5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {7A5C0D53-BDFC-4AF6-8F4D-49E7EB8245F5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{DADF0474-9EF3-4E8D-8139-93504E4F745D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {DADF0474-9EF3-4E8D-8139-93504E4F745D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{6F9973EA-977A-4185-AF24-4E76D9D851C8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {6F9973EA-977A-4185-AF24-4E76D9D851C8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{938D464B-B810-425F-83B6-52877B584DE2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {E9192225-E9F6-44EB-A18E-7F61F1093DA8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{B1EAD466-9C07-4C07-907C-3D5794F6689D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {33D35B3C-9572-432F-8675-6AD7CDF1C0EB} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{0811AC32-E2A4-4BFD-A29A-6451F5756F10} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {FA0FB21B-DC6D-6187-86C3-94DFEB22505D} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{51D7B803-5F6E-4B78-9A5D-326F28CD934F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {20FB4AD7-3414-436D-880C-B2D95280DA3D} = {FA0FB21B-DC6D-6187-86C3-94DFEB22505D}
{45D80D0C-A8A1-4173-B28C-68F0628EE346} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {79DA188A-9C91-3DBA-2827-6072BD5E3D4F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{BA346F72-6F9C-4D68-9CDD-DD05F9687095} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {7CC28442-33DD-D811-CEDA-9CC787317768} = {79DA188A-9C91-3DBA-2827-6072BD5E3D4F}
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {0A800F25-64B6-4F05-BB8E-68E317862CED} = {7CC28442-33DD-D811-CEDA-9CC787317768}
{23D628DC-D98D-427A-B0C0-470E70CC6DD2} = {7CC28442-33DD-D811-CEDA-9CC787317768}
{4E0274C5-39C2-436E-90AA-87DD1C675B4C} = {7CC28442-33DD-D811-CEDA-9CC787317768}
{A5765A50-21FC-4BC6-97E6-3FE3A1AE6008} = {7CC28442-33DD-D811-CEDA-9CC787317768}
{5D5C57B2-D9BC-4E27-8EB1-49FE2FD78207} = {7CC28442-33DD-D811-CEDA-9CC787317768}
{A9894277-E1F3-4B86-AAE4-041116FBBE1D} = {7CC28442-33DD-D811-CEDA-9CC787317768}
{3D981C63-0D1E-466C-9BD6-3DAF46936A45} = {7CC28442-33DD-D811-CEDA-9CC787317768}
{D35B233B-267B-40DB-87EF-689AEE5C9399} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

View File

@@ -1,4 +1,4 @@
namespace Recordingtest.EngineBridge.Client; namespace Recordingtest.Hmeg.Bridge.Client;
public sealed class EngineBridgeException : Exception public sealed class EngineBridgeException : Exception
{ {

View File

@@ -1,7 +1,8 @@
using System.Net.Http; using System.Net.Http;
using System.Text.Json; using System.Text.Json;
using Recordingtest.Hmeg.Catalog;
namespace Recordingtest.EngineBridge.Client; namespace Recordingtest.Hmeg.Bridge.Client;
public sealed class HmEgHttpSnapshot : IEngineSnapshot, IDisposable public sealed class HmEgHttpSnapshot : IEngineSnapshot, IDisposable
{ {

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>Recordingtest.Hmeg.Bridge.Client</RootNamespace>
<AssemblyName>Recordingtest.Hmeg.Bridge.Client</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Recordingtest.Hmeg.Catalog\Recordingtest.Hmeg.Catalog.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,212 @@
using HmEG;
using Recordingtest.Bridge;
namespace Recordingtest.Hmeg.Bridge;
/// <summary>
/// HmEG-aware <see cref="IEngineStateProvider"/> 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:
///
/// <list type="bullet">
/// <item><c>spaceProvider</c> — returns the active <see cref="Space"/> tree (root of the scene/document).</item>
/// <item><c>viewportProvider</c> — returns the active <see cref="HmEGViewport"/> (camera + renderables).</item>
/// </list>
///
/// App-specific glue (e.g. <c>Recordingtest.Sut.EgBim.*</c>) is responsible for
/// resolving those handles from its own <c>AppManager</c> 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 <see cref="NullEngineStateProvider"/> would.
/// The plugin runs in-process inside the SUT and must never throw.
/// </summary>
public sealed class HmegDirectStateProvider : IEngineStateProvider
{
private readonly Func<Space?> _spaceProvider;
private readonly Func<HmEGViewport?> _viewportProvider;
private readonly Func<string?>? _documentPathProvider;
public HmegDirectStateProvider(
Func<Space?> spaceProvider,
Func<HmEGViewport?> viewportProvider,
Func<string?>? documentPathProvider = null)
{
_spaceProvider = spaceProvider ?? throw new ArgumentNullException(nameof(spaceProvider));
_viewportProvider = viewportProvider ?? throw new ArgumentNullException(nameof(viewportProvider));
_documentPathProvider = documentPathProvider;
}
public IReadOnlyList<string> GetSelectedIds()
{
try
{
var space = _spaceProvider();
if (space is null) return Array.Empty<string>();
var ids = new List<string>();
CollectSelectedRecursive(space, ids);
return ids;
}
catch
{
return Array.Empty<string>();
}
}
/// <summary>
/// Walks the Space tree and collects the <c>Uid</c> of every node whose
/// <see cref="HmEG.ISelectable.IsSelected"/> 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 <see cref="object"/> rather
/// than <c>HmEG.ModelBase</c> so this assembly does not have to reference
/// MemoryPack.Core (a serialization dependency that <c>ModelBase</c>
/// transitively pulls in via its attributes). The runtime shape we rely
/// on is just <c>ISelectable</c> + <c>Uid</c> + <c>Children</c>, all of
/// which are read by reflection-free pattern matching.
/// </summary>
private static void CollectSelectedRecursive(object node, List<string> 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;
}
}

View File

@@ -0,0 +1,22 @@
<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.Hmeg.Bridge</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Recordingtest.Bridge.Abstractions\Recordingtest.Bridge.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<!-- HmEG-aware tier may reference HmEG.dll only. App-specific assemblies
(e.g. Editor03.PluginInterface.dll) are forbidden here — they live
in src/Sut/<App>/. -->
<Reference Include="HmEG">
<HintPath>..\..\..\EG-BIM Modeler\HmEG.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Project>

View File

@@ -1,7 +1,7 @@
using System.Reflection; using System.Reflection;
using Recordingtest.EngineBridge; using Recordingtest.Hmeg.Catalog;
namespace Recordingtest.EngineBridge.Probe; namespace Recordingtest.Hmeg.Catalog.Probe;
internal static class Program internal static class Program
{ {

View File

@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<AssemblyName>Recordingtest.Hmeg.Catalog.Probe</AssemblyName>
<RootNamespace>Recordingtest.Hmeg.Catalog.Probe</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Recordingtest.Hmeg.Catalog\Recordingtest.Hmeg.Catalog.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,7 +1,7 @@
using System.Reflection; using System.Reflection;
using System.Text; using System.Text;
namespace Recordingtest.EngineBridge; namespace Recordingtest.Hmeg.Catalog;
public sealed record Candidate( public sealed record Candidate(
string Category, string Category,

View File

@@ -1,7 +1,7 @@
using System.Reflection; using System.Reflection;
using System.Text.Json; using System.Text.Json;
namespace Recordingtest.EngineBridge; namespace Recordingtest.Hmeg.Catalog;
public sealed record TypeEntry(string Assembly, string TypeName, bool IsPublic, string Namespace); public sealed record TypeEntry(string Assembly, string TypeName, bool IsPublic, string Namespace);

View File

@@ -1,4 +1,4 @@
namespace Recordingtest.EngineBridge; namespace Recordingtest.Hmeg.Catalog;
/// <summary> /// <summary>
/// Skeleton implementation of <see cref="IEngineSnapshot"/> for HmEG. /// Skeleton implementation of <see cref="IEngineSnapshot"/> for HmEG.

View File

@@ -1,4 +1,4 @@
namespace Recordingtest.EngineBridge; namespace Recordingtest.Hmeg.Catalog;
public interface IEngineSnapshot public interface IEngineSnapshot
{ {

View File

@@ -1,7 +1,7 @@
using System.Reflection; using System.Reflection;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
namespace Recordingtest.EngineBridge; namespace Recordingtest.Hmeg.Catalog;
/// <summary> /// <summary>
/// Thin wrapper around <see cref="MetadataLoadContext"/>. This class is /// Thin wrapper around <see cref="MetadataLoadContext"/>. This class is

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<AssemblyName>Recordingtest.EngineBridge</AssemblyName> <AssemblyName>Recordingtest.Hmeg.Catalog</AssemblyName>
<RootNamespace>Recordingtest.EngineBridge</RootNamespace> <RootNamespace>Recordingtest.Hmeg.Catalog</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="System.Reflection.MetadataLoadContext" Version="8.0.0" /> <PackageReference Include="System.Reflection.MetadataLoadContext" Version="8.0.0" />

View File

@@ -0,0 +1,37 @@
namespace Recordingtest.Bridge;
/// <summary>
/// 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 <c>Recordingtest.Bridge.Abstractions</c> so that
/// every higher tier (HmEG-aware / app-specific) can target it without the
/// generic core ever seeing a SUT-specific symbol.
/// </summary>
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);
/// <summary>
/// Safe-default provider used when no real SUT bridge is available
/// (CI / unit tests / startup race window).
/// </summary>
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;
}

View File

@@ -4,9 +4,6 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>Recordingtest.EngineBridge.Client</RootNamespace> <RootNamespace>Recordingtest.Bridge</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Recordingtest.EngineBridge\Recordingtest.EngineBridge.csproj" />
</ItemGroup>
</Project> </Project>

View File

@@ -1,48 +0,0 @@
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;
}
}

View File

@@ -1,56 +0,0 @@
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; }
}
}

View File

@@ -1,21 +0,0 @@
<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>

View File

@@ -1,10 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<AssemblyName>Recordingtest.EngineBridge.Probe</AssemblyName>
<RootNamespace>Recordingtest.EngineBridge.Probe</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Recordingtest.EngineBridge\Recordingtest.EngineBridge.csproj" />
</ItemGroup>
</Project>

View File

@@ -25,4 +25,9 @@ public interface IPlayerHost
void CaptureCheckpoint(int afterStep, string saveAs); void CaptureCheckpoint(int afterStep, string saveAs);
void CaptureFailureArtifacts(int stepIndex, string reason); void CaptureFailureArtifacts(int stepIndex, string reason);
// Issue #14: delay between steps. Kept on the host (not in the engine)
// because PlayerEngine contract forbids fixed sleeps; the host is free
// to implement real time or a virtual clock for tests.
void Delay(TimeSpan duration);
} }

View File

@@ -22,6 +22,12 @@ public sealed class Step
public string? WaitFor { get; set; } public string? WaitFor { get; set; }
public int? AfterStep { get; set; } public int? AfterStep { get; set; }
public string? SaveAs { get; set; } public string? SaveAs { get; set; }
// Issue #14: recorder-captured screen-absolute coordinates used as
// fallback when Target is null (Click). Optional; null for non-mouse steps.
public int[]? RawCoord { get; set; }
// Issue #14: recorder-captured absolute timestamp (ms). Used by the
// engine to preserve inter-step pacing during playback.
public long? Ts { get; set; }
} }
public sealed class Target public sealed class Target

View File

@@ -6,6 +6,12 @@ public sealed class PlayerEngineOptions
{ {
public TimeSpan ResolveTimeout { get; set; } = TimeSpan.FromSeconds(10); public TimeSpan ResolveTimeout { get; set; } = TimeSpan.FromSeconds(10);
public TimeSpan WaitForTimeout { get; set; } = TimeSpan.FromSeconds(15); public TimeSpan WaitForTimeout { get; set; } = TimeSpan.FromSeconds(15);
// Issue #14: preserve recorded inter-step delays (clamped). When true the
// engine sleeps step.Ts - prevStep.Ts between steps, bounded by Min/Max.
public bool PreserveTiming { get; set; } = true;
public TimeSpan MinStepDelay { get; set; } = TimeSpan.FromMilliseconds(150);
public TimeSpan MaxStepDelay { get; set; } = TimeSpan.FromSeconds(3);
} }
public sealed class PlayerEngine public sealed class PlayerEngine
@@ -22,9 +28,51 @@ public sealed class PlayerEngine
ArgumentNullException.ThrowIfNull(scenario); ArgumentNullException.ThrowIfNull(scenario);
ArgumentNullException.ThrowIfNull(host); ArgumentNullException.ThrowIfNull(host);
for (int i = 0; i < scenario.Steps.Count; i++) // Issue #14: strip leading alt+tab hotkey steps. These are recording
// startup noise (user tabbing from their editor into the SUT at the
// start of the session). At replay time the player already puts the
// SUT in the foreground, so re-running alt+tab here just switches
// focus AWAY from the SUT and breaks subsequent Type steps.
int start = 0;
while (start < scenario.Steps.Count)
{
var s = scenario.Steps[start];
if (s.Kind == StepKind.Hotkey &&
string.Equals(s.Value, "alt+tab", StringComparison.OrdinalIgnoreCase))
{
Console.WriteLine(
$"[player] info: skipping leading alt+tab step {start} (issue #14)");
start++;
continue;
}
break;
}
// Seed prevTs so the FIRST executed step also gets a pre-delay
// (MinStepDelay). Without this, step 2's Type can fire before the
// SUT has fully settled after foreground switch.
long? prevTs = start < scenario.Steps.Count && scenario.Steps[start].Ts is long firstTs
? firstTs - (long)_options.MinStepDelay.TotalMilliseconds
: null;
for (int i = start; i < scenario.Steps.Count; i++)
{ {
var step = scenario.Steps[i]; var step = scenario.Steps[i];
if (_options.PreserveTiming && step.Ts is long ts)
{
if (prevTs is long p)
{
var delta = ts - p;
if (delta < _options.MinStepDelay.TotalMilliseconds)
delta = (long)_options.MinStepDelay.TotalMilliseconds;
if (delta > _options.MaxStepDelay.TotalMilliseconds)
delta = (long)_options.MaxStepDelay.TotalMilliseconds;
host.Delay(TimeSpan.FromMilliseconds(delta));
}
prevTs = ts;
}
Console.WriteLine($"[player] step {i} kind={step.Kind} value={step.Value ?? ""}");
try try
{ {
ExecuteStep(i, step, host); ExecuteStep(i, step, host);
@@ -70,12 +118,29 @@ public sealed class PlayerEngine
} }
else if (StepRequiresTarget(step.Kind)) else if (StepRequiresTarget(step.Kind))
{ {
// Issue #11: recorder may emit Click/Drag/Type/Focus steps with // Issue #14: recorder emits Type/Click with null target when the
// null target. Never click/drag/type at (0,0) on the desktop — // focused element / UIA path at record time could not be resolved
// skip with a warning instead. // (e.g. typing into a CommandBox before any mouse click, clicks on
Console.WriteLine( // canvas children that don't expose AutomationId). Fall back to:
$"[player] warn: skipping step {index} kind={step.Kind} — target is null (issue #11)"); // - Type → send keystrokes to whatever currently has focus
return; // - Click → use recorded raw_coord (screen-absolute) directly
// This mirrors the manual cleanup that produced box-v5-clean.yaml.
if (step.Kind == StepKind.Type)
{
}
else if (step.Kind == StepKind.Click
&& step.RawCoord is { Length: >= 2 })
{
point = new ScreenPoint(step.RawCoord[0], step.RawCoord[1]);
Console.WriteLine(
$"[player] info: step {index} kind=Click null target — using raw_coord ({point.X},{point.Y}) (issue #14)");
}
else
{
Console.WriteLine(
$"[player] warn: skipping step {index} kind={step.Kind} — target is null and no fallback (issue #14)");
return;
}
} }
switch (step.Kind) switch (step.Kind)

View File

@@ -59,6 +59,13 @@ else
} }
using var host = new UiaPlayerHost(app, artifactDir); using var host = new UiaPlayerHost(app, artifactDir);
// Issue #14: ensure SUT is the foreground window before playback so that
// keystrokes (Type/Hotkey) land on the SUT instead of whatever shell the
// player was launched from (PowerShell, VS Code, etc.). Without this, the
// very first "BOX" type step gets typed into the launching terminal.
host.BringSutToForeground();
var engine = new PlayerEngine(); var engine = new PlayerEngine();
try try
{ {

View File

@@ -206,6 +206,54 @@ public sealed class UiaPlayerHost : IPlayerHost, IDisposable
} }
} }
/// <summary>
/// Issue #14 — force the SUT main window to foreground and give it keyboard
/// focus before playback starts. Handles the common case where the player
/// was launched from a shell that still has focus when the first Type/Hotkey
/// step fires.
/// </summary>
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
public void BringSutToForeground()
{
try
{
var w = _app?.GetMainWindow(_automation, TimeSpan.FromSeconds(5));
if (w is null) return;
var targetHwnd = w.Properties.NativeWindowHandle.ValueOrDefault;
try { w.SetForeground(); } catch { /* best-effort */ }
try { w.Focus(); } catch { /* best-effort */ }
// Issue #14 follow-up: active wait instead of fixed 600ms sleep.
// Poll until the OS reports the SUT as the foreground window, up
// to 2s. Previously a 600ms fixed sleep was threshold-sensitive
// and caused the first "BOX" keystroke to get dropped on a cold
// first run.
var deadline = DateTime.UtcNow.AddSeconds(2);
while (DateTime.UtcNow < deadline)
{
if (targetHwnd != IntPtr.Zero && GetForegroundWindow() == targetHwnd)
break;
System.Threading.Thread.Sleep(25);
}
// Tiny additional settle for the OS keyboard-focus IPC to finish
// after the foreground transition is observed.
System.Threading.Thread.Sleep(100);
}
catch
{
// best-effort; if this fails the user will see the BOX text land
// in the wrong window and can re-run with the SUT focused manually.
}
}
public void Delay(TimeSpan duration)
{
if (duration > TimeSpan.Zero)
System.Threading.Thread.Sleep(duration);
}
public void Dispose() public void Dispose()
{ {
_automation.Dispose(); _automation.Dispose();

View File

@@ -35,6 +35,11 @@ public sealed class DragCollapser
var typeBuf = new System.Text.StringBuilder(); var typeBuf = new System.Text.StringBuilder();
RawEvent? typeFirst = null; RawEvent? typeFirst = null;
UiaResolution? typeRes = null; UiaResolution? typeRes = null;
// Issue #14 Gap I-1: path captured directly from the focus poller at
// the first key_down of a type burst. Takes precedence over the older
// lastFocusPath / lastMousePath fallbacks because it's pinned to the
// instant the user actually started typing.
string? typeFocusPath = null;
// Active modifiers (ctrl/shift/alt/win) held down. // Active modifiers (ctrl/shift/alt/win) held down.
var modsDown = new HashSet<string>(StringComparer.Ordinal); var modsDown = new HashSet<string>(StringComparer.Ordinal);
@@ -69,7 +74,7 @@ public sealed class DragCollapser
} }
else else
{ {
var fallback = lastFocusPath ?? lastMousePath; var fallback = typeFocusPath ?? lastFocusPath ?? lastMousePath;
if (!string.IsNullOrEmpty(fallback)) if (!string.IsNullOrEmpty(fallback))
{ {
step.Target = new ScenarioTarget step.Target = new ScenarioTarget
@@ -83,6 +88,7 @@ public sealed class DragCollapser
typeBuf.Clear(); typeBuf.Clear();
typeFirst = null; typeFirst = null;
typeRes = null; typeRes = null;
typeFocusPath = null;
} }
foreach (var ev in events) foreach (var ev in events)
@@ -265,6 +271,9 @@ public sealed class DragCollapser
{ {
typeFirst = ev; typeFirst = ev;
typeRes = res; typeRes = res;
// Issue #14 Gap I-1: capture focused-element path
// snapshotted by the poller at key_down time.
typeFocusPath = ev.FocusedElementPath;
} }
typeBuf.Append(tr.Text); typeBuf.Append(tr.Text);
break; break;

View File

@@ -27,6 +27,14 @@ public sealed class LowLevelHook : IDisposable
/// </summary> /// </summary>
public IWindowFilter Filter { get; set; } = new PassThroughWindowFilter(); public IWindowFilter Filter { get; set; } = new PassThroughWindowFilter();
/// <summary>
/// Issue #14 Gap I-1 — latest UIA focused-element path observed by a
/// background poller. Stamped onto key_down RawEvents so the collapser
/// can assign a target to the resulting Type step without relying on
/// the stale post-hoc Resolve() pass. Null until the first poll.
/// </summary>
public volatile string? CurrentFocusedPath;
public LowLevelHook(Channel<RawEvent> channel) public LowLevelHook(Channel<RawEvent> channel)
{ {
_channel = channel; _channel = channel;
@@ -83,7 +91,8 @@ public sealed class LowLevelHook : IDisposable
NativeMethods.WM_KEYUP or NativeMethods.WM_SYSKEYUP => "key_up", NativeMethods.WM_KEYUP or NativeMethods.WM_SYSKEYUP => "key_up",
_ => "key", _ => "key",
}; };
var ev = new RawEvent(NowMs(), kind, 0, 0, data.vkCode, 0); var ev = new RawEvent(
NowMs(), kind, 0, 0, data.vkCode, 0, CurrentFocusedPath);
if (Filter.ShouldKeep(ev)) if (Filter.ShouldKeep(ev))
{ {
_channel.Writer.TryWrite(ev); _channel.Writer.TryWrite(ev);

View File

@@ -155,6 +155,63 @@ public static class Program
Console.Error.WriteLine($"[recorder] focus subscribe failed: {ex.Message}"); Console.Error.WriteLine($"[recorder] focus subscribe failed: {ex.Message}");
} }
// Issue #14 Gap I-1 — background focus poller. Periodically queries
// Automation.FocusedElement() and publishes the element path into
// LowLevelHook.CurrentFocusedPath, so key_down events captured on the
// hook thread can be stamped with the live focused-element path at
// the exact instant the user started typing. This catches custom WPF
// controls (e.g. CommandBox) that do NOT raise UIA focus_changed
// events reliably.
var pollerCts = new CancellationTokenSource();
Task? pollerTask = null;
int pollerSuccess = 0;
int pollerNullFocus = 0;
int pollerWrongPid = 0;
int pollerErrors = 0;
string? pollerLastError = null;
if (automation is not null)
{
var auto = automation;
var pid = sutPid;
pollerTask = Task.Run(async () =>
{
while (!pollerCts.IsCancellationRequested)
{
try
{
var focused = auto.FocusedElement();
if (focused is null)
{
System.Threading.Interlocked.Increment(ref pollerNullFocus);
}
else
{
int elPid = 0;
try { elPid = focused.Properties.ProcessId.ValueOrDefault; }
catch { elPid = 0; }
if (pid == 0 || elPid == pid)
{
var snap = new FlaUiSnapshot(focused);
hook.CurrentFocusedPath = ElementPathBuilder.Build(snap);
System.Threading.Interlocked.Increment(ref pollerSuccess);
}
else
{
System.Threading.Interlocked.Increment(ref pollerWrongPid);
}
}
}
catch (Exception ex)
{
System.Threading.Interlocked.Increment(ref pollerErrors);
pollerLastError = ex.GetType().Name + ": " + ex.Message;
}
try { await Task.Delay(100, pollerCts.Token); }
catch (OperationCanceledException) { break; }
}
}, pollerCts.Token);
}
Console.WriteLine("[recorder] capturing... press Ctrl+C to stop."); Console.WriteLine("[recorder] capturing... press Ctrl+C to stop.");
int eventCount = 0; int eventCount = 0;
int unresolvedPaths = 0; // resolver ran but returned null int unresolvedPaths = 0; // resolver ran but returned null
@@ -174,12 +231,20 @@ public static class Program
sw.Stop(); sw.Stop();
// Stop the focus poller before UIA teardown.
pollerCts.Cancel();
try { pollerTask?.Wait(500); } catch { /* ignore */ }
// Collapse buffered raw events into scenario steps via DragCollapser. // Collapse buffered raw events into scenario steps via DragCollapser.
var collapser = new DragCollapser(); var collapser = new DragCollapser();
UiaResolution? Resolve(RawEvent ev) UiaResolution? Resolve(RawEvent ev)
{ {
// Key events have no meaningful coordinate — resolver cannot attempt // Issue #14 Gap I-1 — key events: Resolve() runs at collapse time
// a point-based lookup. Count them separately from genuine misses. // (after the recording ended), so querying FocusedElement() HERE
// would be stale (focus has already left the SUT). The focused
// element path is captured at key_down time by the FocusPoller
// and baked into RawEvent.FocusedElementPath. DragCollapser reads
// that directly; Resolve() simply returns null for key events.
if (ev.Kind == "key_down" || ev.Kind == "key_up") if (ev.Kind == "key_down" || ev.Kind == "key_up")
{ {
noResolverAttempt++; noResolverAttempt++;
@@ -225,6 +290,11 @@ public static class Program
$"[recorder] done. events={eventCount} elapsed={sw.Elapsed} " + $"[recorder] done. events={eventCount} elapsed={sw.Elapsed} " +
$"unresolved_paths={unresolvedPaths} no_resolver_attempt={noResolverAttempt} " + $"unresolved_paths={unresolvedPaths} no_resolver_attempt={noResolverAttempt} " +
$"null_target_steps={nullTargetSteps}"); $"null_target_steps={nullTargetSteps}");
Console.WriteLine(
$"[recorder] focus_poller success={pollerSuccess} null_focus={pollerNullFocus} " +
$"wrong_pid={pollerWrongPid} errors={pollerErrors} last_path={hook.CurrentFocusedPath ?? "<null>"}");
if (pollerLastError is not null)
Console.WriteLine($"[recorder] focus_poller last_error: {pollerLastError}");
automation?.Dispose(); automation?.Dispose();
return 0; return 0;

View File

@@ -1,7 +1,7 @@
using System.Net; using System.Net;
using System.Text; using System.Text;
namespace Recordingtest.EgPlugin; namespace Recordingtest.Sut.EgBim.PluginHost;
/// <summary> /// <summary>
/// Hosts an HttpListener that delegates path routing to <see cref="StateRouter"/>. /// Hosts an HttpListener that delegates path routing to <see cref="StateRouter"/>.

View File

@@ -0,0 +1,54 @@
using Recordingtest.Bridge;
namespace Recordingtest.Sut.EgBim.PluginHost;
/// <summary>
/// Tries the primary provider first; if it returns the empty/default value
/// (e.g. <c>HmegDirectStateProvider</c> 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)
/// </summary>
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<string> 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;
}

View File

@@ -0,0 +1,108 @@
using Editor.PluginInterface;
using HmEG;
using Recordingtest.Bridge;
using Recordingtest.Hmeg.Bridge;
namespace Recordingtest.Sut.EgBim.PluginHost;
/// <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.Sut.EgBim.PluginHost";
public override string Description => "recordingtest engine-bridge v3 (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 = BuildProvider();
var router = new StateRouter(provider, port);
_server = new BridgeHttpServer(router, port);
_server.Start();
}
catch
{
// never throw out of plugin construction; SUT must remain stable.
}
}
/// <summary>
/// Choose the best available <see cref="IEngineStateProvider"/>:
///
/// 1. <c>HmegDirectStateProvider</c> — 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. <c>ReflectionEngineStateProvider</c> — fallback that uses
/// <c>IAppManagerAccessor</c> 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.
/// </summary>
private IEngineStateProvider BuildProvider()
{
// EG-BIM Modeler entry points (resolved via EditorPlugin base):
// RootSpace = AppManager.ViewportManager.RootSpace
// View (EGViewport : HmEGViewport) — plugin-injected active viewport
// AppManager.FileManager.CurrentFile — document path on disk
//
// Each lambda is wrapped in try/catch so plugin construction never
// throws even if the host state is in flight at boot time.
Func<Space?> spaceProvider = () =>
{
try { return RootSpace; }
catch { return null; }
};
Func<HmEGViewport?> viewportProvider = () =>
{
try
{
// EGViewport implements HmEGViewport; the base class exposes it
// as EGViewport on the Obsolete View property. We catch and
// return null to survive the obsolete warning at runtime.
#pragma warning disable CS0618 // Obsolete API on EditorPlugin.View
return View;
#pragma warning restore CS0618
}
catch { return null; }
};
Func<string?> documentPathProvider = () =>
{
try
{
var path = AppManager?.FileManager?.CurrentFile;
return string.IsNullOrEmpty(path) ? null : path;
}
catch { return 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 { }
_server = null;
}
}

View File

@@ -0,0 +1,214 @@
using System.Reflection;
namespace Recordingtest.Sut.EgBim.PluginHost;
/// <summary>
/// engine-bridge v3 — abstraction over the SUT-side AppManager singleton.
/// Lets <see cref="ReflectionEngineStateProvider"/> be unit-tested with a
/// fake instead of needing the real HmEG runtime in CI.
/// </summary>
public interface IAppManagerAccessor
{
/// <summary>The AppManager root, or null if not yet discovered.</summary>
object? GetAppManager();
/// <summary>The currently active document, or null.</summary>
object? GetActiveDocument();
/// <summary>The currently active viewport (camera host), or null.</summary>
object? GetActiveViewport();
/// <summary>Selection IDs as strings. Empty when no selection or not discoverable.</summary>
IReadOnlyList<string> GetSelectedIds();
/// <summary>Object count in the active document, or 0.</summary>
int GetObjectCount();
/// <summary>Active document path on disk, or null when unsaved.</summary>
string? GetDocumentPath();
/// <summary>
/// Camera tuple (eye, target, up, fov). Returns null when no viewport.
/// </summary>
(double[] Eye, double[] Target, double[] Up, double Fov)? GetCameraTuple();
}
/// <summary>
/// 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.
/// </summary>
public sealed class ReflectionAppManagerAccessor : IAppManagerAccessor
{
private readonly object? _seedRoot;
/// <param name="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.
/// </param>
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<string> GetSelectedIds()
{
var doc = GetActiveDocument();
var sel = TryGetMember(doc, new[] { "Selection", "SelectedObjects", "Selected" });
if (sel is not System.Collections.IEnumerable e) return Array.Empty<string>();
var ids = new List<string>();
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 };
}
}
}

View File

@@ -0,0 +1,83 @@
// 3-tier split step 2 — this file now holds only the EgBim-specific
// ReflectionEngineStateProvider, which probes Editor.AppManager.AppManager
// via reflection as the CI / fallback path. The generic IEngineStateProvider
// contract lives in Recordingtest.Bridge.Abstractions; the HmEG-aware
// HmegDirectStateProvider lives in Recordingtest.Hmeg.Bridge.
using Recordingtest.Bridge;
namespace Recordingtest.Sut.EgBim.PluginHost;
/// <summary>
/// engine-bridge v3 — reflection-backed provider. Delegates all SUT-specific
/// lookups to <see cref="IAppManagerAccessor"/>, which is unit-testable via a
/// fake accessor (see <c>FakeAccessor</c> 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 <c>Editor.AppManager.AppManager</c>. It is kept as the CI/fallback
/// path; production should prefer <c>HmegDirectStateProvider</c>.
/// </summary>
public sealed class ReflectionEngineStateProvider : IEngineStateProvider
{
private readonly IAppManagerAccessor _accessor;
public ReflectionEngineStateProvider(IAppManagerAccessor accessor)
{
_accessor = accessor;
}
/// <summary>
/// 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.
/// </summary>
public ReflectionEngineStateProvider(object? seedRoot)
: this(new ReflectionAppManagerAccessor(seedRoot))
{
}
public IReadOnlyList<string> GetSelectedIds()
{
try { return _accessor.GetSelectedIds(); }
catch { return Array.Empty<string>(); }
}
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
{
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 SceneSnapshot(_accessor.GetObjectCount(), _accessor.GetDocumentPath());
}
catch
{
return new SceneSnapshot(0, null);
}
}
public bool GetRenderComplete()
{
// 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;
}
}

View File

@@ -1,4 +1,4 @@
namespace Recordingtest.EgPlugin; namespace Recordingtest.Sut.EgBim.PluginHost;
public static class PortResolver public static class PortResolver
{ {

View File

@@ -0,0 +1,30 @@
<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.Sut.EgBim.PluginHost</RootNamespace>
<AssemblyName>Recordingtest.Sut.EgBim.PluginHost</AssemblyName>
<EnableDefaultItems>true</EnableDefaultItems>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\Recordingtest.Bridge.Abstractions\Recordingtest.Bridge.Abstractions.csproj" />
<ProjectReference Include="..\..\..\Hmeg\Recordingtest.Hmeg.Bridge\Recordingtest.Hmeg.Bridge.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="Editor03.PluginInterface">
<HintPath>..\..\..\..\EG-BIM Modeler\Editor03.PluginInterface.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Editor02.HmEGAppManager">
<HintPath>..\..\..\..\EG-BIM Modeler\Editor02.HmEGAppManager.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="HmEG">
<HintPath>..\..\..\..\EG-BIM Modeler\HmEG.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Project>

View File

@@ -1,8 +1,9 @@
using System.Globalization; using System.Globalization;
using System.Net; using System.Net;
using System.Text; using System.Text;
using Recordingtest.Bridge;
namespace Recordingtest.EgPlugin; namespace Recordingtest.Sut.EgBim.PluginHost;
/// <summary> /// <summary>
/// Pure logic router: maps a request path to (status, json body). /// Pure logic router: maps a request path to (status, json body).

View File

@@ -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<ArgumentNullException>(() =>
new HmegDirectStateProvider(null!, () => null));
Assert.Throws<ArgumentNullException>(() =>
new HmegDirectStateProvider(() => null, null!));
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<RootNamespace>Recordingtest.Hmeg.Bridge.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Hmeg\Recordingtest.Hmeg.Bridge\Recordingtest.Hmeg.Bridge.csproj" />
<ProjectReference Include="..\..\..\src\Recordingtest.Bridge.Abstractions\Recordingtest.Bridge.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="HmEG">
<HintPath>..\..\..\EG-BIM Modeler\HmEG.dll</HintPath>
<!-- Test process is standalone (not loaded into the SUT), so the
assembly must be copied next to the test dll at runtime. -->
<Private>true</Private>
</Reference>
</ItemGroup>
</Project>

View File

@@ -2,7 +2,7 @@ using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
using System.Text; using System.Text;
namespace Recordingtest.EngineBridge.IntegrationTests; namespace Recordingtest.Hmeg.Catalog.IntegrationTests;
public sealed class FakeBridgeServer : IDisposable public sealed class FakeBridgeServer : IDisposable
{ {

View File

@@ -1,7 +1,7 @@
using Recordingtest.EngineBridge.Client; using Recordingtest.Hmeg.Bridge.Client;
using Xunit; using Xunit;
namespace Recordingtest.EngineBridge.IntegrationTests; namespace Recordingtest.Hmeg.Catalog.IntegrationTests;
public class HmEgHttpSnapshotTests public class HmEgHttpSnapshotTests
{ {

View File

@@ -5,7 +5,8 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<RootNamespace>Recordingtest.EngineBridge.IntegrationTests</RootNamespace> <RootNamespace>Recordingtest.Hmeg.Catalog.IntegrationTests</RootNamespace>
<AssemblyName>Recordingtest.Hmeg.Catalog.IntegrationTests</AssemblyName>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
@@ -13,6 +14,6 @@
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\Recordingtest.EngineBridge.Client\Recordingtest.EngineBridge.Client.csproj" /> <ProjectReference Include="..\..\..\src\Hmeg\Recordingtest.Hmeg.Bridge.Client\Recordingtest.Hmeg.Bridge.Client.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,9 +1,9 @@
using System.Reflection; using System.Reflection;
using System.Text.Json; using System.Text.Json;
using Recordingtest.EngineBridge; using Recordingtest.Hmeg.Catalog;
using Xunit; using Xunit;
namespace Recordingtest.EngineBridge.Tests; namespace Recordingtest.Hmeg.Catalog.Tests;
public sealed class EngineBridgeTests public sealed class EngineBridgeTests
{ {

View File

@@ -1,8 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<RootNamespace>Recordingtest.EngineBridge.Tests</RootNamespace> <RootNamespace>Recordingtest.Hmeg.Catalog.Tests</RootNamespace>
<AssemblyName>Recordingtest.EngineBridge.Tests</AssemblyName> <AssemblyName>Recordingtest.Hmeg.Catalog.Tests</AssemblyName>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
@@ -10,6 +10,6 @@
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\Recordingtest.EngineBridge\Recordingtest.EngineBridge.csproj" /> <ProjectReference Include="..\..\..\src\Hmeg\Recordingtest.Hmeg.Catalog\Recordingtest.Hmeg.Catalog.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,115 @@
using System.IO;
using System.Reflection;
using Xunit;
namespace Recordingtest.Architecture.Tests;
/// <summary>
/// Enforces the 3-tier separation rule from CLAUDE.md §8.1:
///
/// Generic → no SUT assembly references at all
/// HmEG-aware → HmEG.dll only, no per-app assembly
/// App-specific → anything (not checked here; not referenced by this project)
///
/// These tests walk <see cref="Assembly.GetReferencedAssemblies"/> on each
/// compiled output and fail if a forbidden reference appears. The test
/// project itself does NOT reference any Sut/* project, so a physical build
/// error would catch accidental upward references even before these tests
/// run — this file is the explicit contract.
/// </summary>
public class DependencyGraphTests
{
private static readonly string[] ForbiddenAppAssemblies =
{
"Editor03.PluginInterface",
"Editor02.HmEGAppManager",
"EditorCore",
};
private const string HmegAssembly = "HmEG";
private static readonly string[] GenericAssemblies =
{
"Recordingtest.Bridge.Abstractions",
"Recordingtest.Recorder",
"Recordingtest.Player",
"Recordingtest.Normalizer",
"Recordingtest.DiffReporter",
"Recordingtest.Runner",
"Recordingtest.SutProber",
};
private static readonly string[] HmegAwareAssemblies =
{
"Recordingtest.Hmeg.Bridge",
"Recordingtest.Hmeg.Catalog",
"Recordingtest.Hmeg.Bridge.Client",
};
[Theory]
[InlineData("Recordingtest.Bridge.Abstractions")]
[InlineData("Recordingtest.Recorder")]
[InlineData("Recordingtest.Player")]
[InlineData("Recordingtest.Normalizer")]
[InlineData("Recordingtest.DiffReporter")]
[InlineData("Recordingtest.Runner")]
[InlineData("Recordingtest.SutProber")]
public void Generic_Tier_Does_Not_Reference_Hmeg_Or_AppAssemblies(string assemblyName)
{
var refs = LoadReferences(assemblyName);
Assert.DoesNotContain(HmegAssembly, refs);
foreach (var app in ForbiddenAppAssemblies)
{
Assert.DoesNotContain(app, refs);
}
}
[Theory]
[InlineData("Recordingtest.Hmeg.Bridge")]
[InlineData("Recordingtest.Hmeg.Catalog")]
[InlineData("Recordingtest.Hmeg.Bridge.Client")]
public void HmegAware_Tier_Does_Not_Reference_AppAssemblies(string assemblyName)
{
var refs = LoadReferences(assemblyName);
foreach (var app in ForbiddenAppAssemblies)
{
Assert.DoesNotContain(app, refs);
}
}
[Fact]
public void Hmeg_Bridge_References_HmEG_Dll()
{
// HmEG-aware tier is expected to reference HmEG.dll. This is the
// positive check that pairs with the App-specific forbidden list.
var refs = LoadReferences("Recordingtest.Hmeg.Bridge");
Assert.Contains(HmegAssembly, refs);
}
private static IReadOnlySet<string> LoadReferences(string assemblyName)
{
// Locate the assembly via a type we know belongs to it. Each tier
// member has at least one public type; we use GenericAssemblies/
// HmegAwareAssemblies as lookups by name.
Assembly? asm = null;
foreach (var loaded in AppDomain.CurrentDomain.GetAssemblies())
{
if (loaded.GetName().Name == assemblyName)
{
asm = loaded;
break;
}
}
if (asm is null)
{
// Force-load by trying to resolve a type: the test project has a
// ProjectReference to the target, so the binary is next to ours.
var dir = Path.GetDirectoryName(typeof(DependencyGraphTests).Assembly.Location)!;
var path = Path.Combine(dir, assemblyName + ".dll");
asm = Assembly.LoadFrom(path);
}
return new HashSet<string>(
asm.GetReferencedAssemblies().Select(n => n.Name ?? string.Empty),
StringComparer.Ordinal);
}
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<RootNamespace>Recordingtest.Architecture.Tests</RootNamespace>
<AssemblyName>Recordingtest.Architecture.Tests</AssemblyName>
</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>
<!-- Reference every Generic-tier assembly so we can inspect its dependency
graph at test time. Add HmEG-aware here too; do NOT reference any
Sut/* project or app-specific DLL. -->
<ProjectReference Include="..\..\src\Recordingtest.Bridge.Abstractions\Recordingtest.Bridge.Abstractions.csproj" />
<ProjectReference Include="..\..\src\Recordingtest.Recorder\Recordingtest.Recorder.csproj" />
<ProjectReference Include="..\..\src\Recordingtest.Player\Recordingtest.Player.csproj" />
<ProjectReference Include="..\..\src\Recordingtest.Normalizer\Recordingtest.Normalizer.csproj" />
<ProjectReference Include="..\..\src\Recordingtest.DiffReporter\Recordingtest.DiffReporter.csproj" />
<ProjectReference Include="..\..\src\Recordingtest.Runner\Recordingtest.Runner.csproj" />
<ProjectReference Include="..\..\src\Recordingtest.SutProber\Recordingtest.SutProber.csproj" />
<ProjectReference Include="..\..\src\Hmeg\Recordingtest.Hmeg.Bridge\Recordingtest.Hmeg.Bridge.csproj" />
<ProjectReference Include="..\..\src\Hmeg\Recordingtest.Hmeg.Catalog\Recordingtest.Hmeg.Catalog.csproj" />
<ProjectReference Include="..\..\src\Hmeg\Recordingtest.Hmeg.Bridge.Client\Recordingtest.Hmeg.Bridge.Client.csproj" />
</ItemGroup>
</Project>

View File

@@ -35,4 +35,6 @@ internal sealed class FakePlayerHost : IPlayerHost
Checkpoints.Add((afterStep, saveAs)); Checkpoints.Add((afterStep, saveAs));
public void CaptureFailureArtifacts(int stepIndex, string reason) => public void CaptureFailureArtifacts(int stepIndex, string reason) =>
Failures.Add((stepIndex, reason)); Failures.Add((stepIndex, reason));
public List<TimeSpan> Delays { get; } = new();
public void Delay(TimeSpan duration) => Delays.Add(duration);
} }

View File

@@ -154,25 +154,33 @@ baselines:
} }
[Fact] [Fact]
public void PlayerEngine_NullTarget_SkipsWithoutCalling() public void PlayerEngine_NullTarget_Fallback_Issue14()
{ {
// Issue #14: null-target fallbacks.
// - Type → send keystrokes to current focus (no target required)
// - Click w/ raw_coord → click at raw_coord screen-absolute
// - Click w/o raw_coord → still skipped (no way to click safely)
// - Drag → still skipped (no raw_coord pair handling yet)
var engine = new PlayerEngine(); var engine = new PlayerEngine();
var host = new FakePlayerHost(); var host = new FakePlayerHost();
var scenario = new Scenario var scenario = new Scenario
{ {
Steps = Steps =
{ {
new Step { Kind = StepKind.Click, Target = null }, new Step { Kind = StepKind.Click, Target = null }, // skip
new Step { Kind = StepKind.Drag, Target = null }, new Step { Kind = StepKind.Click, Target = null, RawCoord = new[] { 123, 456 } },
new Step { Kind = StepKind.Drag, Target = null }, // skip
new Step { Kind = StepKind.Type, Target = null, Value = "hello" }, new Step { Kind = StepKind.Type, Target = null, Value = "hello" },
}, },
}; };
engine.Run(scenario, host); engine.Run(scenario, host);
Assert.Empty(host.Clicks); Assert.Single(host.Clicks);
Assert.Equal(new ScreenPoint(123, 456), host.Clicks[0]);
Assert.Empty(host.Drags); Assert.Empty(host.Drags);
Assert.Empty(host.Types); Assert.Single(host.Types);
Assert.Equal("hello", host.Types[0]);
Assert.Empty(host.Failures); Assert.Empty(host.Failures);
} }

View File

@@ -38,6 +38,7 @@ public sealed class FakePlayerHost : IPlayerHost
} }
public void CaptureCheckpoint(int afterStep, string saveAs) { } public void CaptureCheckpoint(int afterStep, string saveAs) { }
public void CaptureFailureArtifacts(int stepIndex, string reason) { } public void CaptureFailureArtifacts(int stepIndex, string reason) { }
public void Delay(TimeSpan duration) { }
} }
public sealed class FakeHostFactory : IRunnerHostFactory public sealed class FakeHostFactory : IRunnerHostFactory

View File

@@ -0,0 +1,93 @@
using Recordingtest.Bridge;
using Recordingtest.Sut.EgBim.PluginHost;
using Xunit;
namespace Recordingtest.Sut.EgBim.PluginHost.Tests;
public class ChainedEngineStateProviderTests
{
private sealed class ScriptedProvider : IEngineStateProvider
{
public IReadOnlyList<string> SelectedIds = Array.Empty<string>();
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<string> 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());
}
}

View File

@@ -5,7 +5,8 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<RootNamespace>Recordingtest.EgPlugin.Tests</RootNamespace> <RootNamespace>Recordingtest.Sut.EgBim.PluginHost.Tests</RootNamespace>
<AssemblyName>Recordingtest.Sut.EgBim.PluginHost.Tests</AssemblyName>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
@@ -13,6 +14,7 @@
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\Recordingtest.EgPlugin\Recordingtest.EgPlugin.csproj" /> <ProjectReference Include="..\..\..\..\src\Sut\EgBim\Recordingtest.Sut.EgBim.PluginHost\Recordingtest.Sut.EgBim.PluginHost.csproj" />
<ProjectReference Include="..\..\..\..\src\Recordingtest.Bridge.Abstractions\Recordingtest.Bridge.Abstractions.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,149 @@
using Recordingtest.Bridge;
using Recordingtest.Sut.EgBim.PluginHost;
using Xunit;
namespace Recordingtest.Sut.EgBim.PluginHost.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<string> SelectedIds { get; set; } = Array.Empty<string>();
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<string> 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());
}
}

View File

@@ -1,8 +1,9 @@
using System.Net; using System.Net;
using Recordingtest.EgPlugin; using Recordingtest.Bridge;
using Recordingtest.Sut.EgBim.PluginHost;
using Xunit; using Xunit;
namespace Recordingtest.EgPlugin.Tests; namespace Recordingtest.Sut.EgBim.PluginHost.Tests;
public class StateRouterTests public class StateRouterTests
{ {