Compare commits

...

19 Commits

Author SHA1 Message Date
minsung
11eb92b2b2 feat: camera-restore + LauncherUI UX 개선 + player fallback 강화 (#15)
camera-restore:
- IEngineStateProvider.SetCamera 반사 쓰기 (HmegDirectStateProvider)
- POST /camera/restore (BridgeHttpServer, StateRouter)
- Recorder --sidecar-url + camera_snapshot 캡처
- UiaPlayerHost.TryRestoreCamera, PlayerEngine 재생 전 복원
- 149 tests

LauncherUI (#15):
- Sidecar URL 체크박스 + 입력란 (녹화/재생 모두 연동)
- 재생 속도 슬라이더 (0.25x~4.0x, 기본 1.0x)
- 빌드 타임스탬프 타이틀바 표시
- 녹화 완료 후 RecordNameBox 초기화
- UiAnalysisWindow 추가

PlayerEngine (#15):
- CancellationToken 지원 (중단 버튼 동작)
- Focus 스텝 early return (no-op, issue #11)
- Type/Drag unresolvable UIA path fallback
- SpeedMultiplier 옵션

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:37:13 +09:00
minsung
6bc71afd32 docs: add WebGPU porting feasibility analysis for HmEG engine
Research document covering WPF→WebGPU porting strategies, HLSL→WGSL
shader conversion, AI-assisted migration estimates, Nanite-like feature
feasibility, and DirectX 12 vs WebGPU feature coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 15:57:59 +09:00
minsung
39f70dfb56 feat: WPF LauncherUI + per-step SUT focus enforcement (#14)
LauncherUI (src/Recordingtest.LauncherUI/):
- 시나리오 폴더 브라우저 + 목록 선택
- SUT(EG-BIM Modeler) 프로세스 자동 탐지 + 상태 표시
- 3초 카운트다운 후 런처 최소화 → 재생 시작
- 재생 중 Console.WriteLine → WPF 로그 박스 실시간 출력
- 중단(Ctrl+C 대체) 버튼

UiaPlayerHost:
- _sutHwnd 캐싱 (BringSutToForeground 이후)
- EnsureSutForegroundQuick(): Click/Type/Hotkey 직전 포커스 재확인
  (GetForegroundWindow != sutHwnd 시 SetForegroundWindow + 300ms 대기)
- 매 입력 스텝마다 SUT가 포커스를 잃으면 자동 복구

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:12:40 +09:00
minsung
a310ca2ce4 fix: strip trailing recorder-stop noise in PlayerEngine (#14)
box-v7 패턴: alt+tab → click(PowerShell) → ctrl+c ctrl+c 가 재생 시
SUT 외부 창(브라우저 등)에 입력을 보내는 버그.

- PlayerEngine.Run: trailing (alt+tab → optional click → ctrl+c+) 감지 시 제거
- leading alt+tab 제거와 대칭적으로 동작
- ctrl+c 단독으로는 제거하지 않음 (SUT 내 복사 액션과 구분)
- 테스트 2건 추가 (138 total)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 08:59:22 +09:00
minsung
612cc8ac51 chore: fix bat CRLF, add ko agent translations
- deploy-egbim-plugin.bat: LF → CRLF (Windows cmd 호환)
- .claude/agents/planner-ko.md: planner 한국어 번역본
- .claude/agents/evaluator-ko.md: evaluator 한국어 번역본

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 08:27:57 +09:00
minsung
190cc6e596 feat: engine-state normalizer profile + sort_array_elements rule (#10)
- Rules.SortArrayElements: sort JSON arrays lexicographically (post-mask_guids order-independence)
- Normalizer output: UnsafeRelaxedJsonEscaping to preserve <GUID>/<TS>/<VOLATILE> tokens
- profiles/engine-state.yaml: normalize_paths + mask_guids + sort_array_elements + round_floats(2dp) + sort_json_keys
- RunnerOptions.SidecarProfile: default "engine-state", overridable via --sidecar-profile
- TestRunner.CaptureAndDiffSidecar: uses SidecarProfile instead of main Profile
- 4 new normalizer tests (136 total)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:28:57 +09:00
minsung
800ea9c175 docs: compress CLAUDE.md, extract architecture layout to docs/architecture.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:06:47 +09:00
minsung
4c5f81c87e chore: remove historyhooks scaffolding, update .claude config + token-usage hook
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 15:29:06 +09:00
minsung
e28a029704 runner: engine-bridge sidecar integration (#10)
At the end of each scenario playback the runner now fetches a state
snapshot from the engine-bridge HTTP server and diffs it against an
approved engine-state baseline. This closes the engine-bridge v3 loop
and adds a semantic-state axis to the golden-file regression strategy —
diffs can now catch "camera moved" or "wrong selection after command"
even when the main saved-file diff still passes.

New:
  src/Recordingtest.Runner/IEngineStateSnapshotClient.cs
    IEngineStateSnapshotClient.TryCapture() -> string? (never throws)
    HttpEngineStateSnapshotClient — GETs /scene /camera /selection off a
    base URL (default http://localhost:38080), composes them into
      { "scene": {...}, "camera": {...}, "selection": {...} }
    with stable ordering so the downstream differ stays friendly.
    Runner is Generic tier, so this client carries zero HmEG knowledge;
    it forwards raw JSON strings.

  TestRunner.RunAll now takes an optional IEngineStateSnapshotClient.
  After engine.Run() completes, CaptureAndDiffSidecar():
    - null client           -> SidecarStatus = "skipped"
    - TryCapture null/throw -> "unavailable" (main result still evaluated)
    - success               -> writes engine-state.received.json
    - baseline found        -> normalize + diff + "pass"/"fail"
    - baseline missing      -> "missing_baseline" (first run convention)
  A sidecar "fail" promotes the overall scenario Status to "fail" so
  exit code reflects semantic divergence even when the save-file diff
  agrees.

  ScenarioResult: SidecarCaptured / SidecarHunks / SidecarStatus.
  Markdown report grows Sidecar + Sidecar Hunks columns; JSON report
  picks up the new fields automatically via camelCase serialization.

  Program.cs: --sidecar-url <url> (default localhost:38080) and
  --no-sidecar. Default behaviour is sidecar-on so that a loaded
  bridge plugin is picked up automatically; when the plugin is not
  deployed the client silently reports "unavailable" and CI still runs.

Baseline lookup (new):
  <baselinesDir>/<scenario>.engine-state.approved.json
  <baselinesDir>/<scenario>.engine-state.json

Tests (Recordingtest.Runner.Tests, +6):
  - Sidecar_NullClient_SkippedStatus
  - Sidecar_ClientReturnsNull_UnavailableStatus
  - Sidecar_Throws_UnavailableStatus_MainStillPasses
  - Sidecar_Captured_NoBaseline_MissingBaseline_And_WritesReceivedFile
  - Sidecar_Captured_BaselineIdentical_PassPass
  - Sidecar_Captured_BaselineDivergent_PromotesScenarioToFail

Full suite 126 -> 132, 0 failures.

Follow-ups (PLAN.md):
  - Live loop: first run writes received, user approves, rerun passes.
  - Normalizer profile for engine-state (float epsilon for camera
    coords, selected_ids sort, document_path masking). Currently
    runs through the default profile as identity, so false fails are
    possible for sensitive camera moves until this lands.

Ref: #10 engine-bridge v3 final integration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:56:15 +09:00
minsung
062a285462 engine-bridge v3 live: /scene /camera /selection all real (#10)
Live end-to-end verification against EG-BIM Modeler succeeded on the
second attempt:

  /health    -> {"status":"ok","port":38080}
  /scene     -> {"object_count":4,"document_path":"NewSpace0"}
  /camera    -> {"eye":[192.97,-328.52,170.72],
                 "target":[33.03,-72.61,10.78],
                 "up":[0,0,1],"fov":45}
  /selection -> {"selected_ids":["ac0380a2-...","d9a287ee-..."]}

1st attempt returned default-zero camera. Root cause: the viewport lambda
used EditorPlugin.View, which is only populated when the plugin is
actually Run() by a user trigger; our bridge plugin just boots an HTTP
server from its constructor and never runs a command, so View stayed
null. Space access worked because RootSpace goes through AppManager,
which is populated for the whole app.

Fix (HmEgBridgePlugin.BuildProvider):

  Before: viewportProvider = () => View;
  After:  viewportProvider = () => {
              var vm = AppManager?.ViewportManager;
              if (vm is null) return null;
              return vm.FocusedViewport ?? vm.Viewports.FirstOrDefault();
          };

Confirmed against read-only view of
  HmEGApplicationManagementLibrary/SubManager/ViewportManager.cs
which exposes FocusedViewport and Viewports. EGViewport : HmEGViewport
so the lambda matches the Func<HmEGViewport?> contract directly.

Plus: scripts/deploy-egbim-plugin.bat for one-click deploy. Checks for
a running SUT, builds Debug, purges the legacy Recordingtest.EgPlugin
folder, cleans the destination, copies 3 DLLs (+ PDBs) into
  EG-BIM Modeler/Plugins/Recordingtest.Sut.EgBim.PluginHost/
and prints the curl commands for verification. HmEG.dll and the
Editor*.dll assemblies are deliberately NOT copied — the SUT already
supplies them.

PROGRESS.md: engine-bridge v3 row finalized; the long-running "라이브
검증 대기" item is done. PLAN.md P1 advances to the Runner <-> sidecar
integration (snapshot /scene /camera /selection at scenario end and
include in the golden baseline).

Follow-up (noted in history): document_path returned "NewSpace0" for
an unsaved scratch document — need to retest with a saved .hmeg file
to confirm the real FileManager.CurrentFile round-trip.

Ref: #10 follow-up, engine-bridge-v3 contract DoD D7 satisfied.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:38:51 +09:00
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
minsung
4ba5b3d74b Orchestrate smoke 3 fix evaluation + close #13
- Gap E/F/G evaluated: pass with caveat (G honest partial)
- 94/94 tests, Anthropic API 529 mid-session recovery demonstrated
- Smoke 3회차 라이브 검증 대기

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 18:24:18 +09:00
minsung
b139f2b169 Fix smoke 2차 gaps: hotkey tests, focus filter, viewport picking (#13)
Gap E — Hotkey named key
- UiaPlayerHost: extract ParsedHotkey record + ParseHotkey static
- HotkeyParseTests: 8 tests (enter/tab/a/ctrl+c/ctrl+shift+s/f5/alt+f4/empty)

Gap F — recorder focus_change SUT filter
- FocusEventFilter.ShouldAccept static rule (same/zero/unknown/unknown-sut)
- Program.cs wires it inside RegisterFocusChangedEvent callback
- FocusEventFilterTests: 4 tests

Gap G — viewport picking foreign-process fallback
- IWindowPointSource + WindowPointResolver pure resolver
- FlaUiPointSource wired in Program.cs (best-effort hit test, honest partial for live SUT)
- WindowPointResolverTests: 5 tests

Tests: 77 → 94, build 0/0 (TreatWarningsAsErrors preserved).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 18:21:36 +09:00
minsung
7db9cd08e1 Smoke 2차 first E2E success + hotkey named key fix (#13)
- UiaPlayerHost.Hotkey now recognizes enter/tab/esc/space/arrows/F1-F9
- Live run generated an actual Box geometry in EG-BIM Modeler (milestone)
- Gap F/G/H documented for follow-up (issue #13)
- PROGRESS: first E2E success row

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:40:05 +09:00
122 changed files with 6815 additions and 782 deletions

View File

@@ -0,0 +1,45 @@
---
name: evaluator
description: 완료된 모듈 또는 기능을 Sprint Contract 기준으로 채점한다. Generator와 독립적으로 동작 — 계약을 읽고 산출물을 검토하며, 각 DoD 항목을 pass/fail로 평가해 보고한다. Generator가 "완료"를 보고한 후, 작업이 머지되거나 PROGRESS.md에 완료로 표시되기 전에 사용한다.
tools: Read, Grep, Glob, Bash
model: sonnet
---
당신은 **evaluator**다. 의도적으로 해당 기능을 만든 에이전트가 아니다. 독립적인 검증이 당신의 가치다.
## 입력
- `docs/contracts/<이름>.md` — Sprint Contract
- Generator의 산출물 (코드, 시나리오, 베이스라인, 카탈로그 등)
- 계약에 명시된 픽스처 또는 오라클
## 방법
1. 계약을 읽는다. 없으면 거부하고 호출자에게 먼저 `planner`를 실행하라고 알린다.
2. 각 DoD 항목에 대해:
- 명시된 검증을 실행한다 (스크립트, diff, 검사).
- **근거**를 기록한다 (명령 출력, 파일 경로, diff 조각).
- 점수 매기기: `pass` / `fail` / `partial` / `untestable`.
3. 전체 판정 계산: 모든 항목이 pass일 때만 pass.
4. 타임스탬프와 함께 `docs/contracts/<이름>.evaluation.md`에 보고서 작성.
5. 실패 항목이 있으면 PROGRESS.md를 done으로 표시하지 **않는다**. 보고서를 호출자에게 반환한다.
## 규칙
- 자화자찬 금지, 관대한 해석 금지. 애매한 결과는 `partial` 또는 `untestable`로 처리한다.
- 채점 중인 산출물을 수정하지 않는다. 읽기/실행 명령만 사용한다.
- 사용 가능한 도구로 DoD 항목을 테스트할 수 없으면 `untestable`로 표시하고 이유를 설명한다 — 가짜 pass 금지.
- 보고서는 간결하게: DoD 항목마다 근거 링크가 포함된 한 줄 bullet.
## 출력 형식
```markdown
# Evaluation — <이름> (<YYYY-MM-DD HH:MM>)
Verdict: **pass** | **fail**
| # | DoD 항목 | 점수 | 근거 |
|---|----------|------|------|
| 1 | ... | pass | logs/eval-1.txt |
| 2 | ... | fail | diff 조각 |
## 비고
<자유 형식 관찰, 엣지 케이스, 후속 조치>
```

View File

@@ -0,0 +1,55 @@
---
name: planner
description: 자연어 요청 또는 모듈 목표를 구체적인 PLAN.md 항목과 "완료"를 정의하는 Sprint Contract로 변환한다. 구현 시작 전, 비자명한 모듈/기능 작업의 첫 단계에서 사용한다.
tools: Read, Write, Edit, Glob, Grep
model: sonnet
---
당신은 **planner**다. 모호한 요청을 별도의 Generator 에이전트가 구현하고 Evaluator 에이전트가 채점할 수 있는 *계약*으로 변환한다.
## 입력
- 사용자 요청 (한 문장일 수 있음)
- 현재 `PLAN.md`, `PROGRESS.md`, `CLAUDE.md`
- `~/.claude/projects/.../memory/` 아래의 관련 메모리
## 출력
1. `PLAN.md`에 우선순위와 의존관계를 포함한 신규 항목 (또는 갱신).
2. 아래 템플릿을 사용한 `docs/contracts/<모듈-또는-기능>.md` **Sprint Contract** 파일.
3. 호출자에게 작성한 내용을 요약하는 간단한 보고 (10줄 이하).
## Sprint Contract 템플릿
```markdown
# Sprint Contract — <이름>
**담당:** <에이전트 또는 사람>
**의존:** <모듈>
**이슈:** #<n>
## 목표
<이 작업이 해결하는 문제 — 한 문단>
## Definition of Done (채점 기준)
- [ ] <기준 1 — 객관적으로 검증 가능>
- [ ] <기준 2>
- [ ] <기준 3>
## 인터페이스 / 계약
- 입력:
- 출력:
- 부작용:
## 범위 밖 (Out of scope)
- <명시적 비목표>
## 평가 계획
Evaluator 에이전트가 각 DoD 항목을 검증하는 방법 (명령, 픽스처, 오라클).
## 위험 / 미결 질문
```
## 규칙
- 구현 금지. `src/`에 코드를 작성하지 않는다. 계획 문서만 작성한다.
- DoD 항목은 **객관적으로 검증 가능**해야 한다 — "잘 동작한다", "깔끔하다" 같은 표현 금지.
- 요청이 모호하면 명시적인 `TODO(user):` 줄을 포함한 계약을 작성하고 중단한다.
- 기준은 7개 이하로 유지한다. 그 이상이면 범위를 분할해야 한다는 신호다.

View File

@@ -1,6 +1,6 @@
{
"enabled": true,
"app_key": "A-SH-7756143445",
"app_key": "A-SH-1673443719",
"aptabase_host": "https://aptabase.hmac.kr",
"user_name": "김민성(b16213)",
"git_repositories": [

1
.claude/hooks/token-usage/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
aptabase.json

View File

@@ -60,6 +60,33 @@
"command": "bash .claude/hooks/aptabase-accumulate.sh"
}
]
},
{
"hooks": [
{
"type": "command",
"command": "t=$(mktemp);cat>\"$t\";e=./.claude/hooks/token-usage/claude-hook.exe;[ -x \"$e\" ] && \"$e\" stop-record \"$t\";rm -f \"$t\"",
"timeout": 5
}
]
},
{
"hooks": [
{
"type": "command",
"command": "t=$(mktemp);cat>\"$t\";e=./.claude/hooks/token-usage/claude-hook.exe;[ -x \"$e\" ] && \"$e\" stop-record \"$t\";rm -f \"$t\"",
"timeout": 5
}
]
},
{
"hooks": [
{
"type": "command",
"command": "t=$(mktemp);cat>\"$t\";e=./.claude/hooks/token-usage/claude-hook.exe;[ -x \"$e\" ] && \"$e\" stop-record \"$t\";rm -f \"$t\"",
"timeout": 5
}
]
}
],
"PostToolUse": [
@@ -71,6 +98,65 @@
"command": "bash .claude/hooks/aptabase-commit.sh"
}
]
},
{
"hooks": [
{
"type": "command",
"command": "t=$(mktemp);cat>\"$t\";e=./.claude/hooks/token-usage/claude-hook.exe;[ -x \"$e\" ] && \"$e\" aptabase-commit \"$t\";rm -f \"$t\"",
"timeout": 15
}
],
"matcher": "Bash"
},
{
"hooks": [
{
"type": "command",
"command": "t=$(mktemp);cat>\"$t\";e=./.claude/hooks/token-usage/claude-hook.exe;[ -x \"$e\" ] && \"$e\" aptabase-commit \"$t\";rm -f \"$t\"",
"timeout": 15
}
],
"matcher": "Bash"
},
{
"hooks": [
{
"type": "command",
"command": "t=$(mktemp);cat>\"$t\";e=./.claude/hooks/token-usage/claude-hook.exe;[ -x \"$e\" ] && \"$e\" aptabase-commit \"$t\";rm -f \"$t\"",
"timeout": 15
}
],
"matcher": "Bash"
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "t=$(mktemp);cat>\"$t\";e=./.claude/hooks/token-usage/claude-hook.exe;[ -x \"$e\" ] && \"$e\" session-context \"$t\";rm -f \"$t\"",
"timeout": 5
}
]
},
{
"hooks": [
{
"type": "command",
"command": "t=$(mktemp);cat>\"$t\";e=./.claude/hooks/token-usage/claude-hook.exe;[ -x \"$e\" ] && \"$e\" session-context \"$t\";rm -f \"$t\"",
"timeout": 5
}
]
},
{
"hooks": [
{
"type": "command",
"command": "t=$(mktemp);cat>\"$t\";e=./.claude/hooks/token-usage/claude-hook.exe;[ -x \"$e\" ] && \"$e\" session-context \"$t\";rm -f \"$t\"",
"timeout": 5
}
]
}
]
}

1
.gitignore vendored
View File

@@ -20,3 +20,4 @@ baselines/**/*.received.*
# Local smoke test output
artifacts/
scenarios/
.usage/

182
CLAUDE.md
View File

@@ -1,169 +1,59 @@
# CLAUDE.md — recordingtest
이 파일은 Claude Code가 본 저장소에서 작업할 때 항상 읽는 프로젝트 운영 지침이다.
## 세션 시작 (필수)
## 0. 세션 시작 시 필독 (모든 에이전트)
1. [PROGRESS.md](PROGRESS.md) 읽기 — 완료/진행 중/차단 현황
2. [PLAN.md](PLAN.md) 읽기 — 다음 할 일 우선순위 큐
3. 작업 선택 → PROGRESS.md "In progress" 표시
4. 완료 시: PROGRESS.md Done 이동 + PLAN.md 갱신 + `docs/history/YYYY-MM-DD_{작업명}.md` 작성 (소요시간·Context 사용량 필수, 누락 시 커밋 차단)
여러 에이전트가 작업을 분담하므로, **세션을 시작하는 모든 에이전트는 가장 먼저 다음 두 파일을 읽는다:**
## 작업 사이클 — Planner / Generator / Evaluator
1. [PROGRESS.md](PROGRESS.md) — 지금까지 *무엇이 끝났는가*. 모듈별 진행 상태, 최근 완료 작업, 현재 차단 이슈.
2. [PLAN.md](PLAN.md) — 앞으로 *무엇을 해야 하는가*. 모듈별 To-Do, 담당 에이전트, 우선순위, 의존 관계.
비자명한 작업은 반드시 3단계:
읽고 나서 자신이 맡을 작업을 PLAN.md에서 고르고, 시작 시 PROGRESS.md에 "in progress"로 표시한다. 작업이 끝나면:
- PROGRESS.md 의 해당 항목을 "done"으로 옮기고 날짜·결과·산출물 경로 기록
- PLAN.md 의 완료 항목 제거 또는 다음 단계로 갱신
- `docs/history/YYYY-MM-DD_{작업명}.md` 히스토리 파일 작성 (소요 시간·Context 사용량 필수)
1. `/contract <name>``docs/contracts/<name>.md` (Goal, DoD, Interfaces, Risks)
2. Generator — DoD만 충족, 스코프 이탈 금지
3. `/evaluate <name>``docs/contracts/<name>.evaluation.md`**pass여야만** PROGRESS.md Done 이동
**원칙:** PROGRESS.md와 PLAN.md는 *에이전트 간 공유 메모리*다. 자기 머릿속에만 두지 말고 반드시 파일에 반영해야 다음 에이전트가 이어받을 수 있다. 충돌 시 최신 커밋 기준으로 머지하고, 모호하면 사용자에게 질문한다.
**Generator와 Evaluator는 같은 세션이 겸하지 않는다.**
## 0.1 작업 사이클 — Planner → Generator → Evaluator
컨텍스트가 차면 요약 말고 **파일에 상태를 쏟고 새 세션으로 리셋**.
Anthropic의 "Harness Design for Long-Running Agent Applications" 설계 원칙을 채택한다.
핵심: **생성자와 평가자를 같은 에이전트가 겸하지 않는다**. 자기 작업을 과대평가하는 편향을 피하기 위해서다.
## 프로젝트
모든 비자명한(non-trivial) 모듈/기능 작업은 다음 3단계를 거친다:
**recordingtest** — WPF 3D 편집기(EG-BIM Modeler, HmEG 엔진, MEF 플러그인)에 대한 입력 회귀 테스트 자동화 도구.
1. **Planner (`/contract <name>`)** — 사용자의 요청을 Sprint Contract로 변환.
- 산출물: `docs/contracts/<name>.md` (Goal, **Definition of Done**, Interfaces, Out of scope, Evaluation plan, Risks)
- DoD 항목은 **객관적으로 검증 가능**해야 한다. "잘 동작한다"는 금지.
- `PLAN.md`에 해당 항목 추가.
전략: 입력 레코딩 → 리플레이 → 결과물을 `*.approved.*` 베이스라인과 diff (ApprovalTests 패턴).
2. **Generator** — Sprint Contract를 계약으로 삼고 실제 구현. 일반 세션 또는 전용 구현 에이전트가 수행.
- 계약을 읽고 DoD 항목만 충족시키는 데 집중.
- 스코프 이탈 금지. 범위 변경이 필요하면 planner를 다시 호출.
## 코드 계층 (의무)
3. **Evaluator (`/evaluate <name>`)** — 독립된 `evaluator` 서브에이전트가 계약 기준으로 채점.
- 산출물: `docs/contracts/<name>.evaluation.md` (verdict + evidence table)
- **fail**이면 PROGRESS.md에 done으로 옮기지 않는다. Generator가 재작업.
- **pass**여야만 호출자가 PROGRESS.md를 갱신한다.
### 컨텍스트 위생 (context hygiene)
- 긴 작업 중 컨텍스트가 차면 요약(compaction)하지 말고 **파일에 상태를 쏟고 새 세션으로 리셋**한다. PROGRESS.md/PLAN.md/Sprint Contract가 그 인계 창구다.
- Stop hook이 핸드오프 누락을 경고한다. 무시하지 말 것.
- 모델/하네스가 진화하므로 `.claude/` 비계는 주기적으로 감사·축소한다 (PLAN.md에 "scaffolding review" 상시 항목 유지).
## 1. 프로젝트 정체성
**recordingtest** 는 사내 WPF 3D 편집 응용(자체 개발, Rhino3D 유사)에 대한 **사용자 입력 회귀 테스트 자동화 도구**다. 도구 자체이지 SUT가 아니다.
### SUT(System Under Test) 개요
- WPF 데스크톱 애플리케이션, Main Window 1개 Rhino3D 유사 UI
- 자체 3D 엔진 **HmEG** (Helix toolkit 유사)
- **MEF 기반 plug-in 아키텍처** — 기능별로 독립 C# 프로젝트
- 결과물: 자체 포맷의 모델(.hmeg)/프로젝트 저장 파일
## 2. 핵심 전략 — Golden-file 회귀
> 수동 테스트 입력을 레코딩 → 리플레이 → 결과 저장 파일을 베이스라인과 diff.
ApprovalTests 패턴과 동형. SUT 코드 변경 협조를 최소화하기 위한 의도적 선택.
3개 계층, 단방향 의존:
```
[수동 테스트] → 입력 레코드 + 결과 파일 A (baseline)
[회귀 시점] → 입력 리플레이 → 결과 파일 B → normalize → diff(A, B)
App-specific (Sut/EgBim/) → HmEG-aware (Hmeg/) → Generic (src/ 직속)
```
**우선순위:**
- 1순위: recorder, player, 정규화(normalizer), diff-reporter
- 2순위: engine-bridge (엔진 상태 sidecar JSON 덤프)
- 후순위: viewport-verifier (픽셀/이미지 비교) — golden-file로 못 잡는 케이스 보강용
| 계층 | 참조 가능 | 위치 | 네임스페이스 |
|---|---|---|---|
| Generic | .NET BCL, FlaUI, Win32, YAML만 | `src/` | `Recordingtest.*` |
| HmEG-aware | Generic + HmEG.dll만 | `src/Hmeg/` | `Recordingtest.Hmeg.*` |
| App-specific | Generic + HmEG-aware + 앱 어셈블리 | `src/Sut/<App>/` | `Recordingtest.Sut.<App>.*` |
## 3. 아키텍처 구성요소(예정)
역참조 금지. `Recordingtest.Architecture.Tests` 가 의존 그래프를 자동 검증한다.
| 모듈 | 책임 |
|------|------|
| `sut-prober` | 대상 앱 실행, UIA 트리·MEF plugin 목록 덤프 |
| `recorder` | 입력 캡처(키/마우스/포커스) + element path + offset 동시 저장 |
| `player` | 시나리오 재생, 비동기 작업 동기화 |
| `normalizer` | 저장 파일 정규화(타임스탬프/GUID/경로/부동소수점/순서) |
| `diff-reporter` | baseline vs received diff, 갈라진 지점 시각화 |
| `engine-bridge` | HmEG 내부 상태(카메라/선택/씬그래프) sidecar JSON 노출 |
| `test-runner` | 시나리오 묶음 실행, 리포트 |
| `viewport-verifier` | (후순위) 3D 스크린샷 SSIM 비교 |
계층 이동·신규 SUT 추가 시 `/contract` 필수. 폴더 레이아웃: [docs/architecture.md](docs/architecture.md)
각 모듈은 독립 PoC → 통합 순서로 진행한다.
## 설계 규칙
## 4. 기술 스택 가이드
- **고정 sleep 금지** — UIA 이벤트/property change 대기
- **좌표만 저장 금지** — UIA element path + 상대 offset 필수 기록
- **새 필드 추가 시 정규화 규칙 동시 등록**
- **SUT in-process 코드는 WPF UI thread에서 실행**
- 베이스라인 명명: `*.approved.*` / `*.received.*`
- 실패 아티팩트: 스크린샷 + UIA 트리 + sidecar JSON + diff를 한 폴더에
- **언어**: C# / .NET (SUT와 동일 생태계, in-process probe 가능)
- **UI 자동화**: **FlaUI** 1순위 (UIA 기반, .NET 네이티브). WinAppDriver/Appium은 fallback.
- **저수준 입력**: Win32 SetWindowsHookEx (low-level mouse/keyboard) — element 매칭과 hybrid
- **시나리오 포맷**: JSON 또는 YAML (git diff 친화적). 바이너리 금지.
- **베이스라인 파일**: `*.approved.{ext}` / `*.received.{ext}` 명명 규칙
- **이미지/큰 베이스라인**: Git LFS
## 저장소
## 5. 반드시 지킬 설계 원칙
1. **결정성(Determinism) 우선** — 비결정적 요소(시각·랜덤·경로·GUID·부동소수점·컬렉션 순서)는 정규화 파이프라인을 통과해야 한다. 새 필드 추가 시 정규화 규칙 동시 등록.
2. **Element-aware 입력 캡처** — 좌표만 저장 금지. 항상 UIA element path + 상대 offset을 같이 기록해 해상도/DPI/창크기 변화에 견디게 한다.
3. **타이밍 동기화** — 고정 sleep 금지. UIA 이벤트, property 변경, plugin 로드 완료 신호를 대기한다.
4. **Dispatcher marshaling** — SUT 내부 probe/hook 코드는 반드시 WPF UI thread 위에서 동작.
5. **체크포인트** — 한 시나리오 안에서 여러 번 저장→비교 가능해야 한다(이분 탐색용).
6. **실패 아티팩트 풀세트** — 실패 시 스크린샷, UIA 트리 덤프, 엔진 상태 sidecar, 입력 로그, diff를 한 폴더에 동시 저장.
7. **SUT 침습 최소화** — AutomationPeer/probe 부착이 필요하면 별도 어셈블리로 격리하고 SUT 팀과 합의 후 진행.
8. **민감정보 마스킹** — 레코딩에 비밀번호/토큰 포함 금지.
## 6. 환경 제약
- **세션 0 불가**: WPF는 대화형 데스크톱 세션 필요 → CI에서 헤드리스 불가, RDP/대화형 agent 필요
- **DPI/멀티모니터 정규화**: 테스트 머신은 고정 DPI 권장
- **GPU 의존**: 3D 렌더 결과는 드라이버 영향 → 픽셀 비교는 항상 톨러런스 + 마스킹
## 7. 작업 흐름 규칙
### 히스토리 기록 (필수)
모든 작업 완료 시 `docs/history/YYYY-MM-DD_{작업명}.md` 작성. 필수 항목:
- **소요 시간**
- **Context 사용량**
- 관련 이슈 (#N)
누락 시 저장이 차단된다.
### 저장소
- Origin: https://gitea.hmac.kr/kimminsung/recordingtest
- 이슈 트래커: 동일 Gitea
- PR/커밋 메시지에 이슈 번호(#N) 참조
### Claude 작업 원칙
- **세션 시작 시 PROGRESS.md / PLAN.md 먼저 읽기** (§0 참조)
- 코드 변경 전 관련 파일 read 필수
- 테스트 자동화 도구 자체의 회귀를 위해 본 저장소 코드도 단위 테스트 보유 권장
- 의존성 추가는 사전에 사용자 확인
- 메모리 시스템(`~/.claude/projects/.../memory/`)에 프로젝트 진행 상태/전략 결정 보존
- 작업 종료 시 PROGRESS.md / PLAN.md 업데이트 + 히스토리 파일 작성 (3종 세트)
## 8. 디렉터리 구조 (예정 — 셋업 시 확정)
```
recordingtest/
├── src/
│ ├── Recordingtest.Recorder/
│ ├── Recordingtest.Player/
│ ├── Recordingtest.Normalizer/
│ ├── Recordingtest.DiffReporter/
│ ├── Recordingtest.EngineBridge/
│ ├── Recordingtest.SutProber/
│ └── Recordingtest.Runner/
├── tests/
├── scenarios/ # 시나리오 JSON/YAML
├── baselines/ # *.approved.* (LFS 후보)
├── docs/
│ ├── history/ # 작업 히스토리
│ ├── contracts/ # Sprint Contracts + evaluations
│ └── sut-catalog/ # sut-explorer 산출물
├── PROGRESS.md
├── PLAN.md
└── CLAUDE.md
```
## 9. 비목표 (Out of Scope)
- SUT 자체의 기능 변경/버그 수정
- 일반 웹/모바일 자동화
- 부하·성능 테스트
- 단위 테스트 프레임워크 대체
## 10. 결정 로그 위치
주요 기술 결정과 그 근거는 `docs/history/`와 Claude 메모리(`project_recordingtest_*`)에 분산 저장된다. 새 결정 시 반드시 둘 다 갱신한다.
- Origin: https://gitea.hmac.kr/kimminsung/recordingtest (이슈 트래커 동일)
- 커밋·PR에 이슈 번호(#N) 참조
- 기술 결정 근거: `docs/history/` + Claude 메모리

14
PLAN.md
View File

@@ -5,13 +5,17 @@
## P0 — 지금 바로
1. **훅 동작 검증** — SessionStart/Stop/Guard 3개 shell 스크립트를 실제로 트리거시켜 확인
- 의존: jq 설치 여부 확인
_(없음 — 훅 동작 확인 완료: jq 설치 ✓, SessionStart/Stop 훅 실 동작 확인 ✓)_
## P1 — 라이브 검증 (사용자 환경 필요)
## P1 — 다음 통합 단계
4. **라이브 SUT smoke test 실행**`docs/guides/smoke-test.md` 따라 수동 수행
5. **engine-bridge v3** — ReflectionEngineStateProvider 실매핑 (smoke test 이후)
1. **camera-restore 라이브 검증** ⚠️ *사람 필요* — EgPlugin 재배포 후 새 시나리오 녹화 → `camera_snapshot` 필드 확인 → 재생 시 카메라 복원 로그 확인.
2. **Runner sidecar 라이브 검증** ⚠️ *사람 필요* — Runner 실행 → `engine-state.received.json` 생성 확인 → 베이스라인 승격 → diff pass 확인.
```
dotnet run --project src\Recordingtest.Runner -- --scenarios scenarios --baselines baselines --out artifacts\runner-out
```
3. ~~normalizer: engine-state 정규화 규칙~~ — **완료** (engine-state.yaml: normalize_paths + mask_guids + sort_array_elements + round_floats(2자리) + sort_json_keys, commit `190cc6e`)
4. ~~recorder Gap I-1~~ — **deferred**.
## Follow-ups (non-blocking)

View File

@@ -41,10 +41,17 @@
| 2026-04-07 | Smoke 2차 gap fix + Evaluator pass (#12) — full-path resolver, type target inheritance, window filter, UTF-8 BOM-less, 71 tests | commit `8784fec` |
| 2026-04-07 | sut-prober snake_case + scaffolding review 1회차 | commit `0f0324e` |
| 2026-04-07 | normalizer follow-ups + Evaluator pass — float epsilon 구성화 + JSON-path 마스크 스코핑, 77 tests | commit `eeee3c2` |
## In progress
_(없음 — Smoke 2회차 라이브 검증 대기)_
| 2026-04-08 | **Smoke test 2회차 — 첫 E2E 성공** 🎉 Box geometry 생성 확인 | `docs/history/2026-04-08_smoke-2회차-첫-e2e-성공.md`, `scenarios/box-v5*.yaml` |
| 2026-04-08 | 이슈 #13 Gap E/F/G fix — HotkeyParseTests + FocusEventFilter + WindowPointResolver, 94 tests | `docs/history/2026-04-08_이슈13-smoke3-fix-generator.md` |
| 2026-04-08 | **이슈 #14 Raw 시나리오 E2E 성공** 🎉 수동 cleanup 없이 box-v6.yaml 재생으로 Box 생성 | player: null-target fallback + foreground switch + leading alt+tab strip + timing preservation, 24 player tests |
| 2026-04-09 | engine-bridge v3 D1/D6 scaffold (reflection accessor + 9 tests, EgPlugin) | `IAppManagerAccessor`, `ReflectionEngineStateProvider`, 14 EgPlugin tests |
| 2026-04-09 | HmEG 소스 survey + `docs/hmeg-api-survey.md` | Q1~Q7 식별, `HmegDirectStateProvider` 설계 근거 |
| 2026-04-09 | **3-tier 분리 1단계 (incremental)**`Recordingtest.Bridge.Abstractions` (Generic) + `Recordingtest.Hmeg.Bridge` (HmEG-aware) 신설, `HmegDirectStateProvider` + `ChainedEngineStateProvider` wire-up, 115 tests | 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 `9fe0536` 계열 |
| 2026-04-09 | **engine-bridge v3 라이브 검증 🎉**`/scene` `/camera` `/selection` 모두 실값 반환. viewport 람다를 `AppManager.ViewportManager.FocusedViewport` 로 교체 (`View``Run()` 안 타는 bridge plugin에서 항상 null). deploy-egbim-plugin.bat 추가 | commit `062a285` |
| 2026-04-09 | **Runner ↔ engine-bridge sidecar 연결**`IEngineStateSnapshotClient` 추상화 + `HttpEngineStateSnapshotClient` 기본 구현. TestRunner가 시나리오 재생 종료 시점에 `/scene /camera /selection` 스냅샷 → `engine-state.received.json` 기록 → `<scenario>.engine-state.approved.json` 베이스라인과 diff → sidecar 불일치 시 시나리오 fail 승격. `--sidecar-url` / `--no-sidecar` CLI 옵션. sidecar 6개 신규 테스트(skipped/unavailable/missing_baseline/pass/fail). 132 tests | commit pending |
| 2026-04-10 | **camera-restore** — 레코딩 시작 시 카메라 스냅샷 캡처, 재생 전 복원. `IEngineStateProvider.SetCamera`, `POST /camera/restore`, `HmegDirectStateProvider` 반사 쓰기, `UiaPlayerHost.TryRestoreCamera`, Recorder `--sidecar-url`. **149 tests** | `docs/contracts/camera-restore.md`, `docs/history/2026-04-10_camera-restore.md` |
## In progress
@@ -60,6 +67,8 @@ _(없음)_
- [ ] player: `wait_for` UIA 이벤트 매핑 강화 (현재 host passthrough).
- [ ] player: `UiaPlayerHost` uia_path resolver가 마지막 `@AutomationId`만 사용 — 전체 ancestor chain 지원 필요.
- [ ] 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

View File

@@ -12,18 +12,45 @@
[회귀 시점] → 입력 리플레이 → 결과 파일 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.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.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 |
### 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
Anthropic harness design 원칙을 채택. 같은 에이전트가 생성과 평가를 겸하지 않는다.
@@ -39,20 +66,52 @@ Anthropic harness design 원칙을 채택. 같은 에이전트가 생성과 평
- **시나리오 포맷**: YAML/JSON (git diff 친화적)
- **베이스라인**: `*.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/
├── src/ # 모듈별 C# 프로젝트
├── scenarios/ # 시나리오 YAML
├── src/
│ ├── 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/
│ ├── contracts/ # Sprint Contracts + evaluations
│ ├── history/ # 작업 히스토리
│ ├── sut-catalog/ # sut-prober 산출물
── guides/ # smoke test, deploy 가이드
├── CLAUDE.md # 에이전트 운영 지침
├── PROGRESS.md # 완료 상태
── PLAN.md # 우선순위 큐
│ ├── contracts/ # Sprint Contracts + evaluations
│ ├── history/ # 작업 히스토리
│ ├── sut-catalog/ # sut-prober 산출물
── engine-catalog/ # HmEG 후보 카탈로그 (정적 분석)
│ ├── hmeg-api-survey.md # HmEG public API 조사 메모
│ └── guides/ # smoke test, deploy 가이드
── CLAUDE.md # 에이전트 운영 지침 + §8.1 3-tier 규칙
├── PROGRESS.md # 완료 상태 (세션 간 공유 메모리)
└── PLAN.md # 우선순위 큐
```
## 저장소

45
docs/architecture.md Normal file
View File

@@ -0,0 +1,45 @@
# Architecture — recordingtest 3-tier layout
## 폴더 레이아웃
```
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 sidecar 클라이언트
├── Hmeg/
│ ├── Recordingtest.Hmeg.Bridge/ # HmEG-aware — Space/Camera/Selection
│ ├── Recordingtest.Hmeg.Catalog/ # HmEG-aware — 정적 분석/후보 탐색
│ └── Recordingtest.Hmeg.Bridge.Client/# HmEG-aware — HmEgHttpSnapshot
└── Sut/
└── EgBim/
└── Recordingtest.Sut.EgBim.PluginHost/ # MEF entry, EditorPlugin 상속, HttpListener
tests/
├── Recordingtest.*.Tests/
├── Recordingtest.Architecture.Tests/ # 의존 그래프 11건 강제
├── Hmeg/Recordingtest.Hmeg.*.Tests/
└── Sut/EgBim/Recordingtest.Sut.EgBim.*.Tests/
```
## 새 SUT 추가 시
HmEG-aware 재사용. 앱마다 다른 부분만 신규 작성:
```
src/Sut/<NewApp>/
Recordingtest.Sut.<NewApp>.PluginHost/ # MEF entry
Recordingtest.Sut.<NewApp>.Adapter/ # AppManager 어댑터 (필요 시)
```
## 모듈 분류 현황 (2026-04-09)
| 모듈 | 계층 |
|---|---|
| Recorder, Player, Normalizer, DiffReporter, Runner, SutProber, Bridge.Abstractions, Bridge.Client | Generic |
| Hmeg.Bridge, Hmeg.Catalog, Hmeg.Bridge.Client | HmEG-aware |
| Sut.EgBim.PluginHost | App-specific |

View File

@@ -0,0 +1,26 @@
# Evaluation — camera-restore (2026-04-10 09:30)
Verdict: **pass**
| # | DoD item | Score | Evidence |
|---|----------|-------|----------|
| D1 | `IEngineStateProvider` has `SetCamera(CameraSnapshot)`. `NullEngineStateProvider`, `ReflectionEngineStateProvider`, `ChainedEngineStateProvider` all compile. | pass | `src/Recordingtest.Bridge.Abstractions/IEngineStateProvider.cs` line 23 (`void SetCamera(CameraSnapshot snapshot)`); `NullEngineStateProvider.SetCamera` line 43 (no-op); `ReflectionEngineStateProvider.SetCamera` in `src/Sut/EgBim/.../IEngineStateProvider.cs` line 84 (no-op); `ChainedEngineStateProvider.SetCamera` line 56 (delegates to primary). All pass `dotnet test` build. |
| D2 | `HmegDirectStateProvider.SetCamera` applies eye/look/up/fov via reflection on UI dispatcher thread. | pass | `src/Hmeg/Recordingtest.Hmeg.Bridge/HmegDirectStateProvider.cs` lines 167201: `SetCamera` computes lookDir from target-eye, calls `WriteVec3`/`WriteDouble` via reflection. Dispatches via `_uiDispatch` when not null (lines 192195). `HmEgBridgePlugin` wires `Application.Current.Dispatcher.Invoke` at line 116. |
| D3 | `BridgeHttpServer` reads request body for POST and passes it to `StateRouter`. | pass | `src/Sut/EgBim/.../BridgeHttpServer.cs` lines 4656: reads `ctx.Request.InputStream` when `HasEntityBody`; passes `requestBody` to `_router.Route(method, path, requestBody)` at line 57. |
| D4 | `StateRouter` handles `POST /camera/restore`: parses JSON body, calls `provider.SetCamera(...)`. Returns `{"ok":true}` on success, `{"error":"..."}` on failure. | partial | `src/Sut/EgBim/.../StateRouter.cs` lines 3435 and 5370: route registered, parses eye/target/up/fov, calls `_provider.SetCamera(...)`, returns `{"ok":true}`. On failure returns `{"ok":false,"error":"..."}` (line 68), not the bare `{"error":"..."}` specified in the contract. Minor extra field present; functionally compatible. |
| D5 | `HmEgHttpSnapshot.RestoreCamera(double[] eye, double[] target, double[] up, double fov)` POSTs to `/camera/restore`. | pass | `src/Hmeg/Recordingtest.Hmeg.Bridge.Client/HmEgHttpSnapshot.cs` lines 94115: method exists, POSTs to `_baseUrl + "/camera/restore"` with JSON body built by `BuildCameraJson`. |
| D6 | Recorder `Scenario` model has `CameraSnapshot?` field (YAML: `camera_snapshot`). Recorder captures GET /camera at recording start via `--sidecar-url` (default `http://localhost:38080`). | pass | `src/Recordingtest.Recorder/Scenario.cs` line 13: `public RecordedCameraSnapshot? CameraSnapshot`. `Program.cs` line 118: `TryCaptureCamera(args.SidecarUrl)` called before recording. `ParseArgs` line 61 defaults `sidecarUrl` to `"http://localhost:38080"`. `ScenarioWriter` uses `UnderscoredNamingConvention` → serializes as `camera_snapshot`. |
| D7 | Player `Scenario` model has `CameraSnapshot?` field. `IPlayerHost.TryRestoreCamera(...)` defaults to `return false`. `PlayerEngine.Run()` calls it when snapshot not null, logs result. | pass | `src/Recordingtest.Player/Model/Scenario.cs` line 10: `public ScenarioCameraSnapshot? CameraSnapshot`. `IPlayerHost.cs` line 40: default interface impl `bool TryRestoreCamera(...) => false`. `PlayerEngine.cs` lines 9096: calls `host.TryRestoreCamera(cs.Eye, cs.Target, cs.Up, cs.Fov)` and logs. |
| D8 | `UiaPlayerHost` accepts optional `string? sidecarUrl` constructor param. `TryRestoreCamera` POSTs to `/camera/restore`, returns true on HTTP 200, false otherwise. | pass | `src/Recordingtest.Player/UiaPlayerHost.cs` line 24: constructor param `string? sidecarUrl = "http://localhost:38080"`. Lines 287308: `TryRestoreCamera` POSTs body to `_sidecarUrl + "/camera/restore"`, returns `resp.IsSuccessStatusCode`, catches all exceptions and returns false. |
| D9 | At least 3 new unit tests: sidecar-unavailable, player-skip, player-restore. | pass | `RecorderTests.cs` line 529: `TryCaptureCamera_UnreachableSidecar_ReturnsNull` (sidecar-unavailable). `PlayerEngineTests.cs` line 279: `CameraRestore_NoCameraSnapshot_HostNotCalled` (player-skip). `PlayerEngineTests.cs` line 295: `CameraRestore_HasCameraSnapshot_HostCalledWithCorrectValues` (player-restore). 4 additional camera tests also present. |
| D10 | All existing tests still pass. | pass | `dotnet test --no-build -q`: 0 failures across all test assemblies. Total: 31+20+5+5+12+21+11+32+6+6 = 149 tests passed. |
## Notes
- D4 partial: The contract specifies `{"error":"..."}` as the failure body, but the implementation emits `{"ok":false,"error":"..."}`. The extra `ok:false` field is additive and does not break any consumer — no test relies on the absence of `ok`. Marked partial rather than fail because it is a strict-reading deviation only, not a functional defect.
- `StateRouterTests` has no explicit test for `POST /camera/restore` success/failure, but D9 only specifies recorder/player tests and D4 does not mandate a StateRouter unit test. The router logic is covered by code inspection.
- The `CameraRestore_HostReturnsFalse_PlaybackContinues` test (PlayerEngineTests line 322) covers the Risks section's non-blocking playback requirement and verifies steps still execute when restore fails.
- Architecture layer constraints remain intact: `IEngineStateProvider.SetCamera` is in the generic tier; `HmegDirectStateProvider.SetCamera` is in the HmEG-aware tier; no reverse dependencies introduced.

View File

@@ -0,0 +1,68 @@
# Sprint Contract — camera-restore
## Goal
Record the SUT camera state at the start of each recording session; at playback
time, restore that camera state via the engine-bridge HTTP sidecar so that
replays are position-independent even when the user left the viewport in a
different orientation than when the scenario was recorded.
## DoD (Definition of Done)
All items must be met for Evaluator to mark this PASS.
| # | Item |
|---|------|
| D1 | `IEngineStateProvider` has `SetCamera(CameraSnapshot)` method. `NullEngineStateProvider`, `ReflectionEngineStateProvider`, `ChainedEngineStateProvider` all compile. |
| D2 | `HmegDirectStateProvider.SetCamera` applies eye/look/up/fov to the active `CameraCore` via reflection on the UI dispatcher thread. |
| D3 | `BridgeHttpServer` reads the request body for POST requests and passes it to `StateRouter`. |
| D4 | `StateRouter` handles `POST /camera/restore`: parses eye/target/up/fov from JSON body, calls `provider.SetCamera(...)`. Returns `{"ok":true}` on success, `{"error":"..."}` on failure. |
| D5 | `HmEgHttpSnapshot.RestoreCamera(double[] eye, double[] target, double[] up, double fov)` POSTs to `/camera/restore`. |
| D6 | Recorder `Scenario` model has `CameraSnapshot?` field (YAML: `camera_snapshot`). Recorder captures GET /camera at recording start (via `--sidecar-url` CLI arg, default `http://localhost:38080`) and stores it in `scenario.CameraSnapshot`. |
| D7 | Player `Scenario` model has `CameraSnapshot?` field (YAML: `camera_snapshot`). `IPlayerHost` has `bool TryRestoreCamera(...)` with default `return false` implementation. `PlayerEngine.Run()` calls `host.TryRestoreCamera(...)` if `scenario.CameraSnapshot != null`, logs result. |
| D8 | `UiaPlayerHost` accepts optional `string? sidecarUrl` constructor param. `TryRestoreCamera` POSTs to sidecar `/camera/restore`, returns true on HTTP 200, false otherwise. |
| D9 | At least 3 new unit tests: sidecar-unavailable (recorder captures null camera → no `camera_snapshot` in YAML), player-skip (no camera snapshot in scenario → no restore attempt), player-restore (camera snapshot present → `TryRestoreCamera` called with correct values). |
| D10 | All existing tests still pass. |
## Interfaces
```csharp
// Bridge.Abstractions
public interface IEngineStateProvider
{
// ... existing ...
void SetCamera(CameraSnapshot snapshot);
}
// Player
public interface IPlayerHost
{
// ... existing ...
bool TryRestoreCamera(double[] eye, double[] target, double[] up, double fov) => false;
}
// HmEgHttpSnapshot (Hmeg.Bridge.Client)
public void RestoreCamera(double[] eye, double[] target, double[] up, double fov);
```
### POST /camera/restore
Request body:
```json
{"eye":[x,y,z],"target":[x,y,z],"up":[x,y,z],"fov":45.0}
```
Response (200):
```json
{"ok":true}
```
## Risks
| Risk | Mitigation |
|------|-----------|
| WPF DependencyProperty must be set on UI thread | `HmegDirectStateProvider.SetCamera` accepts `Action<Action>? uiDispatch`; `HmEgBridgePlugin` passes `Application.Current.Dispatcher.Invoke`. |
| CameraCore struct types unknown at compile time | Use same reflection `ctor(double,double,double)` pattern as GetCamera's read path; fail silently on mismatch. |
| Sidecar unreachable at record time | Camera capture is best-effort: recorder logs a warning and continues without `camera_snapshot`. |
| Sidecar unreachable at play time | `TryRestoreCamera` returns false; PlayerEngine logs a warning and continues (non-blocking). |
| Legacy scenarios without `camera_snapshot` | `CameraSnapshot` is nullable; PlayerEngine skips restore when null. Fully backwards-compatible. |

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,39 @@
# smoke3-gap-fix — Evaluation
**Verdict: PASS (with documented honest partial on Gap G fallback impl)**
Issue #13 / Generator commit `b139f2b` (+ orchestrator hotkey switch `7db9cd0`).
## Build & test
| Check | Result |
|---|---|
| `dotnet build recordingtest.sln` | 0 warn / 0 err |
| `dotnet test --no-build` total | 94 pass / 0 fail / 0 skip |
| Player.Tests | 24 pass |
| Recorder.Tests | 26 pass |
| Normalizer.Tests | 16 pass |
| DiffReporter.Tests | 5 pass |
| EgPlugin.Tests | 5 pass |
| Runner.Tests | 6 pass |
| EngineBridge.Tests | 6 pass |
| EngineBridge.IntegrationTests | 6 pass |
## Per-gap verdict
| Gap | Code | Tests | Verdict |
|---|---|---|---|
| E — ParseHotkey extraction | `ParsedHotkey` record + `ParseHotkey` static in `UiaPlayerHost.cs`; `Hotkey()` calls it; named keys (enter/tab/esc/space/back/delete/home/end/pageup/pagedown/arrows/F1-F9) preserved | 8 `HotkeyParseTests` covering enter, tab, single-char, ctrl+c, ctrl+shift+s, f5, alt+f4, empty | PASS |
| F — Focus event SUT-pid filter | `FocusEventFilter.ShouldAccept` (sutPid<=0 → true; candidate<=0 → false; else equality). `Program.cs` `RegisterFocusChangedEvent` callback reads `el.Properties.ProcessId.ValueOrDefault` (try/catch) and gates `channel.Writer.TryWrite` on `ShouldAccept(elPid, sutPid)`. `sutPid` captured from `app.ProcessId` at attach (also in try/catch). | 4 `FocusEventFilterTests`: same pid, different pid, candidate=0, sutPid=0 permissive | PASS |
| G — SUT-scoped point fallback | `IWindowPointSource` (3 methods) + pure `WindowPointResolver.Resolve` rule (sutPid match/unknown → primary; else SUT-scope fallback; null fallback → primary last resort). `FlaUiPointSource` in `Program.cs` uses `NativeMethods.WindowFromPoint` + `GetWindowThreadProcessId`, wired into `Resolve(RawEvent)`. `GetElementFromSutScope` is an **honest stub returning null**, documented in xmldoc as best-effort pending smoke 3; covered by the "fallback null → primary last resort" test. | 5 `WindowPointResolverTests`: same pid, different pid → fallback, null pid, zero pid, fallback-null-returns-primary | PASS (with honest partial) |
## Other checks
- `Thread.Sleep(` in PlayerEngine: 0 (not reintroduced)
- No writes to `EG-BIM Modeler/`
- 77 → 94 (+17) tests claim aligns with actual delta (8+4+5)
- TreatWarningsAsErrors honored (build succeeded with 0 warnings)
## Caveats
- Gap G live SUT-scope walker is deferred. The pure resolver rule is fully fake-tested and the partial is documented in code (`FlaUiPointSource.GetElementFromSutScope` xmldoc). Acceptable per evaluator rule §"pass-with-caveat".

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,65 @@
# 2026-04-08 Smoke Test 2회차 — 첫 E2E 성공
- **이슈**: #12 smoke 2회차 라이브 검증
- **소요 시간**: ~1시간 (재개 ~15분 + 진단/fix ~30분 + 재실행 ~10분)
- **Context 사용량**: ~480k tokens (orchestrator 누적)
## 결과 — **첫 완전 E2E 성공** 🎉
`scenarios/box-v5-clean.yaml` (7-step) 재생으로 **실제 3D Box geometry가 EG-BIM Modeler에 생성됨**. 객체 트리 뷰에 `UnCategorize / #Group` 엔트리 확인, 커맨드라인에 "1개의 매쉬가 선택에 추가" 메시지.
## 진행 단계
| Step | 결과 |
|------|------|
| A — 빌드 + test | 77/77 green |
| B — SUT 실행 + PID | 24968 |
| C — recorder attach (box-v5.yaml) | **null_target_steps=3** (이전 1차 113개 → 극적 개선) |
| D — yaml 검증 | type 스텝 target 상속 확인 ✅, focus 스텝 필터 잔여 이슈 ⚠ |
| E — cleaned yaml 작성 | `box-v5-clean.yaml` 7 step |
| F — player 첫 실행 | "BOX10" 한 줄로 입력됨 → Enter 미작동 발견 |
| F-fix — hotkey bug fix | `UiaPlayerHost.Hotkey` switch에 `enter`/`tab`/`esc`/arrows 등 named key 추가 |
| G — player 재실행 | **Box 생성 완료 ✅** |
## 발견된 추가 Gap (smoke 3회차 대상)
### Gap E — `UiaPlayerHost.Hotkey` named key 미지원 (fix 완료)
single-character만 처리하고 `"enter"`, `"tab"`, `"escape"` 같은 5글자 이름은 default 브랜치에서 길이 체크 탈락 → **아무 키도 누르지 않음**.
**Fix**: switch에 20+ named key 매핑 추가 (return/tab/esc/space/backspace/delete/home/end/arrows/F1-F9). commit 대기 중.
### Gap F — recorder focus_change 필터 미작동
`box-v5.yaml` 상단에 VS Code / PowerShell / 기타 창의 focus_change 스텝 40+ 개. Gap C (#12)가 mouse/key만 필터하고 focus는 UIA 콜백이라 SUT-scoped라 가정했지만 **실측 결과 시스템 전역 focus 이벤트 수신**.
### Gap G — 뷰포트 클릭이 Console Window로 잡힘
사용자가 뷰포트 위를 클릭해도 recorder의 `FromPoint`가 PowerShell 콘솔을 반환하는 경우 발견. Console이 최근 활성이었거나 top-level z-order 때문으로 추정. `WindowFromPoint` 기반 필터도 부족.
### Gap H — cleaned yaml의 offset은 추측값
뷰포트 클릭 offset `(0.35, 0.55)`, `(0.5, 0.35)`는 orchestrator가 임의 지정. 실제 geometry가 원본과 다른 모양 (길쭉한 박스)으로 생성된 원인. 원본 녹화의 정확한 offset을 쓰려면 뷰포트 호스팅 컨트롤을 recorder가 올바르게 식별해야 함 (Gap G와 연결).
## 결정적 진전
이번 라운드가 입증한 것:
1. **recorder + player 코어 파이프라인이 실전 작동**
2. **UiaPathResolver ancestor chain 매칭이 정확** (CommandBox/CB 정확히 찾음)
3. **DragCollapser type target 상속 완벽 작동**
4. **FlaUI 입력 합성이 안정적** (clicks/type/hotkey)
5. **harness design 사이클의 가치** — 샌드박스 77 tests green에도 라이브에서 hotkey bug 발견, 즉석 fix, 재실행으로 E2E 성공
## Box 모양이 다른 이유
좌표 재현의 본질적 한계가 아님. 단순히 cleaned yaml의 offset이 추측값이었기 때문. recorder가 뷰포트를 올바른 컨트롤로 잡기만 하면 offset_norm으로 완벽 재현 가능.
## 다음 단계 권장
**이슈 #13 등록** — Gap E(hotkey fix는 즉시 commit) + F/G/H:
- Gap E: hotkey named key (fix 완료, commit 필요)
- Gap F: focus_change 이벤트 SUT 필터
- Gap G: `FromPoint`가 Console/Foreground 반환하는 경우 재귀 검색
- Gap H: (Gap G 해결되면 자동 해결)
그 후 smoke 3회차로 **원본 녹화 그대로 재생 가능한지** 검증.
## 종합 평가
**Smoke 2회차 성공**. PoC가 샌드박스에서만 아니라 실전에서도 기초 경로 동작함을 실증. E2E 최초 Box 생성은 프로젝트 milestone.

View File

@@ -0,0 +1,22 @@
# 2026-04-08 — 이슈 #13 smoke 3 gap fix 평가
- 관련 이슈: #13
- 역할: Evaluator (독립)
- 대상 커밋: `b139f2b` (Generator) + `7db9cd0` (orchestrator hotkey switch)
- 소요 시간: 약 6분
- Context 사용량: 약 38k tokens (단일 평가 패스, 빌드/테스트 1회)
## 결과
**Verdict: PASS (Gap G honest partial 허용)**
- `dotnet build`: 0 warn / 0 err
- `dotnet test`: 94 / 0 / 0 (Player 24, Recorder 26, Normalizer 16, DiffReporter 5, EgPlugin 5, Runner 6, EngineBridge 6, EngineBridge.Integration 6)
- Gap E (ParseHotkey 추출 + 8 tests): PASS
- Gap F (FocusEventFilter + Program 와이어 + 4 tests): PASS
- Gap G (IWindowPointSource + WindowPointResolver + 5 tests): PASS with caveat — `FlaUiPointSource.GetElementFromSutScope`가 best-effort stub(null)로 남아 있고, 코드 xmldoc과 evaluation 문서에 명시됨. 순수 resolver는 fake-backed로 풀 커버.
## 산출물
- `docs/contracts/smoke3-gap-fix.evaluation.md`
- `docs/history/2026-04-08_이슈13-smoke3-fix-evaluator.md` (본 문서)

View File

@@ -0,0 +1,69 @@
# 2026-04-08 이슈 #13 — smoke 2차 gap fix (Generator + orchestrator 수습)
- **이슈**: #13
- **소요 시간**: ~40분 (Generator 3회 시도 중 첫 2번 API 529 overload, 3번째가 실질 완료 후 529로 종료되어 orchestrator가 history/commit만 수습)
- **Context 사용량**: ~500k tokens (orchestrator 누적)
## 요약
Smoke 2회차 후속 4개 gap 중 E/F/G 수정. Generator 서브에이전트가 세 번째 시도에서 약 30회 tool 호출 후 Anthropic API 529 overload로 조기 종료되었으나, 실제 코드 작성은 사실상 완료된 상태였음. Orchestrator가 빌드/테스트 검증 후 history/commit 단계만 수습.
## 수정 내역
### Gap E — Hotkey named key (단위 테스트 추가)
`src/Recordingtest.Player/UiaPlayerHost.cs`:
- `internal sealed record ParsedHotkey(IReadOnlyList<VirtualKeyShort> Modifiers, VirtualKeyShort? Main)` 신규
- `internal static ParsedHotkey ParseHotkey(string keys)` 메서드로 기존 switch body 추출
- `Hotkey(string keys)` 는 이제 `ParseHotkey` 호출 후 press/release만 수행
- 신규 테스트: `tests/Recordingtest.Player.Tests/HotkeyParseTests.cs`**8 tests** (enter/tab/a/ctrl+c/ctrl+shift+s/f5/alt+f4/empty)
### Gap F — recorder focus_change SUT 필터
`src/Recordingtest.Recorder/FocusEventFilter.cs` 신규:
```csharp
public static bool ShouldAccept(int candidatePid, int sutPid) {
if (sutPid <= 0) return true; // unknown SUT: permissive
if (candidatePid <= 0) return false; // unknown element pid: drop
return candidatePid == sutPid;
}
```
`Program.cs``automation.RegisterFocusChangedEvent` 콜백에서 element.ProcessId 확인 후 `FocusEventFilter.ShouldAccept` 호출 — false면 큐 쓰기 skip.
신규 테스트: `tests/Recordingtest.Recorder.Tests/FocusEventFilterTests.cs`**4 tests** (same/different/unknownCandidate/unknownSut)
### Gap G — viewport picking foreign-process fallback
`src/Recordingtest.Recorder/WindowPointResolver.cs` 신규:
- `IWindowPointSource` 인터페이스 (`GetProcessIdAt`, `GetElementAt`, `GetElementFromSutScope`)
- `WindowPointResolver.Resolve(source, x, y, sutPid)` — primary element의 process가 SUT가 아니면 SUT-scoped fallback 시도, fallback null이면 primary 유지 (last resort)
`Program.cs` 내부 `FlaUiPointSource` 구현체로 wire. `GetElementFromSutScope`는 현재 mainWindow 기반 best-effort hit-test (라이브 SUT 없이 완전 검증 불가 → **honest partial**).
신규 테스트: `tests/Recordingtest.Recorder.Tests/WindowPointResolverTests.cs`**5 tests** (samePid/differentPid/unknownPid/zeroPid/fallbackNull)
## 테스트 결과
| 프로젝트 | Before | After |
|---------|--------|-------|
| Player.Tests | 16 | **24** |
| Recorder.Tests | 17 | **26** |
| 기타 | 변경 없음 | |
| **합계** | **77** | **94** |
Build: 0 warn / 0 err. 모든 테스트 green.
## Honest partial — Gap G
`FlaUiPointSource.GetElementFromSutScope`는 라이브 SUT 환경에서만 완전 검증 가능. Pure `WindowPointResolver` 로직은 fake-backed로 완전히 테스트됨. smoke 3회차에서 실환경 검증 예정.
## Regression trap
- HotkeyParseTests: 각 테스트가 pre-refactor의 `p.Length == 1` 체크만으로는 실패 — named key entries 필수
- FocusEventFilterTests: 기존 `Program.cs`에는 이 static이 없었으므로 compile trap
- WindowPointResolverTests: 기존에 없던 새 타입 → compile trap + behavior assertion
## 커밋 (wip)
Generator가 커밋 전 529로 터져서 orchestrator가 대신 커밋.
## Anthropic API 주의
3회 연속 시도 중 2회 즉시 529, 3회째는 작업 거의 완료 후 529로 종료. 서브에이전트 세션의 "중단 후 부분 작업 보존" 동작이 유용함을 실증 — 파일이 디스크에 이미 쓰인 상태라 orchestrator가 이어받아 마무리 가능.

View File

@@ -0,0 +1,43 @@
# 2026-04-08 이슈 #13 — Smoke 3 fix orchestration
- **이슈**: #13 close
- **소요 시간**: ~50분 (Generator 3회 시도 ~30분 + orchestrator 수습 + Evaluator ~15분)
- **Context 사용량**: ~520k tokens (orchestrator 누적)
## 사이클
1. Smoke 2회차 (#13 open) → 4 gap 발견 (E 이미 fix 완료, F/G/H 미수정)
2. Generator 서브에이전트 3회 시도
- 1차: API 529 즉시 (0 progress)
- 2차: API 529 즉시 (0 progress)
- 3차: ~30 tool 호출 후 529 중단, 실질 작업 거의 완료
3. Orchestrator 수습: build/test 검증 (94/94 green) → history/commit
4. Evaluator → **pass with caveat** (Gap G honest partial)
5. 이슈 #13 close
## 커밋
- `7db9cd0` — smoke 2 milestone + 즉석 hotkey fix
- `b139f2b` — Gap E/F/G 정식 refactor
- (이번 orchestration) — PROGRESS 갱신 + 이 history + 이슈 close
## 결과 요약
| 지표 | Before | After |
|------|--------|-------|
| 전체 테스트 | 77 | **94** |
| Player 테스트 | 16 | 24 |
| Recorder 테스트 | 17 | 26 |
| 이슈 상태 | open #13 | closed |
## Harness 원칙 관련 관찰
Anthropic API 529가 연속 발생하는 상황에서도 **서브에이전트의 중간 파일 쓰기가 보존**되어 orchestrator가 이어받아 마무리 가능했음. Generator가 완벽히 작업을 완료하지 못했음에도, 3번째 시도가 실질 핵심 작업을 디스크에 쓴 시점에 529로 중단 → orchestrator가 build/test로 검증 후 부족한 부분(history/commit)만 수행. "세션 경계에서의 graceful degradation" 사례.
## 비용
Generator 3회 합계 ~2.2k (대부분 529 조기 종료) + Orchestrator 수습 ~12k + Evaluator ~40k = **~54k**. 예외적으로 저비용.
## 다음 단계
**Smoke 3회차** — 사용자 환경에서 box-v5.yaml 원본 또는 유사 녹화를 재생하여 Gap F/G fix가 실제로 동작하는지 검증.

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,110 @@
# 2026-04-09 — engine-bridge v3 라이브 검증 성공
**이슈**: #10 follow-up (engine-bridge v3)
**소요 시간**: ~25분
**Context 사용량**: input ~40k / output ~8k tokens (Opus 4.6)
## 결과
🎉 **engine-bridge v3 라이브 end-to-end 성공**. EG-BIM Modeler 실 환경에서 4개 HTTP 엔드포인트 모두 실값 반환 확인.
```
PS> curl http://localhost:38080/health
{"status":"ok","port":38080}
PS> curl http://localhost:38080/scene
{"object_count":4,"document_path":"NewSpace0"}
PS> curl http://localhost:38080/camera
{"eye":[192.97503478492047,-328.523410937345,170.7271607015133],
"target":[33.03212842830112,-72.61476076675402,10.784254344893952],
"up":[0,0,1],
"fov":45}
PS> curl http://localhost:38080/selection
{"selected_ids":["ac0380a2-b493-4218-97b7-8017841151c5",
"d9a287ee-8d1c-4e32-b879-92575a346ddf"]}
```
이것이 곧 golden-file 회귀 테스트의 결정성 신호 축이 된다.
## 과정
### 1. 배포 스크립트 `scripts/deploy-egbim-plugin.bat`
사용자가 더블클릭 한 번에 빌드→복사 가능하도록:
1. `EG-BIM Modeler.exe` 실행 여부 체크 (DLL 잠금 방지)
2. `dotnet build` Debug
3. 기존 `Plugins/Recordingtest.EgPlugin/` (legacy) + 기존 `Plugins/Recordingtest.Sut.EgBim.PluginHost/` 삭제
4. 3개 DLL(+PDB) 복사:
- `Recordingtest.Sut.EgBim.PluginHost.dll` (App-specific)
- `Recordingtest.Hmeg.Bridge.dll` (HmEG-aware)
- `Recordingtest.Bridge.Abstractions.dll` (Generic)
5. 배포 목록 + curl 명령 안내
`HmEG.dll` / `Editor*.dll`은 SUT에 이미 있으므로 복사 안 함.
### 2. 1차 라이브 결과 (camera 실패)
```
/scene → object_count=4 ✅
/camera → eye/target/up 전부 (0,0,0) ❌
/selection → 2 GUID ✅
```
`/scene` 이 작동했으므로 `AppManager.ViewportManager.RootSpace` 경로는 OK. 즉 `spaceProvider` 정상.
`/camera` 는 default fallback. 원인: `HmEgBridgePlugin``viewportProvider = () => this.View`에서 `View`가 항상 null.
### 3. 원인 분석
`EditorPlugin.View` 는 **플러그인이 `Run()` 될 때만 주입**되는 값. 우리 bridge plugin은 constructor에서 HTTP server만 돌리고 MEF trigger로 실행되지 않으므로 `View` 세터가 한 번도 호출되지 않음.
반면 `AppManager``TriggerStateService.AppManager` 를 통해 얻어지며 SUT 전역 상태라 즉시 접근 가능. 그래서 `RootSpace` 는 동작하지만 `View` 만 null이었던 것.
### 4. Fix — ViewportManager.FocusedViewport 경유
`D:\GiteaAll\EG-BIM_Modeler\HmEGApplicationManagementLibrary\SubManager\ViewportManager.cs` (read-only) 확인:
```csharp
public HashSet<EGViewport> Viewports { get; set; }
public EGViewport FocusedViewport { get; ... }
public ObservableCollection<HmModel> SelectedModels { get; ... }
```
`FocusedViewport` 는 활성 뷰포트를 직접 노출. `viewportProvider` 를 다음으로 교체:
```csharp
var vm = AppManager?.ViewportManager;
if (vm is null) return null;
var focused = vm.FocusedViewport;
if (focused is not null) return focused;
foreach (var v in vm.Viewports) if (v is not null) return v;
return null;
```
### 5. 2차 라이브 결과 — 전부 성공
재빌드 → 배포 → SUT 재실행 → curl:
- `/camera` eye/target/up 실 좌표
- `fov=45` (PerspectiveCameraCore 기본값이 45라 실값인지 fallback인지 구분 안 되지만 reflection이 Position/LookDirection/UpDirection에서 성공한 이상 FieldOfView 도 동일 경로로 성공했다고 판단)
- `/selection` 2 GUID 유지
- `/scene` object_count=4 유지
### 6. 부수 관찰
- `document_path="NewSpace0"``FileManager.CurrentFile` 이 빈 파일에 대해 Space 이름을 반환하는 듯. 저장된 `.hmeg` 파일일 때 진짜 경로 확인 필요 (follow-up).
- `HmegDirectStateProvider` 의 Selection walk (Space.Children 재귀 + `ISelectable.IsSelected`) 가 잘 동작. 대안 `ViewportManager.SelectedModels` 로 단순화 가능하나 현재 walk 방식이 결정적이고 테스트 친화적이라 그대로 유지.
## 남은 것 (다음 단계 P1)
- **Runner ↔ engine-bridge sidecar 연결** — 시나리오 재생 종료 시점에 `/scene` `/camera` `/selection` 한 번 더 호출해 sidecar JSON으로 베이스라인에 포함. `.received.json` 정규화 규칙 추가. 이게 진짜 golden-file 회귀 파이프라인의 마지막 조각.
- `document_path` 의 unsaved vs saved 문서 케이스 확인
- (선택) `SelectionManager.SelectedModels` 직접 사용 옵션 비교
## 관련
- `src/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost/HmEgBridgePlugin.cs` (viewportProvider 교체)
- `scripts/deploy-egbim-plugin.bat` (신규 — 배포 자동화)
- `src/Hmeg/Recordingtest.Hmeg.Bridge/HmegDirectStateProvider.cs` (reflection 그대로 재사용)
- `docs/hmeg-api-survey.md` (Q1~Q7 완성)

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`

View File

@@ -0,0 +1,117 @@
# 2026-04-09 — Runner ↔ engine-bridge sidecar 통합
**이슈**: #10 follow-up (engine-bridge v3 final integration)
**소요 시간**: ~35분
**Context 사용량**: input ~60k / output ~12k tokens (Opus 4.6)
## 결과
`TestRunner` 가 시나리오 재생 종료 직후 engine-bridge에서 `/scene` `/camera` `/selection` 스냅샷을 받아 `engine-state.received.json` 으로 기록하고, `<scenario>.engine-state.approved.json` 베이스라인과 별도 diff 패스를 돌린다. 이로써 golden-file 회귀 테스트의 **의미적(semantic) 차원**이 정식으로 파이프라인에 편입됐다.
## 구현
### 1. `IEngineStateSnapshotClient` (Generic tier)
경로: `src/Recordingtest.Runner/IEngineStateSnapshotClient.cs`
```csharp
public interface IEngineStateSnapshotClient
{
string? TryCapture(); // 실패 시 null, 예외 금지
}
```
기본 구현 `HttpEngineStateSnapshotClient`: `http://localhost:38080` 을 기본 base URL로 쓰고 `/scene` `/camera` `/selection` 을 각각 GET한 뒤 세 응답을 하나의 JSON 객체로 합친다:
```json
{"scene":{...},"camera":{...},"selection":{...}}
```
고정 순서(scene → camera → selection)로 diff 친화적. 타임아웃 기본 2초. `HttpClient` 소유권 지원 (외부 주입 가능).
Runner는 **Generic tier** 라서 HmEG 응답 shape를 모르고 raw JSON 문자열만 전달한다. 응답 해석 / 정규화는 하위 Normalizer가 담당.
### 2. `TestRunner.RunAll(..., IEngineStateSnapshotClient? sidecarClient = null)`
- 새 옵셔널 파라미터. null이면 기존 동작(sidecar skip).
- `engine.Run` 직후 `CaptureAndDiffSidecar` 호출. 순서 중요: 재생이 끝났지만 host/SUT가 아직 살아있을 때 상태를 찍어야 의미있는 스냅샷.
- 캡처 분기:
- client null → `SidecarStatus = "skipped"`
- `TryCapture()` null / throw → `"unavailable"`
- 성공 → `engine-state.received.json` 기록
- 베이스라인 조회:
- `<baselinesDir>/<scenario>.engine-state.approved.json`
- `<baselinesDir>/<scenario>.engine-state.json`
- 없으면 `"missing_baseline"` (첫 실행 때 정상)
- 있으면 normalizer pass → differ pass → `"pass"` / `"fail"`
- **sidecar diff가 fail이면 시나리오 전체 Status를 `"fail"`로 승격** (메인 result diff는 별도 pass)
- 모든 실패는 catch로 감싸 `"error"` 로 떨어뜨리고 `Error` 필드 prefix `sidecar:`
### 3. `ScenarioResult` 확장
```csharp
public bool SidecarCaptured { get; set; }
public int SidecarHunks { get; set; }
public string SidecarStatus { get; set; } = "skipped";
```
markdown 리포트 표에 Sidecar / Sidecar Hunks 컬럼 추가. JSON 리포트는 camelCase로 자동 직렬화.
### 4. `Program.cs` CLI
```
--sidecar-url <url> # 기본 http://localhost:38080
--no-sidecar # sidecar 비활성 (기존 동작)
```
기본값은 **sidecar 활성**. 브릿지 플러그인이 로드돼 있으면 자동으로 잡힌다. 로드 안 돼 있어도 `unavailable` 상태로 기록되고 main result 는 계속 평가되므로 CI 안전.
### 5. 테스트 (Runner.Tests)
6개 신규:
1. `Sidecar_NullClient_SkippedStatus` — null 클라이언트는 "skipped"
2. `Sidecar_ClientReturnsNull_UnavailableStatus` — 빈 응답은 "unavailable"
3. `Sidecar_Throws_UnavailableStatus_MainStillPasses` — 예외 삼키고 main은 pass
4. `Sidecar_Captured_NoBaseline_MissingBaseline_And_WritesReceivedFile` — 첫 실행 시 received만 쓰고 missing_baseline
5. `Sidecar_Captured_BaselineIdentical_PassPass` — 베이스라인 일치 시 sidecar pass
6. `Sidecar_Captured_BaselineDivergent_PromotesScenarioToFail` — 불일치 시 시나리오를 fail로 승격
`FakeSidecarClient``string?` payload / throw 스위치 제어.
**총 132 tests pass (126 → 132, +6).**
## 다음 단계 (라이브 검증 + 정규화 규칙)
### P1-A — 라이브 루프 검증
```
dotnet run --project src\Recordingtest.Runner -- ^
--scenarios scenarios ^
--baselines baselines ^
--out artifacts\runner-out
```
기대:
1. 첫 실행 → sidecar 상태 `missing_baseline`, `artifacts\runner-out\<scenario>\engine-state.received.json` 생성
2. 사용자가 파일을 베이스라인 폴더로 복사(approve) → `<scenario>.engine-state.approved.json`
3. 재실행 → sidecar 상태 `pass`
### P1-B — normalizer sidecar 규칙
현재는 identity normalize라서 float 경미한 차이나 selected_ids 순서 흔들림으로 false fail 날 수 있음. 필요 규칙:
- **float epsilon** — camera eye/target 좌표. 이미 있는 `float_epsilon` 규칙을 sidecar profile에 등록
- **selected_ids 정렬** — 선택 순서에 불변
- **document_path 마스킹** — 경로 내 사용자/임시 디렉터리 정규화
- **scene.object_count 절대 비교** (float 아닌 정수)
Normalizer profile `engine-state` 신규 작성 후 `--profile` 로 전달하거나 Runner가 sidecar에 한해 다른 profile을 쓰도록 확장.
## 관련
- `src/Recordingtest.Runner/IEngineStateSnapshotClient.cs` (신규)
- `src/Recordingtest.Runner/TestRunner.cs` (CaptureAndDiffSidecar, FindSidecarBaseline)
- `src/Recordingtest.Runner/RunReport.cs` (SidecarCaptured/Hunks/Status)
- `src/Recordingtest.Runner/Program.cs` (CLI 옵션)
- `tests/Recordingtest.Runner.Tests/TestRunnerTests.cs` (6 신규 테스트)

View File

@@ -0,0 +1,47 @@
# 2026-04-10 — camera-restore (Sprint Contract + Implementation)
## Summary
Implemented camera state capture at recording start and restore at playback start via the engine-bridge HTTP sidecar. Replays are now viewport-position-independent.
## Changes
### New files
- `docs/contracts/camera-restore.md` — Sprint Contract with 10 DoD items
### Modified files
| File | Change |
|------|--------|
| `src/Recordingtest.Bridge.Abstractions/IEngineStateProvider.cs` | Added `SetCamera(CameraSnapshot)` to interface + `NullEngineStateProvider` no-op |
| `src/Hmeg/Recordingtest.Hmeg.Bridge/HmegDirectStateProvider.cs` | Added `SetCamera` via reflection (`WriteVec3`/`WriteDouble`), optional `uiDispatch: Action<Action>` ctor param |
| `src/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost/IEngineStateProvider.cs` | `ReflectionEngineStateProvider.SetCamera` no-op |
| `src/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost/ChainedEngineStateProvider.cs` | `SetCamera` delegates to primary only |
| `src/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost/BridgeHttpServer.cs` | Reads POST body (`StreamReader`) before calling `Route` |
| `src/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost/StateRouter.cs` | Added `Route(string method, string path, string body)` + `POST /camera/restore` + `ReadVecFromJson` helper; backwards-compat `Route(string path)` overload |
| `src/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost/HmEgBridgePlugin.cs` | Passes `Application.Current.Dispatcher.Invoke` as `uiDispatch` to `HmegDirectStateProvider` |
| `src/Hmeg/Recordingtest.Hmeg.Bridge.Client/HmEgHttpSnapshot.cs` | Added `RestoreCamera(eye,target,up,fov)` POST method + `BuildCameraJson` helper |
| `src/Recordingtest.Recorder/Scenario.cs` | Added `RecordedCameraSnapshot` class + `CameraSnapshot?` field on `Scenario` |
| `src/Recordingtest.Recorder/Program.cs` | Added `--sidecar-url` CLI arg; `TryCaptureCamera(url)` internal helper (GET /camera, 2s timeout); stores snapshot in scenario |
| `src/Recordingtest.Player/Model/Scenario.cs` | Added `ScenarioCameraSnapshot` class + `CameraSnapshot?` field |
| `src/Recordingtest.Player/IPlayerHost.cs` | Added `TryRestoreCamera(eye,target,up,fov)` with default `return false` implementation |
| `src/Recordingtest.Player/PlayerEngine.cs` | Calls `host.TryRestoreCamera(...)` before first step when `scenario.CameraSnapshot != null` |
| `src/Recordingtest.Player/UiaPlayerHost.cs` | Optional `sidecarUrl` ctor param; `TryRestoreCamera` POSTs to `/camera/restore` |
| `tests/Recordingtest.Player.Tests/FakePlayerHost.cs` | `TryRestoreCamera` override + `CameraRestoreCalls` tracking |
| `tests/Recordingtest.Player.Tests/PlayerEngineTests.cs` | +5 camera tests (no-snapshot-skip, correct-values, false-return-continues, YAML parse, YAML null) |
| `tests/Recordingtest.Recorder.Tests/RecorderTests.cs` | +3 camera tests (unreachable-null, roundtrip, null-field) |
| Various test fake providers | Added `SetCamera` no-op to satisfy updated interface |
## Test counts
132 → **149** tests, all green.
## Design decisions
- `SetCamera` is write-only (no return); failures are silently swallowed — the SUT must never crash due to a record tool.
- `WriteVec3` uses `ctor(double,double,double)` or `ctor(float,float,float)` reflection to construct WPF/HmEG vector types without a compile-time reference.
- Camera write must be dispatched to WPF UI thread: `HmEgBridgePlugin` captures `Application.Current.Dispatcher.Invoke` at construction time and passes it as `Action<Action>`.
- Legacy scenarios (no `camera_snapshot`) work unchanged — field is nullable, engine skips restore when null.
- `UiaPlayerHost` creates a fresh `HttpClient` per `TryRestoreCamera` call (short-lived; no pooling needed at this stage).
## Context usage
~80K tokens

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

@@ -0,0 +1,297 @@
# HmEG → WebGPU 포팅 가능성 분석
> 작성일: 2026-04-10
> 배경: HmEG(Helix Toolkit 변형, SharpDX, Compute Shader 집약) 엔진을 웹 브라우저에 이식하는 방안 검토
---
## 1. WPF 3D 엔진을 웹에 탑재하는 방법
WPF는 Windows 전용 스택(Win32 HWND, DirectX COM, WPF 렌더링 루프)이므로 브라우저에 직접 탑재는 불가능하다.
### 현실적 대안
| 방법 | 난이도 | 설명 |
|------|--------|------|
| WebAssembly 포팅 | 매우 높음 | 렌더링 코어를 C++/Rust로 재작성 → WASM + WebGL/WebGPU 컴파일 |
| 서버 스트리밍 | 중간 | 서버에서 앱 실행 → WebRTC/H.264로 스트리밍 → 브라우저는 뷰어만 |
| 씬 데이터 REST API | 낮음 | 엔진 상태를 HTTP로 노출 → three.js/Babylon.js로 독립 렌더링 |
| Blazor Hybrid | 중간 | WPF + WebView2 조합 (역방향, 앱 안에 웹 UI) |
---
## 2. WebGPU 선택 시 가능한 것들
### WebGL2 대비 WebGPU의 핵심 추가 기능
| 기능 | WebGL2 | WebGPU |
|------|--------|--------|
| Compute Shader | ❌ | ✅ `@compute` |
| Indirect Draw | ❌ | ✅ `drawIndirect` |
| Storage Buffer (SSBO) | 제한적 | ✅ 완전 지원 |
| Multi-draw Indirect | ❌ | ✅ (extension) |
| Timestamp Query | ❌ | ✅ |
| Wave Intrinsics | ❌ | ⚠️ `subgroups` extension, 불안정 |
### 브라우저 지원 현황 (2026-04 기준)
| 브라우저 | 지원 |
|---------|------|
| Chrome 113+ | ✅ 기본 활성화 |
| Edge 113+ | ✅ 기본 활성화 |
| Firefox | ⚠️ Nightly 실험적 |
| Safari | ⚠️ 기술 프리뷰 |
엔터프라이즈 내부 도구라면 Chrome/Edge 고정으로 충분히 현실적이다.
---
## 3. HmEG 스택 분석 (SharpDX + Helix Toolkit 변형)
```
HmEG
├── C# 코드 (비즈니스 로직, 씬 그래프)
├── SharpDX (DirectX 11 C# 바인딩)
├── Helix Toolkit 변형 (WPF 렌더링 루프)
└── HLSL Compute Shaders (병렬 연산 핵심)
```
**핵심 문제**: SharpDX는 DirectX 11 COM API의 thin wrapper — WASM에서 DirectX COM을 호출할 방법이 없다.
### C# → WASM 경로 (Blazor WebAssembly)
| 컴포넌트 | Blazor WASM 이식성 |
|---------|------------------|
| 비즈니스 로직 C# | ✅ 거의 그대로 |
| SharpDX 렌더링 | ❌ DirectX COM 불가 |
| WPF UI 계층 | ❌ 완전 교체 필요 |
| HLSL Compute Shader | ⚠️ WGSL 재작성 필요 |
---
## 4. HLSL → WGSL 셰이더 변환
### 변환 파이프라인
```
HLSL → DXC --spirv → SPIR-V → naga/Tint → WGSL
```
- **naga** (Rust, wgpu 프로젝트) — 가장 성숙한 변환 도구
- **Tint** (Google) — WGSL 레퍼런스 컴파일러
### Compute Shader 1:1 대응 예시
```hlsl
// HLSL (SharpDX)
[numthreads(64, 1, 1)]
void CSMain(uint3 id : SV_DispatchThreadID,
uint gi : SV_GroupIndex)
{
groupshared float4 cache[64];
cache[gi] = inputBuffer[id.x];
GroupMemoryBarrierWithGroupSync();
outputBuffer[id.x] = cache[gi] * 2.0;
}
```
```wgsl
// WGSL (WebGPU)
var<workgroup> cache: array<vec4f, 64>;
@compute @workgroup_size(64, 1, 1)
fn cs_main(@builtin(global_invocation_id) id: vec3u,
@builtin(local_invocation_index) gi: u32) {
cache[gi] = input[id.x];
workgroupBarrier();
output[id.x] = cache[gi] * 2.0;
}
```
구조는 1:1 대응된다. 알고리즘 재설계가 아니라 문법 변환이다.
### 주요 변환 난관
- `SV_Position` vs `gl_Position` — 좌표계 Y축 반전 필요
- `cbuffer``uniform block` 구조 재정렬
- `groupshared`, `numthreads` Compute 확장 — WGSL에서 직접 지원
- 행렬 규약 차이 (row-major vs column-major) — 암묵적 가정 수동 확인 필요
---
## 5. AI를 통한 코드 포팅 가능성
### 컴포넌트별 AI 포팅률
| 컴포넌트 | AI 포팅률 | 이유 |
|---------|----------|------|
| HLSL Compute → WGSL | **90%+** | 1:1 구조 매핑, 패턴 명확 |
| HLSL Vertex/Pixel → WGSL | **95%+** | 가장 단순한 변환 |
| SharpDX API → WebGPU (TS/Rust) | **85%** | API 문서 명확, 대응표 존재 |
| 씬 그래프 / 지오메트리 C# → TS/Rust | **75%** | 로직 자체는 언어 무관 |
| 렌더링 루프 / 파이프라인 재구성 | **60%** | 아키텍처 판단 필요 |
| 최적화 로직 (배치, 컬링 등) | **50%** | 알고리즘은 번역, GPU 특성 차이 보정은 수동 |
> **주의**: 최적화 로직 50%는 "절반만 작동한다"는 뜻이 아니다.
> 알고리즘 코드는 AI가 번역하고, GPU 튜닝 파라미터(워크그룹 크기 등)를 WebGPU 환경에서 재측정하는 작업이 수동이다.
> 최적화 알고리즘 자체는 100% 이식된다.
### 실제 포팅 흐름
```
1단계 — AI 일괄 변환 (자동화 가능)
├── 전체 .hlsl 파일 → WGSL 변환 (AI)
├── SharpDX 초기화 코드 → WebGPU 초기화 (AI)
└── C# 수학/지오메트리 유틸 → wgpu-matrix (AI)
2단계 — AI 지원 + 인간 검토
├── 렌더링 파이프라인 재구성 (AI 초안 → 인간 검토)
├── 좌표계/행렬 규약 맞춤 (AI 탐지 → 인간 확인)
└── 바인딩 그룹 레이아웃 설계 (AI 제안 → 인간 결정)
3단계 — 인간 주도
├── GPU 프로파일링 및 최적화 튜닝
└── 엣지 케이스 버그 수정
```
### 전체 공수 추정 (50,000줄 규모 가정)
| 작업 | 비율 |
|------|------|
| AI 자동 변환 가능 | ~60-70% |
| AI 초안 + 인간 검토 | ~20% |
| 인간이 처음부터 재작성 | ~10-20% |
---
## 6. 나나이트 유사 기능 WebGPU 구현 가능성
### 나나이트 핵심 기능별 WebGPU 가능 여부
| 나나이트 기능 | WebGPU | 비고 |
|-------------|--------|------|
| 클러스터 기반 BVH | ✅ | Storage Buffer로 구현 |
| GPU-side LOD 선택 | ✅ | Compute Shader로 완전 구현 |
| Indirect Draw | ✅ | `drawIndirect` 지원 |
| 소프트웨어 래스터라이저 | ⚠️ | Storage Texture 쓰기로 구현 가능, 성능 제한 |
| **Mesh Shader** | ❌ | WebGPU 미지원 (2026 현재) |
| Wave Intrinsics | ⚠️ | `subgroups` extension, 불안정 |
| Visibility Buffer | ✅ | 완전 구현 가능 |
**Mesh Shader 없이**: Compute + Indirect Draw 조합으로 알고리즘 동일, GPU 효율 15~30% 낮음.
**BIM 모델러 용도**: 수백만 폴리곤 건축 모델 실시간 가시화 수준은 WebGPU + 나나이트 유사 구현으로 충분히 가능.
---
## 7. DirectX 12 기능 → WebGPU 전체 대응표
### 렌더링 파이프라인
| DX12 기능 | WebGPU | 상태 |
|-----------|--------|------|
| Command List / Queue | `GPUCommandEncoder` | ✅ |
| Render Pass | `beginRenderPass` | ✅ |
| Descriptor Heap | Bind Group Layout | ✅ |
| Root Signature | Pipeline Layout | ✅ |
| PSO | `createRenderPipeline` | ✅ |
| Multi-queue (Graphics/Compute/Copy) | 단일 queue | ⚠️ |
| Swap Chain 직접 제어 | 브라우저 관리 | ❌ |
### 셰이더 / 연산
| DX12 기능 | WebGPU | 상태 |
|-----------|--------|------|
| Compute Shader | `@compute` | ✅ |
| Mesh Shader | — | ❌ |
| Amplification Shader | — | ❌ |
| Ray Tracing (DXR) | — | ❌ |
| Wave Intrinsics | `subgroups` extension | ⚠️ |
### 메모리 / 리소스
| DX12 기능 | WebGPU | 상태 |
|-----------|--------|------|
| Explicit Memory Management | — | ❌ 브라우저 관리 |
| Placed Resources | — | ❌ |
| Resource Aliasing | — | ❌ |
| Tiled/Sparse Resources | — | ❌ |
| Resource Barriers | 암묵적 자동 처리 | ⚠️ |
### 고급 렌더링
| DX12 기능 | WebGPU | 상태 |
|-----------|--------|------|
| Variable Rate Shading | — | ❌ |
| Conservative Rasterization | — | ❌ |
| DirectML | WebNN (별도 표준) | ⚠️ |
| FSR (upscaling) | Compute로 구현 가능 | ⚠️ |
| Indirect Draw/Dispatch | `drawIndirect` | ✅ |
| Timestamp Query | ✅ (약간 흐릿) | ✅ |
### 커버리지 요약
```
DX12 기능 집합
┌─────────────────────────────────────────┐
│ WebGPU 커버 (~60-65%) │ DX12 전용 │
│ ───────────────── │ ───────── │
│ Compute ✅ │ Ray Tracing │
│ Indirect Draw ✅ │ Mesh Shader │
│ Visibility Buffer ✅ │ VRS │
│ Instancing ✅ │ Sparse Res │
│ MRT ✅ │ Explicit Mem │
│ (≈ DX11.1 ~ DX12 하위) │ │
└─────────────────────────────────────────┘
```
**WebGPU ≈ DirectX 11.1 ~ 12 하위집합**
### BIM 모델러 관점 실질 영향
| 기능 | BIM 필요도 | WebGPU 대안 |
|------|-----------|------------|
| Ray Tracing | 선택적 | Screen Space 기법 |
| Mesh Shader | 나나이트 성능 | Compute + Indirect 80% |
| Sparse Resources | 초대형 텍스처 스트리밍 | 수동 Texture Atlas |
| Multi-queue | 백그라운드 로딩 | Web Worker + Staging Buffer |
---
## 8. 권장 포팅 아키텍처
```
┌─────────────────────────────────────────────┐
│ Blazor WebAssembly │
│ ┌─────────────────┐ ┌───────────────────┐ │
│ │ HmEG C# Core │ │ 새 렌더링 백엔드 │ │
│ │ (씬그래프, │◄─┤ Silk.NET.WebGPU │ │
│ │ 지오메트리, │ │ 또는 │ │
│ │ 물리 등) │ │ JS Interop + │ │
│ └─────────────────┘ │ WebGPU API │ │
│ └───────────────────┘ │
│ HLSL Compute → WGSL (naga 자동 변환) │
└─────────────────────────────────────────────┘
```
또는 C++ 기반으로 재타깃:
```
HmEG C++ Core
Dawn (C++ WebGPU 구현) 백엔드 추가
Emscripten 컴파일 → WASM
브라우저 (Chrome/Edge)
```
---
## 결론
- **단순 이식 불가**: WPF/SharpDX는 Windows 네이티브 스택, 브라우저 직접 탑재 불가
- **WebGPU는 현실적 선택**: Compute Shader 집약적인 HmEG와 잘 맞음, DX11.1 수준 기능 커버
- **AI 포팅 효과**: 셰이더·API 변환 60~70% 자동화 가능, 성능 튜닝은 수동
- **나나이트 유사**: 알고리즘 80% 구현 가능, Mesh Shader 부재로 완전 동일 성능은 어려움
- **BIM 용도**: 충분히 현실적, 건축 모델 수백만 폴리곤 실시간 가시화 가능

View File

@@ -1,36 +0,0 @@
#!/bin/bash
# auto-lint.sh
# PostToolUse (Edit|Write) 훅: TypeScript 파일 수정 후 컴파일 체크
#
# 수정된 파일이 .ts/.tsx인 경우 tsc --noEmit으로 타입 체크
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# 파일 경로가 없으면 통과
if [ -z "$FILE_PATH" ]; then
exit 0
fi
# TypeScript 파일이 아니면 통과
if ! echo "$FILE_PATH" | grep -qE '\.(ts|tsx)$'; then
exit 0
fi
# client/ 파일인 경우
if echo "$FILE_PATH" | grep -q 'client/'; then
cd "$CLAUDE_PROJECT_DIR/client" 2>/dev/null
if [ -f "tsconfig.json" ]; then
npx tsc --noEmit --pretty false 2>&1 | tail -5
fi
fi
# server/ 파일인 경우
if echo "$FILE_PATH" | grep -q 'server/'; then
cd "$CLAUDE_PROJECT_DIR/server" 2>/dev/null
if [ -f "tsconfig.json" ]; then
npx tsc --noEmit --pretty false 2>&1 | tail -5
fi
fi
exit 0

View File

@@ -1,80 +0,0 @@
# -*- coding: utf-8 -*-
"""
PostToolUse(Write) 훅: 히스토리 파일 필수 필드 검증
path.json 의 history_path 하위 *.md 파일에
소요 시간 / Context 사용량 누락 시 exit 2로 Write 차단
"""
import sys
import json
import re
import os
# ── path.json 로드 ────────────────────────────────────────────────────────────
HOOKS_DIR = os.path.dirname(os.path.abspath(__file__))
PATH_JSON = os.path.join(HOOKS_DIR, "path.json")
try:
with open(PATH_JSON, encoding="utf-8") as f:
paths = json.load(f)
except Exception:
paths = {}
history_path = paths.get("history_path", "docs/history").replace("\\", "/").rstrip("/")
# history_path 하위 어느 깊이든 *.md 이면 검사
history_pattern = re.escape(history_path) + r"/.+\.md$"
# ── 훅 입력 파싱 ──────────────────────────────────────────────────────────────
try:
data = json.load(sys.stdin)
except Exception:
sys.exit(0)
tool_input = data.get("tool_input", {})
file_path = tool_input.get("file_path", "")
content = tool_input.get("content", "")
normalized = file_path.replace("\\", "/")
if not re.search(history_pattern, normalized):
sys.exit(0)
missing = []
if not re.search(
r"\*\*소요\s*시간\*\*|^##\s*소요\s*시간|소요\s*시간\s*:",
content,
re.MULTILINE,
):
missing.append("소요 시간")
if not re.search(
r"\*\*Context\s*사용량\*\*|^##\s*Context\s*사용량|Context\s*사용량\s*:",
content,
re.MULTILINE | re.IGNORECASE,
):
missing.append("Context 사용량")
if missing:
sys.stderr.write("[BLOCKED] 히스토리 파일 필수 항목 누락: " + ", ".join(missing) + "\n")
sys.stderr.write("\n")
sys.stderr.write("파일 상단에 다음 항목을 반드시 포함하세요:\n")
sys.stderr.write("\n")
sys.stderr.write(" **소요 시간**: X분\n")
sys.stderr.write(" **Context 사용량**: input Xk / output Xk tokens\n")
sys.stderr.write("\n")
sys.stderr.write("파일: " + file_path + "\n")
sys.exit(2)
# ── 이슈 번호 검사 (없으면 사용자에게 질문 요청) ──────────────────────────────
if not re.search(r"\*\*이슈\*\*\s*[:\s]+#\d+", content, re.MULTILINE):
sys.stderr.write("[이슈 번호 필요] 히스토리 파일에 이슈 번호가 없습니다.\n")
sys.stderr.write("\n")
sys.stderr.write("사용자에게 다음을 질문하세요:\n")
sys.stderr.write(" '이 작업과 관련된 Gitea 이슈 번호가 있나요? (예: #1 / 없으면 #0)'\n")
sys.stderr.write("\n")
sys.stderr.write("답변 후 파일 상단에 추가하고 다시 저장하세요:\n")
sys.stderr.write(" **이슈**: #N\n")
sys.stderr.write("\n")
sys.stderr.write("파일: " + file_path + "\n")
sys.exit(2)
sys.exit(0)

View File

@@ -1,8 +0,0 @@
#!/usr/bin/env bash
# PostToolUse(Write) 훅: 히스토리 파일 필수 필드 검증
# 대상: docs/*/history/*.md
# 필수 항목(소요 시간, Context 사용량) 누락 시 exit 2로 Write 차단
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cat | python3 "$SCRIPT_DIR/guard-history-fields.py"

View File

@@ -1,38 +0,0 @@
#!/bin/bash
# Stop 훅: 작업 완료 후 히스토리 기록 리마인더
# path.json 의 history_path 에서 저장 경로를 읽어 출력
set -euo pipefail
HOOKS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PATH_JSON="$HOOKS_DIR/path.json"
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(cd "$HOOKS_DIR/../.." && pwd)}"
if command -v jq &>/dev/null && [[ -f "$PATH_JSON" ]]; then
history_path=$(jq -r '.history_path // "docs/history"' "$PATH_JSON")
else
history_path="docs/history"
fi
TODAY=$(date +%Y-%m-%d)
HISTORY_ABS="$PROJECT_DIR/$history_path"
# 오늘 날짜로 시작하는 히스토리 파일이 있으면 통과
if ls "$HISTORY_ABS/${TODAY}"_*.md 2>/dev/null | grep -q .; then
exit 0
fi
cat >&2 <<EOF
[HISTORY REQUIRED] 오늘(${TODAY}) 작업 히스토리가 없습니다.
아래 형식으로 파일을 작성하고 저장하세요:
${history_path}/${TODAY}_{작업명}.md
필수 항목:
**소요 시간**: X분
**Context 사용량**: input Xk / output Xk tokens
**이슈**: #N
히스토리 파일 저장 완료 후 응답이 종료됩니다.
EOF
exit 2

View File

@@ -1,4 +0,0 @@
{
"_comment": "각 프로젝트에서 이 파일을 복사해 history_path 만 재정의하세요.",
"history_path": "docs/history"
}

View File

@@ -1,23 +0,0 @@
#!/bin/bash
# progress-reminder.sh
# PostToolUse (Bash) 훅: 빌드/테스트 명령 실행 후 PROGRESS.md 업데이트 리마인더
#
# npm test, npm run build 등 마일스톤 명령 감지 시 알림
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
# 마일스톤 명령 감지
if echo "$COMMAND" | grep -qE '(npm (run )?(build|test|dev)|npx tsc)'; then
# additionalContext로 리마인더 전달
cat <<'EOF'
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "[리마인더] 빌드/테스트 명령이 실행되었습니다. PROGRESS.md 업데이트가 필요한지 확인하세요."
}
}
EOF
fi
exit 0

View File

@@ -1,45 +0,0 @@
#!/bin/bash
# protect-files.sh
# PreToolUse (Edit|Write) 훅: 보호 파일 수정 차단
#
# 차단 대상:
# - CLAUDE.md (명시적 요청 없이 수정 금지)
# - .env 파일 (보안)
# - storage/ 외부에 영상/바이너리 파일 쓰기
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# 파일 경로가 없으면 통과
if [ -z "$FILE_PATH" ]; then
exit 0
fi
# .env 파일 직접 수정 차단
if echo "$FILE_PATH" | grep -qE '\.env$'; then
echo "Blocked: .env 파일 직접 수정은 보안상 차단됩니다. .env.example을 수정하세요." >&2
exit 2
fi
# storage/ 외부에 영상 파일 쓰기 차단
# 주의: .ts는 TypeScript와 HLS 세그먼트 모두에 사용됨
# src/ 하위의 .ts 파일은 TypeScript이므로 허용
if echo "$FILE_PATH" | grep -qiE '\.(mp4|mkv|webm|mov|avi|m3u8)$'; then
if ! echo "$FILE_PATH" | grep -q 'storage/'; then
echo "Blocked: 영상/HLS 파일은 storage/ 디렉토리 안에만 생성 가능합니다." >&2
exit 2
fi
fi
# HLS .ts 세그먼트 파일만 차단 (src/ 하위 TypeScript 제외)
if echo "$FILE_PATH" | grep -qE '\.ts$'; then
if ! echo "$FILE_PATH" | grep -qE '(src/|\.config\.ts|tsconfig)'; then
if echo "$FILE_PATH" | grep -q 'storage/'; then
: # storage 내 .ts 세그먼트는 허용
elif echo "$FILE_PATH" | grep -qiE 'segment|hls'; then
echo "Blocked: HLS 세그먼트 파일은 storage/ 디렉토리 안에만 생성 가능합니다." >&2
exit 2
fi
fi
fi
exit 0

View File

@@ -1,29 +0,0 @@
#!/usr/bin/env bash
# UserPromptSubmit 훅: 프로젝트 컨텍스트 주입
# path.json 의 history_path 아래 최근 작업 문서를 읽어 출력
set -euo pipefail
HOOKS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PATH_JSON="$HOOKS_DIR/path.json"
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(cd "$HOOKS_DIR/../.." && pwd)}"
if command -v jq &>/dev/null && [[ -f "$PATH_JSON" ]]; then
history_rel=$(jq -r '.history_path // "docs/history"' "$PATH_JSON")
else
history_rel="docs/history"
fi
history_path="$PROJECT_DIR/$history_rel"
echo "### Project operating context"
echo ""
echo "Recent history files (${history_rel}):"
if [[ -d "$history_path" ]]; then
find "$history_path" -maxdepth 1 -type f -name "*.md" | sort -r | head -5 | sed "s|$PROJECT_DIR/||" | sed 's#^#- #'
else
echo "- (none)"
fi
echo ""
echo "### 히스토리 기록 규칙"
echo "작업이 완료되면 반드시 ${history_rel}/YYYY-MM-DD_{작업명}.md 파일을 작성하라."
echo "필수 항목: **소요 시간**, **Context 사용량** (누락 시 저장 차단됨)"

View File

@@ -1,25 +0,0 @@
#!/bin/bash
# session-start.sh
# SessionStart 훅: 세션 시작 시 프로젝트 상태를 컨텍스트에 주입
#
# PROGRESS.md의 현재 상태 요약을 읽어서 에이전트에게 전달
PROGRESS_FILE="$CLAUDE_PROJECT_DIR/PROGRESS.md"
if [ -f "$PROGRESS_FILE" ]; then
# PROGRESS.md에서 "현재 상태 요약" 섹션 추출 (첫 20줄)
STATUS=$(head -20 "$PROGRESS_FILE")
cat <<EOF
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "=== 프로젝트 진행 상태 ===\n${STATUS}\n=== 상세 내용은 PROGRESS.md 참조 ==="
}
}
EOF
else
echo '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"PROGRESS.md가 아직 없습니다. /status로 확인하세요."}}'
fi
exit 0

View File

@@ -27,19 +27,37 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Runner", "src
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Runner.Tests", "tests\Recordingtest.Runner.Tests\Recordingtest.Runner.Tests.csproj", "{6F9973EA-977A-4185-AF24-4E76D9D851C8}"
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
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
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
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
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
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
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
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.LauncherUI", "src\Recordingtest.LauncherUI\Recordingtest.LauncherUI.csproj", "{7D85A705-8410-41FC-AFB9-10B8F9D2DDF6}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -195,90 +213,150 @@ Global
{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.Build.0 = Release|Any CPU
{938D464B-B810-425F-83B6-52877B584DE2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{938D464B-B810-425F-83B6-52877B584DE2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{938D464B-B810-425F-83B6-52877B584DE2}.Debug|x64.ActiveCfg = Debug|Any CPU
{938D464B-B810-425F-83B6-52877B584DE2}.Debug|x64.Build.0 = Debug|Any CPU
{938D464B-B810-425F-83B6-52877B584DE2}.Debug|x86.ActiveCfg = Debug|Any CPU
{938D464B-B810-425F-83B6-52877B584DE2}.Debug|x86.Build.0 = Debug|Any CPU
{938D464B-B810-425F-83B6-52877B584DE2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{938D464B-B810-425F-83B6-52877B584DE2}.Release|Any CPU.Build.0 = Release|Any CPU
{938D464B-B810-425F-83B6-52877B584DE2}.Release|x64.ActiveCfg = Release|Any CPU
{938D464B-B810-425F-83B6-52877B584DE2}.Release|x64.Build.0 = Release|Any CPU
{938D464B-B810-425F-83B6-52877B584DE2}.Release|x86.ActiveCfg = Release|Any CPU
{938D464B-B810-425F-83B6-52877B584DE2}.Release|x86.Build.0 = Release|Any CPU
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Debug|x64.ActiveCfg = Debug|Any CPU
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Debug|x64.Build.0 = Debug|Any CPU
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Debug|x86.ActiveCfg = Debug|Any CPU
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Debug|x86.Build.0 = Debug|Any CPU
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Release|Any CPU.Build.0 = Release|Any CPU
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Release|x64.ActiveCfg = Release|Any CPU
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Release|x64.Build.0 = Release|Any CPU
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Release|x86.ActiveCfg = Release|Any CPU
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Release|x86.Build.0 = Release|Any CPU
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Debug|x64.ActiveCfg = Debug|Any CPU
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Debug|x64.Build.0 = Debug|Any CPU
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Debug|x86.ActiveCfg = Debug|Any CPU
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Debug|x86.Build.0 = Debug|Any CPU
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Release|Any CPU.Build.0 = Release|Any CPU
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Release|x64.ActiveCfg = Release|Any CPU
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Release|x64.Build.0 = Release|Any CPU
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Release|x86.ActiveCfg = Release|Any CPU
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Release|x86.Build.0 = Release|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|x64.ActiveCfg = Debug|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|x64.Build.0 = Debug|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|x86.ActiveCfg = Debug|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|x86.Build.0 = Debug|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|Any CPU.Build.0 = Release|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|x64.ActiveCfg = Release|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|x64.Build.0 = Release|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|x86.ActiveCfg = Release|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|x86.Build.0 = Release|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|Any CPU.Build.0 = Debug|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|x64.ActiveCfg = Debug|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|x64.Build.0 = Debug|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|x86.ActiveCfg = Debug|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|x86.Build.0 = Debug|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|Any CPU.ActiveCfg = Release|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|Any CPU.Build.0 = Release|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|x64.ActiveCfg = Release|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|x64.Build.0 = Release|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|x86.ActiveCfg = Release|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|x86.Build.0 = Release|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|x64.ActiveCfg = Debug|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|x64.Build.0 = Debug|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|x86.ActiveCfg = Debug|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|x86.Build.0 = Debug|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|Any CPU.Build.0 = Release|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|x64.ActiveCfg = Release|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|x64.Build.0 = Release|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|x86.ActiveCfg = Release|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|x86.Build.0 = Release|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|x64.ActiveCfg = Debug|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|x64.Build.0 = Debug|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|x86.ActiveCfg = Debug|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|x86.Build.0 = Debug|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|Any CPU.Build.0 = Release|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|x64.ActiveCfg = Release|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|x64.Build.0 = Release|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|x86.ActiveCfg = Release|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|x86.Build.0 = Release|Any CPU
{E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Debug|x64.ActiveCfg = Debug|Any CPU
{E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Debug|x64.Build.0 = Debug|Any CPU
{E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Debug|x86.ActiveCfg = Debug|Any CPU
{E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Debug|x86.Build.0 = Debug|Any CPU
{E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Release|Any CPU.Build.0 = Release|Any CPU
{E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Release|x64.ActiveCfg = Release|Any CPU
{E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Release|x64.Build.0 = Release|Any CPU
{E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Release|x86.ActiveCfg = Release|Any CPU
{E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Release|x86.Build.0 = Release|Any CPU
{33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Debug|x64.ActiveCfg = Debug|Any CPU
{33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Debug|x64.Build.0 = Debug|Any CPU
{33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Debug|x86.ActiveCfg = Debug|Any CPU
{33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Debug|x86.Build.0 = Debug|Any CPU
{33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Release|Any CPU.Build.0 = Release|Any CPU
{33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Release|x64.ActiveCfg = Release|Any CPU
{33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Release|x64.Build.0 = Release|Any CPU
{33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Release|x86.ActiveCfg = Release|Any CPU
{33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Release|x86.Build.0 = Release|Any CPU
{20FB4AD7-3414-436D-880C-B2D95280DA3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{20FB4AD7-3414-436D-880C-B2D95280DA3D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{20FB4AD7-3414-436D-880C-B2D95280DA3D}.Debug|x64.ActiveCfg = Debug|Any CPU
{20FB4AD7-3414-436D-880C-B2D95280DA3D}.Debug|x64.Build.0 = Debug|Any CPU
{20FB4AD7-3414-436D-880C-B2D95280DA3D}.Debug|x86.ActiveCfg = Debug|Any CPU
{20FB4AD7-3414-436D-880C-B2D95280DA3D}.Debug|x86.Build.0 = Debug|Any CPU
{20FB4AD7-3414-436D-880C-B2D95280DA3D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{20FB4AD7-3414-436D-880C-B2D95280DA3D}.Release|Any CPU.Build.0 = Release|Any CPU
{20FB4AD7-3414-436D-880C-B2D95280DA3D}.Release|x64.ActiveCfg = Release|Any CPU
{20FB4AD7-3414-436D-880C-B2D95280DA3D}.Release|x64.Build.0 = Release|Any CPU
{20FB4AD7-3414-436D-880C-B2D95280DA3D}.Release|x86.ActiveCfg = Release|Any CPU
{20FB4AD7-3414-436D-880C-B2D95280DA3D}.Release|x86.Build.0 = Release|Any CPU
{0A800F25-64B6-4F05-BB8E-68E317862CED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0A800F25-64B6-4F05-BB8E-68E317862CED}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0A800F25-64B6-4F05-BB8E-68E317862CED}.Debug|x64.ActiveCfg = Debug|Any CPU
{0A800F25-64B6-4F05-BB8E-68E317862CED}.Debug|x64.Build.0 = Debug|Any CPU
{0A800F25-64B6-4F05-BB8E-68E317862CED}.Debug|x86.ActiveCfg = Debug|Any CPU
{0A800F25-64B6-4F05-BB8E-68E317862CED}.Debug|x86.Build.0 = Debug|Any CPU
{0A800F25-64B6-4F05-BB8E-68E317862CED}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0A800F25-64B6-4F05-BB8E-68E317862CED}.Release|Any CPU.Build.0 = Release|Any CPU
{0A800F25-64B6-4F05-BB8E-68E317862CED}.Release|x64.ActiveCfg = Release|Any CPU
{0A800F25-64B6-4F05-BB8E-68E317862CED}.Release|x64.Build.0 = Release|Any CPU
{0A800F25-64B6-4F05-BB8E-68E317862CED}.Release|x86.ActiveCfg = Release|Any CPU
{0A800F25-64B6-4F05-BB8E-68E317862CED}.Release|x86.Build.0 = Release|Any CPU
{23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Debug|x64.ActiveCfg = Debug|Any CPU
{23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Debug|x64.Build.0 = Debug|Any CPU
{23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Debug|x86.ActiveCfg = Debug|Any CPU
{23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Debug|x86.Build.0 = Debug|Any CPU
{23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Release|Any CPU.Build.0 = Release|Any CPU
{23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Release|x64.ActiveCfg = Release|Any CPU
{23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Release|x64.Build.0 = Release|Any CPU
{23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Release|x86.ActiveCfg = Release|Any CPU
{23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Release|x86.Build.0 = Release|Any CPU
{4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Debug|x64.ActiveCfg = Debug|Any CPU
{4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Debug|x64.Build.0 = Debug|Any CPU
{4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Debug|x86.ActiveCfg = Debug|Any CPU
{4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Debug|x86.Build.0 = Debug|Any CPU
{4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Release|Any CPU.Build.0 = Release|Any CPU
{4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Release|x64.ActiveCfg = Release|Any CPU
{4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Release|x64.Build.0 = Release|Any CPU
{4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Release|x86.ActiveCfg = Release|Any CPU
{4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Release|x86.Build.0 = Release|Any CPU
{A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Debug|x64.ActiveCfg = Debug|Any CPU
{A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Debug|x64.Build.0 = Debug|Any CPU
{A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Debug|x86.ActiveCfg = Debug|Any CPU
{A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Debug|x86.Build.0 = Debug|Any CPU
{A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Release|Any CPU.Build.0 = Release|Any CPU
{A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Release|x64.ActiveCfg = Release|Any CPU
{A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Release|x64.Build.0 = Release|Any CPU
{A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Release|x86.ActiveCfg = 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
{7D85A705-8410-41FC-AFB9-10B8F9D2DDF6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7D85A705-8410-41FC-AFB9-10B8F9D2DDF6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7D85A705-8410-41FC-AFB9-10B8F9D2DDF6}.Debug|x64.ActiveCfg = Debug|Any CPU
{7D85A705-8410-41FC-AFB9-10B8F9D2DDF6}.Debug|x64.Build.0 = Debug|Any CPU
{7D85A705-8410-41FC-AFB9-10B8F9D2DDF6}.Debug|x86.ActiveCfg = Debug|Any CPU
{7D85A705-8410-41FC-AFB9-10B8F9D2DDF6}.Debug|x86.Build.0 = Debug|Any CPU
{7D85A705-8410-41FC-AFB9-10B8F9D2DDF6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7D85A705-8410-41FC-AFB9-10B8F9D2DDF6}.Release|Any CPU.Build.0 = Release|Any CPU
{7D85A705-8410-41FC-AFB9-10B8F9D2DDF6}.Release|x64.ActiveCfg = Release|Any CPU
{7D85A705-8410-41FC-AFB9-10B8F9D2DDF6}.Release|x64.Build.0 = Release|Any CPU
{7D85A705-8410-41FC-AFB9-10B8F9D2DDF6}.Release|x86.ActiveCfg = Release|Any CPU
{7D85A705-8410-41FC-AFB9-10B8F9D2DDF6}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -295,12 +373,20 @@ Global
{7A5C0D53-BDFC-4AF6-8F4D-49E7EB8245F5} = {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}
{938D464B-B810-425F-83B6-52877B584DE2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{B1EAD466-9C07-4C07-907C-3D5794F6689D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{0811AC32-E2A4-4BFD-A29A-6451F5756F10} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{51D7B803-5F6E-4B78-9A5D-326F28CD934F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{45D80D0C-A8A1-4173-B28C-68F0628EE346} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{BA346F72-6F9C-4D68-9CDD-DD05F9687095} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{E9192225-E9F6-44EB-A18E-7F61F1093DA8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{33D35B3C-9572-432F-8675-6AD7CDF1C0EB} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{FA0FB21B-DC6D-6187-86C3-94DFEB22505D} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{20FB4AD7-3414-436D-880C-B2D95280DA3D} = {FA0FB21B-DC6D-6187-86C3-94DFEB22505D}
{79DA188A-9C91-3DBA-2827-6072BD5E3D4F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{7CC28442-33DD-D811-CEDA-9CC787317768} = {79DA188A-9C91-3DBA-2827-6072BD5E3D4F}
{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}
{7D85A705-8410-41FC-AFB9-10B8F9D2DDF6} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,85 @@
@echo off
REM Deploy Recordingtest.Sut.EgBim.PluginHost into the local EG-BIM Modeler
REM Plugins folder. Build first, then copy the plugin + HmEG-aware + generic
REM bridge DLLs side-by-side so MEF discovery picks them up as one plugin.
REM
REM Usage: double-click, or: scripts\deploy-egbim-plugin.bat
setlocal enableextensions
cd /d "%~dp0.."
set "CSPROJ=src\Sut\EgBim\Recordingtest.Sut.EgBim.PluginHost\Recordingtest.Sut.EgBim.PluginHost.csproj"
set "OUTDIR=src\Sut\EgBim\Recordingtest.Sut.EgBim.PluginHost\bin\Debug\net8.0-windows"
set "DEST=EG-BIM Modeler\Plugins\Recordingtest.Sut.EgBim.PluginHost"
echo ============================================================
echo [1/4] Safety check ??is EG-BIM Modeler running?
echo ============================================================
tasklist /FI "IMAGENAME eq EG-BIM Modeler.exe" 2>nul | find /I "EG-BIM Modeler.exe" >nul
if not errorlevel 1 (
echo.
echo [WARN] EG-BIM Modeler is running. Close it before deploying
echo to avoid locked plugin DLLs.
echo.
pause
exit /b 1
)
echo ============================================================
echo [2/4] Build Recordingtest.Sut.EgBim.PluginHost (Debug)
echo ============================================================
dotnet build "%CSPROJ%" -c Debug -nologo -v q
if errorlevel 1 (
echo.
echo [ERROR] Build failed. Aborting deploy.
pause
exit /b 1
)
echo ============================================================
echo [3/4] Purge legacy and fresh-copy plugin folder
echo ============================================================
if exist "EG-BIM Modeler\Plugins\Recordingtest.EgPlugin" (
echo Removing legacy Recordingtest.EgPlugin folder...
rmdir /s /q "EG-BIM Modeler\Plugins\Recordingtest.EgPlugin"
)
if exist "%DEST%" (
echo Cleaning existing %DEST%...
rmdir /s /q "%DEST%"
)
mkdir "%DEST%"
REM Copy plugin entry + HmEG-aware provider + generic contract.
REM Intentionally *not* copying HmEG.dll / Editor*.dll ??those are already
REM provided by the SUT at runtime.
copy /y "%OUTDIR%\Recordingtest.Sut.EgBim.PluginHost.dll" "%DEST%\" >nul || goto :copy_fail
copy /y "%OUTDIR%\Recordingtest.Hmeg.Bridge.dll" "%DEST%\" >nul || goto :copy_fail
copy /y "%OUTDIR%\Recordingtest.Bridge.Abstractions.dll" "%DEST%\" >nul || goto :copy_fail
REM Optional PDBs for in-SUT debugging (ignore if missing)
copy /y "%OUTDIR%\Recordingtest.Sut.EgBim.PluginHost.pdb" "%DEST%\" >nul 2>&1
copy /y "%OUTDIR%\Recordingtest.Hmeg.Bridge.pdb" "%DEST%\" >nul 2>&1
copy /y "%OUTDIR%\Recordingtest.Bridge.Abstractions.pdb" "%DEST%\" >nul 2>&1
echo ============================================================
echo [4/4] Deployed files:
echo ============================================================
dir /b "%DEST%"
echo.
echo OK ??Recordingtest.Sut.EgBim.PluginHost deployed.
echo Next: launch EG-BIM Modeler, then in a shell:
echo curl http://localhost:38080/health
echo curl http://localhost:38080/scene
echo curl http://localhost:38080/camera
echo curl http://localhost:38080/selection
echo.
pause
exit /b 0
:copy_fail
echo.
echo [ERROR] Copy failed. Check that the build output exists:
echo %OUTDIR%
pause
exit /b 1

View File

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

View File

@@ -1,7 +1,9 @@
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using Recordingtest.Hmeg.Catalog;
namespace Recordingtest.EngineBridge.Client;
namespace Recordingtest.Hmeg.Bridge.Client;
public sealed class HmEgHttpSnapshot : IEngineSnapshot, IDisposable
{
@@ -85,6 +87,43 @@ public sealed class HmEgHttpSnapshot : IEngineSnapshot, IDisposable
}
}
/// <summary>
/// POST /camera/restore with the given camera state.
/// Throws <see cref="EngineBridgeException"/> on failure.
/// </summary>
public void RestoreCamera(double[] eye, double[] target, double[] up, double fov)
{
var body = BuildCameraJson(eye, target, up, fov);
try
{
var content = new System.Net.Http.StringContent(
body, System.Text.Encoding.UTF8, "application/json");
using var resp = _http.PostAsync(_baseUrl + "/camera/restore", content)
.GetAwaiter().GetResult();
if (!resp.IsSuccessStatusCode)
throw new EngineBridgeException("/camera/restore", $"HTTP {(int)resp.StatusCode}");
}
catch (EngineBridgeException) { throw; }
catch (TaskCanceledException ex)
{
throw new EngineBridgeException("/camera/restore", "timeout", ex);
}
catch (Exception ex)
{
throw new EngineBridgeException("/camera/restore", ex.Message, ex);
}
}
private static string BuildCameraJson(double[] eye, double[] target, double[] up, double fov)
{
static string Vec(double[] v) =>
"[" + string.Join(",", v.Select(d => d.ToString("R", System.Globalization.CultureInfo.InvariantCulture))) + "]";
return "{\"eye\":" + Vec(eye) +
",\"target\":" + Vec(target) +
",\"up\":" + Vec(up) +
",\"fov\":" + fov.ToString("R", System.Globalization.CultureInfo.InvariantCulture) + "}";
}
private JsonDocument Get(string endpoint)
{
try

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,315 @@
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;
/// <summary>
/// Dispatcher used to marshal <see cref="SetCamera"/> onto the WPF UI thread.
/// When null the set is attempted directly (acceptable in tests that don't run a
/// WPF message loop; in production always supply <c>Application.Current.Dispatcher.Invoke</c>).
/// </summary>
private readonly Action<Action>? _uiDispatch;
public HmegDirectStateProvider(
Func<Space?> spaceProvider,
Func<HmEGViewport?> viewportProvider,
Func<string?>? documentPathProvider = null,
Action<Action>? uiDispatch = null)
{
_spaceProvider = spaceProvider ?? throw new ArgumentNullException(nameof(spaceProvider));
_viewportProvider = viewportProvider ?? throw new ArgumentNullException(nameof(viewportProvider));
_documentPathProvider = documentPathProvider;
_uiDispatch = uiDispatch;
}
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;
}
/// <summary>
/// Apply a recorded camera snapshot to the active HmEG viewport.
/// All reflection exceptions are swallowed — this is best-effort.
/// The actual property writes are dispatched to the WPF UI thread via
/// <c>_uiDispatch</c> when provided.
/// </summary>
public void SetCamera(CameraSnapshot snapshot)
{
try
{
void DoSet()
{
var vp = _viewportProvider();
var core = vp?.CameraCore;
if (core is null) return;
// Target is stored as eye+lookDir in HmEG (not as a target point).
var lookDir = new double[]
{
snapshot.Target[0] - snapshot.Eye[0],
snapshot.Target[1] - snapshot.Eye[1],
snapshot.Target[2] - snapshot.Eye[2],
};
var t = core.GetType();
WriteVec3(core, t, new[] { "Position", "Eye" }, snapshot.Eye);
WriteVec3(core, t, new[] { "LookDirection", "Direction" }, lookDir);
WriteVec3(core, t, new[] { "UpDirection", "Up" }, snapshot.Up);
WriteDouble(core, t, new[] { "FieldOfView", "Fov", "FOV" }, snapshot.Fov);
}
if (_uiDispatch is not null)
_uiDispatch(DoSet);
else
DoSet();
}
catch
{
// never throw from the sidecar HTTP thread
}
}
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;
}
/// <summary>
/// Write a 3-component vector to a property/field whose type has a
/// constructor of the form <c>(double,double,double)</c> or
/// <c>(float,float,float)</c>. This covers WPF Point3D/Vector3D and
/// HmEG's own vector types without needing a compile-time reference.
/// </summary>
private static void WriteVec3(object owner, Type t, string[] names, double[] value)
{
foreach (var n in names)
{
try
{
System.Reflection.PropertyInfo? p = t.GetProperty(n);
System.Reflection.FieldInfo? f = p is null ? t.GetField(n) : null;
Type? memberType = p?.PropertyType ?? f?.FieldType;
if (memberType is null) continue;
// Try ctor(double,double,double) first, then ctor(float,float,float)
object? instance = null;
var ctorD = memberType.GetConstructor(new[] { typeof(double), typeof(double), typeof(double) });
if (ctorD is not null)
instance = ctorD.Invoke(new object[] { value[0], value[1], value[2] });
else
{
var ctorF = memberType.GetConstructor(new[] { typeof(float), typeof(float), typeof(float) });
if (ctorF is not null)
instance = ctorF.Invoke(new object[] { (float)value[0], (float)value[1], (float)value[2] });
}
if (instance is null) continue;
if (p is not null && p.CanWrite) { p.SetValue(owner, instance); return; }
if (f is not null && !f.IsInitOnly) { f.SetValue(owner, instance); return; }
}
catch { /* try next name */ }
}
}
private static void WriteDouble(object owner, Type t, string[] names, double value)
{
foreach (var n in names)
{
try
{
var p = t.GetProperty(n);
if (p is not null && p.CanWrite) { p.SetValue(owner, value); return; }
var f = t.GetField(n);
if (f is not null && !f.IsInitOnly) { f.SetValue(owner, value); return; }
}
catch { /* try next */ }
}
}
}

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 Recordingtest.EngineBridge;
using Recordingtest.Hmeg.Catalog;
namespace Recordingtest.EngineBridge.Probe;
namespace Recordingtest.Hmeg.Catalog.Probe;
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.Text;
namespace Recordingtest.EngineBridge;
namespace Recordingtest.Hmeg.Catalog;
public sealed record Candidate(
string Category,

View File

@@ -1,7 +1,7 @@
using System.Reflection;
using System.Text.Json;
namespace Recordingtest.EngineBridge;
namespace Recordingtest.Hmeg.Catalog;
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>
/// Skeleton implementation of <see cref="IEngineSnapshot"/> for HmEG.

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,44 @@
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();
/// <summary>
/// Restore the camera state in the live SUT. Best-effort: implementations
/// that cannot write the camera (reflection fallback, null provider) must
/// swallow any exception and return silently.
/// </summary>
void SetCamera(CameraSnapshot snapshot);
}
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;
public void SetCamera(CameraSnapshot snapshot) { /* no-op */ }
}

View File

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

@@ -0,0 +1,16 @@
<Application x:Class="Recordingtest.LauncherUI.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml">
<Application.Resources>
<Style TargetType="Button">
<Setter Property="Padding" Value="12,6"/>
<Setter Property="Margin" Value="4,2"/>
<Setter Property="Cursor" Value="Hand"/>
</Style>
<Style TargetType="TextBox">
<Setter Property="Padding" Value="4,3"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
</Style>
</Application.Resources>
</Application>

View File

@@ -0,0 +1,5 @@
using System.Windows;
namespace Recordingtest.LauncherUI;
public partial class App : Application { }

View File

@@ -0,0 +1,148 @@
<Window x:Class="Recordingtest.LauncherUI.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Recordingtest Launcher" Width="720" Height="640"
MinWidth="560" MinHeight="420"
WindowStartupLocation="CenterScreen">
<Grid Margin="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="180"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Header -->
<Border Grid.Row="0" Background="#1E3A5F" CornerRadius="4" Margin="0,0,0,8" Padding="12,8">
<TextBlock Text="🎬 Recordingtest Launcher"
Foreground="White" FontSize="16" FontWeight="SemiBold"/>
</Border>
<!-- Scenarios folder row -->
<Grid Grid.Row="1" Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="시나리오 폴더:" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBox Grid.Column="1" x:Name="ScenariosPathBox"
TextChanged="ScenariosPath_TextChanged"/>
<Button Grid.Column="2" Content="📂" Click="BrowseScenarios_Click"
ToolTip="폴더 선택" Padding="8,4"/>
<Button Grid.Column="3" Content="↻" Click="RefreshScenarios_Click"
ToolTip="목록 새로고침" Padding="8,4"/>
</Grid>
<!-- Scenario list -->
<ListBox Grid.Row="2" x:Name="ScenarioListBox"
Margin="0,0,0,4"
FontFamily="Consolas" FontSize="12"
ScrollViewer.VerticalScrollBarVisibility="Auto"/>
<!-- SUT status -->
<Border Grid.Row="3" BorderBrush="#DDD" BorderThickness="1"
CornerRadius="3" Padding="8,6" Margin="0,0,0,6">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="SUT:" FontWeight="SemiBold"
VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBlock Grid.Column="1" x:Name="SutStatusText"
VerticalAlignment="Center" TextWrapping="Wrap"/>
<Button Grid.Column="2" Content="새로고침" Click="RefreshSut_Click"
Padding="8,3" Margin="8,0,0,0"/>
</Grid>
</Border>
<!-- Action buttons + countdown -->
<StackPanel Grid.Row="4" Orientation="Horizontal" Margin="0,0,0,6" VerticalAlignment="Center">
<Button x:Name="RunButton" Content="▶ 실행" Click="Run_Click"
Background="#2196F3" Foreground="White"
FontWeight="SemiBold" Width="120"/>
<Button x:Name="StopButton" Content="⏹ 중단" Click="Stop_Click"
Background="#F44336" Foreground="White"
IsEnabled="False" Width="90"/>
<Button Content="🔍 UI 분석" Click="UiAnalysis_Click"
Background="#5C6BC0" Foreground="White"
FontWeight="SemiBold" Width="110"
ToolTip="SUT UIA 트리를 시각화합니다"/>
<TextBlock x:Name="CountdownText" VerticalAlignment="Center"
FontSize="14" FontWeight="Bold" Foreground="#E65100"
Margin="16,0,0,0" Visibility="Collapsed"/>
<Separator Width="1" Height="24" Margin="12,0" Background="#CCC"/>
<TextBlock Text="속도:" VerticalAlignment="Center" Margin="0,0,4,0"/>
<Slider x:Name="SpeedSlider" Minimum="0.25" Maximum="4.0" Value="1.0"
Width="100" VerticalAlignment="Center"
TickFrequency="0.25" IsSnapToTickEnabled="True"
ToolTip="재생 속도 (1.0=보통, 2.0=2배속)"
ValueChanged="SpeedSlider_ValueChanged"/>
<TextBlock x:Name="SpeedLabel" Text="1.0x" VerticalAlignment="Center"
Width="36" Margin="4,0,0,0" FontWeight="SemiBold"/>
</StackPanel>
<!-- Sidecar URL row -->
<Border Grid.Row="5" BorderBrush="#DDD" BorderThickness="1"
CornerRadius="3" Padding="8,6" Margin="0,0,0,4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<CheckBox Grid.Column="0" x:Name="SidecarEnabledCheck"
Content="Sidecar URL:" IsChecked="True"
VerticalAlignment="Center" Margin="0,0,8,0"
ToolTip="녹화/재생 시 engine-bridge sidecar에 연결합니다 (camera-restore 포함)"/>
<TextBlock Grid.Column="1" Text="" Width="0"/>
<TextBox Grid.Column="2" x:Name="SidecarUrlBox"
Text="http://localhost:38080"
VerticalContentAlignment="Center"
FontFamily="Consolas" FontSize="11"/>
</Grid>
</Border>
<!-- Record controls -->
<Border Grid.Row="6" BorderBrush="#DDD" BorderThickness="1"
CornerRadius="3" Padding="8,6" Margin="0,0,0,6">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="녹화명:" VerticalAlignment="Center"
FontWeight="SemiBold" Margin="0,0,8,0"/>
<TextBox Grid.Column="1" x:Name="RecordNameBox"
Text="recorded" VerticalContentAlignment="Center"/>
<Button Grid.Column="2" x:Name="RecordStartButton"
Content="● 녹화 시작" Click="RecordStart_Click"
Background="#43A047" Foreground="White"
FontWeight="SemiBold" Width="110"/>
<Button Grid.Column="3" x:Name="RecordStopButton"
Content="⏹ 녹화 중단" Click="RecordStop_Click"
Background="#E53935" Foreground="White"
IsEnabled="False" Width="110"/>
</Grid>
</Border>
<!-- Log output -->
<Border Grid.Row="7" BorderBrush="#CCC" BorderThickness="1" CornerRadius="3">
<ScrollViewer x:Name="LogScroll" VerticalScrollBarVisibility="Auto">
<TextBox x:Name="LogBox" IsReadOnly="True"
FontFamily="Consolas" FontSize="11"
TextWrapping="Wrap" Background="#FAFAFA"
BorderThickness="0" Padding="6"/>
</ScrollViewer>
</Border>
</Grid>
</Window>

View File

@@ -0,0 +1,379 @@
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Windows;
using System.Windows.Media;
using Microsoft.Win32;
using Recordingtest.Player;
using Recordingtest.Player.Model;
namespace Recordingtest.LauncherUI;
public partial class MainWindow : Window
{
private CancellationTokenSource? _cts;
private bool _refreshingPath;
// ── Recording state ──────────────────────────────────────────────────────
private Process? _recorderProcess;
private StreamWriter? _recorderStdin;
public MainWindow()
{
InitializeComponent();
var built = System.IO.File.GetLastWriteTime(GetType().Assembly.Location);
Title = $"Recordingtest Launcher [built {built:yyyy-MM-dd HH:mm:ss}]";
ScenariosPathBox.Text = FindScenariosDir();
RefreshScenarioList();
RefreshSutStatus();
}
// ── Scenario folder ─────────────────────────────────────────────────────
private static string FindScenariosDir()
{
var candidates = new[]
{
Path.Combine(AppContext.BaseDirectory, "scenarios"),
Path.Combine(Directory.GetCurrentDirectory(), "scenarios"),
// walk up 3 levels from exe (dev layout: bin/Debug/net8.0-windows)
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "scenarios")),
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "scenarios")),
};
foreach (var c in candidates)
if (Directory.Exists(c)) return c;
return Path.Combine(Directory.GetCurrentDirectory(), "scenarios");
}
private void RefreshScenarioList()
{
var dir = ScenariosPathBox.Text?.Trim() ?? "";
ScenarioListBox.Items.Clear();
if (!Directory.Exists(dir)) return;
foreach (var f in Directory.GetFiles(dir, "*.yaml").OrderBy(x => x))
ScenarioListBox.Items.Add(Path.GetFileNameWithoutExtension(f));
if (ScenarioListBox.Items.Count > 0)
ScenarioListBox.SelectedIndex = ScenarioListBox.Items.Count - 1; // select latest
}
private void ScenariosPath_TextChanged(object sender,
System.Windows.Controls.TextChangedEventArgs e)
{
if (_refreshingPath) return;
RefreshScenarioList();
}
private void BrowseScenarios_Click(object sender, RoutedEventArgs e)
{
var dlg = new OpenFolderDialog { Title = "시나리오 폴더 선택" };
if (dlg.ShowDialog() == true)
{
_refreshingPath = true;
ScenariosPathBox.Text = dlg.FolderName;
_refreshingPath = false;
RefreshScenarioList();
}
}
private void RefreshScenarios_Click(object sender, RoutedEventArgs e) =>
RefreshScenarioList();
// ── SUT status ───────────────────────────────────────────────────────────
private void RefreshSutStatus()
{
var procs = Process.GetProcessesByName("EG-BIM Modeler");
if (procs.Length > 0)
{
SutStatusText.Text = $"✓ EG-BIM Modeler 실행 중 (PID {procs[0].Id})";
SutStatusText.Foreground = new SolidColorBrush(Color.FromRgb(0x2E, 0x7D, 0x32));
}
else
{
SutStatusText.Text = "✗ SUT 없음 — EG-BIM Modeler를 먼저 실행하세요";
SutStatusText.Foreground = new SolidColorBrush(Color.FromRgb(0xC6, 0x28, 0x28));
}
}
private void RefreshSut_Click(object sender, RoutedEventArgs e) => RefreshSutStatus();
// ── Run / Stop ───────────────────────────────────────────────────────────
private async void Run_Click(object sender, RoutedEventArgs e)
{
if (ScenarioListBox.SelectedItem is not string scenarioName)
{
AppendLog("[warn] 시나리오를 선택하세요.");
return;
}
var procs = Process.GetProcessesByName("EG-BIM Modeler");
if (procs.Length == 0)
{
AppendLog("[error] EG-BIM Modeler가 실행 중이 아닙니다.");
return;
}
RunButton.IsEnabled = false;
StopButton.IsEnabled = true;
LogBox.Clear();
AppendLog($"[launcher] 시나리오: {scenarioName}");
// 3-second countdown so user can move cursor away
CountdownText.Visibility = Visibility.Visible;
for (int i = 3; i >= 1; i--)
{
CountdownText.Text = $"▶ {i}초 후 시작...";
await Task.Delay(1000);
}
CountdownText.Visibility = Visibility.Collapsed;
// Minimize launcher so it can't steal focus during playback
var prevState = WindowState;
WindowState = WindowState.Minimized;
_cts = new CancellationTokenSource();
var scenariosDir = ScenariosPathBox.Text?.Trim() ?? "";
var sidecarUrl = (SidecarEnabledCheck.IsChecked == true)
? SidecarUrlBox.Text?.Trim()
: null;
try
{
await Task.Run(() => RunScenario(scenarioName, scenariosDir, sidecarUrl, _cts.Token));
}
catch (OperationCanceledException)
{
AppendLog("[launcher] 중단됨.");
}
catch (Exception ex)
{
AppendLog($"[launcher] 오류: {ex.Message}");
}
finally
{
WindowState = prevState;
RunButton.IsEnabled = true;
StopButton.IsEnabled = false;
}
}
private void Stop_Click(object sender, RoutedEventArgs e) => _cts?.Cancel();
private void SpeedSlider_ValueChanged(object sender,
System.Windows.RoutedPropertyChangedEventArgs<double> e)
{
if (SpeedLabel is null) return;
SpeedLabel.Text = $"{SpeedSlider.Value:0.##}x";
}
private void UiAnalysis_Click(object sender, RoutedEventArgs e)
{
try
{
var win = new UiAnalysisWindow();
win.Owner = this;
win.Show();
}
catch (Exception ex)
{
MessageBox.Show($"UI 분석 창 실행 오류:\n\n{ex.GetType().Name}: {ex.Message}\n\n{ex.StackTrace}",
"UI 분석 오류", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
// ── Recording ────────────────────────────────────────────────────────────
private static string? FindRecorderExe()
{
var candidates = new[]
{
Path.Combine(AppContext.BaseDirectory, "Recordingtest.Recorder.exe"),
// dev layout: LauncherUI/bin/Debug/net8.0-windows → Recorder/bin/Debug/net8.0-windows
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory,
"..", "..", "..", "..", "Recordingtest.Recorder", "bin", "Debug", "net8.0-windows",
"Recordingtest.Recorder.exe")),
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory,
"..", "..", "..", "..", "Recordingtest.Recorder", "bin", "Release", "net8.0-windows",
"Recordingtest.Recorder.exe")),
};
foreach (var c in candidates)
if (File.Exists(c)) return c;
return null;
}
private async void RecordStart_Click(object sender, RoutedEventArgs e)
{
var scenarioName = RecordNameBox.Text?.Trim();
if (string.IsNullOrEmpty(scenarioName))
{
AppendLog("[warn] 녹화 파일명을 입력하세요.");
return;
}
var procs = Process.GetProcessesByName("EG-BIM Modeler");
if (procs.Length == 0)
{
AppendLog("[error] EG-BIM Modeler가 실행 중이 아닙니다.");
return;
}
var recorderExe = FindRecorderExe();
if (recorderExe is null)
{
AppendLog("[error] Recordingtest.Recorder.exe를 찾을 수 없습니다. 먼저 빌드하세요.");
return;
}
var scenariosDir = ScenariosPathBox.Text?.Trim() ?? "scenarios";
var outputPath = Path.Combine(scenariosDir, scenarioName + ".yaml");
LogBox.Clear();
AppendLog($"[launcher] 녹화 시작: {outputPath}");
AppendLog($"[launcher] SUT PID: {procs[0].Id}");
RecordStartButton.IsEnabled = false;
RecordStopButton.IsEnabled = true;
var sidecarArg = "";
if (SidecarEnabledCheck.IsChecked == true)
{
var url = SidecarUrlBox.Text?.Trim();
if (!string.IsNullOrEmpty(url))
sidecarArg = $" --sidecar-url \"{url}\"";
}
var psi = new ProcessStartInfo(recorderExe)
{
Arguments = $"--output \"{outputPath}\" --attach \"{procs[0].Id}\"{sidecarArg}",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = true,
CreateNoWindow = true,
};
_recorderProcess = new Process { StartInfo = psi, EnableRaisingEvents = true };
_recorderProcess.OutputDataReceived += (_, ev2) =>
{
if (ev2.Data is not null) AppendLog(ev2.Data);
};
_recorderProcess.ErrorDataReceived += (_, ev2) =>
{
if (ev2.Data is not null) AppendLog("[stderr] " + ev2.Data);
};
_recorderProcess.Exited += (_, _) =>
{
Dispatcher.Invoke(() =>
{
AppendLog("[launcher] 녹화 종료.");
RecordStartButton.IsEnabled = true;
RecordStopButton.IsEnabled = false;
_recorderProcess = null;
_recorderStdin = null;
RecordNameBox.Text = ""; // 다음 녹화 시 이름을 새로 입력하도록 초기화
RefreshScenarioList();
});
};
_recorderProcess.Start();
_recorderProcess.BeginOutputReadLine();
_recorderProcess.BeginErrorReadLine();
_recorderStdin = _recorderProcess.StandardInput;
// Minimize launcher so it doesn't interfere with recording
await Task.Delay(500);
WindowState = WindowState.Minimized;
}
private void RecordStop_Click(object sender, RoutedEventArgs e)
{
if (_recorderProcess is null || _recorderProcess.HasExited)
{
RecordStartButton.IsEnabled = true;
RecordStopButton.IsEnabled = false;
return;
}
AppendLog("[launcher] 녹화 중단 요청...");
WindowState = WindowState.Normal;
try
{
// Close stdin → recorder detects EOF → graceful stop + file write
_recorderStdin?.Close();
}
catch { /* already closed */ }
}
// ── Playback ─────────────────────────────────────────────────────────────
private void RunScenario(string scenarioName, string scenariosDir,
string? sidecarUrl, CancellationToken ct)
{
var yamlPath = Path.Combine(scenariosDir, scenarioName + ".yaml");
if (!File.Exists(yamlPath))
{
AppendLog($"[error] 파일 없음: {yamlPath}");
return;
}
Scenario scenario;
try { scenario = ScenarioLoader.LoadFromFile(yamlPath); }
catch (Exception ex) { AppendLog($"[error] yaml 파싱 실패: {ex.Message}"); return; }
var app = UiaPlayerHost.AttachByExeName("EG-BIM Modeler.exe");
if (app is null)
{
AppendLog("[error] EG-BIM Modeler 프로세스에 연결할 수 없습니다.");
return;
}
var artifactDir = Path.Combine("artifacts", "launcher-out", scenarioName);
Directory.CreateDirectory(artifactDir);
using var host = new UiaPlayerHost(app, artifactDir, sidecarUrl);
// Redirect Console.WriteLine → WPF log box
var prevOut = Console.Out;
Console.SetOut(new DispatcherWriter(AppendLog));
try
{
host.BringSutToForeground();
ct.ThrowIfCancellationRequested();
var speed = Dispatcher.Invoke(() => SpeedSlider.Value);
var engine = new PlayerEngine(new PlayerEngineOptions
{
PreserveTiming = true,
SpeedMultiplier = speed,
});
engine.Run(scenario, host, ct);
AppendLog($"[launcher] ✓ {scenarioName} 완료.");
}
finally
{
Console.SetOut(prevOut);
}
}
// ── Helpers ──────────────────────────────────────────────────────────────
private void AppendLog(string msg) =>
Dispatcher.Invoke(() =>
{
LogBox.AppendText(msg + "\n");
LogScroll.ScrollToBottom();
});
/// <summary>Redirects Console.WriteLine output to the WPF log box.</summary>
private sealed class DispatcherWriter : TextWriter
{
private readonly Action<string> _log;
public override Encoding Encoding => Encoding.UTF8;
public DispatcherWriter(Action<string> log) => _log = log;
public override void WriteLine(string? value) => _log(value ?? "");
public override void Write(string? value) { /* absorb partial writes */ }
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<AssemblyName>Recordingtest.LauncherUI</AssemblyName>
<RootNamespace>Recordingtest.LauncherUI</RootNamespace>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationIcon />
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FlaUI.Core" Version="4.0.0" />
<PackageReference Include="FlaUI.UIA3" Version="4.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Recordingtest.Player\Recordingtest.Player.csproj" />
<ProjectReference Include="..\Recordingtest.Runner\Recordingtest.Runner.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,106 @@
<Window x:Class="Recordingtest.LauncherUI.UiAnalysisWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="UI 컨트롤 맵" Width="1000" Height="700"
WindowStartupLocation="Manual"
Topmost="True">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Title row -->
<Border Grid.Row="0" Background="#263238" Padding="8,6">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" x:Name="TitleText"
Text="UI 컨트롤 맵" Foreground="White"
FontWeight="SemiBold" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="× 닫기" Click="Close_Click"
Background="#B71C1C" Foreground="White"
BorderBrush="#C62828" Padding="10,4"/>
</Grid>
</Border>
<!-- Toolbar row -->
<Border Grid.Row="1" Background="#2E3C43" Padding="6,4" BorderBrush="#37474F" BorderThickness="0,0,0,1">
<WrapPanel Orientation="Horizontal">
<CheckBox x:Name="FilterIdOnly"
Content="ID/Name만" IsChecked="True"
Foreground="#B0BEC5" VerticalAlignment="Center"
Checked="Filter_Changed" Unchecked="Filter_Changed"
Margin="0,0,12,0"/>
<CheckBox x:Name="ShowContainers"
Content="컨테이너" IsChecked="False"
Foreground="#B0BEC5" VerticalAlignment="Center"
Checked="Filter_Changed" Unchecked="Filter_Changed"
Margin="0,0,16,0"/>
<Button Content="↻ 새로고침" Click="Refresh_Click"
Background="#37474F" Foreground="White"
BorderBrush="#546E7A" Padding="8,3" Margin="0,0,4,0"/>
<Button Content="⛶ 전체 맞춤" Click="FitToWindow_Click"
Background="#37474F" Foreground="White"
BorderBrush="#546E7A" Padding="8,3" Margin="0,0,4,0"
ToolTip="전체 맵을 창 크기에 맞게 축소합니다"/>
<Button Content="100%" Click="Zoom100_Click"
Background="#37474F" Foreground="White"
BorderBrush="#546E7A" Padding="8,3" Margin="0,0,4,0"
ToolTip="원본 크기(1:1)로 복원합니다"/>
<Button Content="💾 이미지 저장" Click="SaveImage_Click"
Background="#1B5E20" Foreground="White"
BorderBrush="#2E7D32" Padding="8,3" Margin="0,0,4,0"
ToolTip="전체 컨트롤 맵을 PNG 파일로 저장합니다"/>
<TextBlock x:Name="ZoomText" Text="100%"
Foreground="#78909C" VerticalAlignment="Center"
FontSize="11" Margin="8,0,0,0"/>
</WrapPanel>
</Border>
<!-- Canvas area -->
<Border Grid.Row="2" Background="#37474F">
<ScrollViewer x:Name="MapScroll"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto"
Background="#455A64">
<Canvas x:Name="MapCanvas" Background="#546E7A">
<Canvas.LayoutTransform>
<ScaleTransform x:Name="MapScale" ScaleX="1" ScaleY="1"/>
</Canvas.LayoutTransform>
</Canvas>
</ScrollViewer>
</Border>
<!-- Status bar -->
<Border Grid.Row="3" Background="#1C313A" Padding="8,4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" x:Name="StatusText"
Text="SUT 연결 중..." Foreground="#90A4AE"
FontSize="11" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal">
<Border Width="12" Height="12" Background="#6495ED" Margin="4,0,2,0"/>
<TextBlock Text="Button" Foreground="#90A4AE" FontSize="10" VerticalAlignment="Center" Margin="0,0,8,0"/>
<Border Width="12" Height="12" Background="#4CAF50" Margin="0,0,2,0"/>
<TextBlock Text="TextBox" Foreground="#90A4AE" FontSize="10" VerticalAlignment="Center" Margin="0,0,8,0"/>
<Border Width="12" Height="12" Background="#FF9800" Margin="0,0,2,0"/>
<TextBlock Text="Combo" Foreground="#90A4AE" FontSize="10" VerticalAlignment="Center" Margin="0,0,8,0"/>
<Border Width="12" Height="12" Background="#9C27B0" Margin="0,0,2,0"/>
<TextBlock Text="List" Foreground="#90A4AE" FontSize="10" VerticalAlignment="Center" Margin="0,0,8,0"/>
<Border Width="12" Height="12" Background="#FF5722" Margin="0,0,2,0"/>
<TextBlock Text="Custom" Foreground="#90A4AE" FontSize="10" VerticalAlignment="Center" Margin="0,0,8,0"/>
<Border Width="12" Height="12" Background="#78909C" Margin="0,0,2,0"/>
<TextBlock Text="기타" Foreground="#90A4AE" FontSize="10" VerticalAlignment="Center"/>
</StackPanel>
</Grid>
</Border>
</Grid>
</Window>

View File

@@ -0,0 +1,417 @@
using System.Diagnostics;
using System.IO;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using FlaUI.Core.Definitions;
using FlaUI.UIA3;
using Microsoft.Win32;
using FlaUIAutomationElement = FlaUI.Core.AutomationElements.AutomationElement;
namespace Recordingtest.LauncherUI;
public partial class UiAnalysisWindow : Window
{
// ── Model ─────────────────────────────────────────────────────────────────
private sealed record UiEl(
string ClassName,
string AutomationId,
string Name,
ControlType CtrlType,
Rect Bounds, // physical-pixel, SUT-window-relative
int Depth);
private List<UiEl> _elements = new();
private double _dpiX = 1.0, _dpiY = 1.0;
private CancellationTokenSource _cts = new();
// ── Init ──────────────────────────────────────────────────────────────────
public UiAnalysisWindow()
{
InitializeComponent();
Loaded += OnLoaded;
}
private async void OnLoaded(object sender, RoutedEventArgs e)
{
try
{
var src = PresentationSource.FromVisual(this);
if (src?.CompositionTarget != null)
{
_dpiX = src.CompositionTarget.TransformToDevice.M11;
_dpiY = src.CompositionTarget.TransformToDevice.M22;
}
await DoRefreshAsync();
}
catch (Exception ex) { ShowError("OnLoaded", ex); }
}
// ── Toolbar handlers ──────────────────────────────────────────────────────
private async void Refresh_Click(object sender, RoutedEventArgs e)
{
try { await DoRefreshAsync(); }
catch (Exception ex) { ShowError("Refresh", ex); }
}
private void Close_Click(object sender, RoutedEventArgs e) => Close();
private void FitToWindow_Click(object sender, RoutedEventArgs e)
{
try
{
if (MapCanvas.Width <= 0 || MapCanvas.Height <= 0) return;
// Give ScrollViewer a moment to measure, then compute scale
MapScroll.UpdateLayout();
double vw = MapScroll.ViewportWidth;
double vh = MapScroll.ViewportHeight;
if (vw <= 0 || vh <= 0) return;
double scale = Math.Min(vw / MapCanvas.Width, vh / MapCanvas.Height) * 0.97;
scale = Math.Max(0.05, Math.Min(scale, 4.0));
SetZoom(scale);
}
catch (Exception ex) { ShowError("FitToWindow", ex); }
}
private void Zoom100_Click(object sender, RoutedEventArgs e) => SetZoom(1.0);
private void SetZoom(double scale)
{
MapScale.ScaleX = scale;
MapScale.ScaleY = scale;
ZoomText.Text = $"{scale * 100:0}%";
}
private void SaveImage_Click(object sender, RoutedEventArgs e)
{
try
{
if (MapCanvas.Width <= 0 || MapCanvas.Height <= 0)
{ MessageBox.Show("저장할 맵이 없습니다.", "알림"); return; }
var dlg = new SaveFileDialog
{
Title = "컨트롤 맵 이미지 저장",
Filter = "PNG 이미지|*.png",
FileName = $"ui-control-map-{DateTime.Now:yyyyMMdd-HHmmss}.png",
DefaultExt = ".png",
};
if (dlg.ShowDialog() != true) return;
// Render the entire canvas at 1:1 scale (regardless of current zoom)
double w = MapCanvas.Width;
double h = MapCanvas.Height;
// Draw background + canvas content into a DrawingVisual
var visual = new DrawingVisual();
using (var dc = visual.RenderOpen())
{
var bgBrush = new SolidColorBrush(Color.FromRgb(0x54, 0x6E, 0x7A));
dc.DrawRectangle(bgBrush, null, new Rect(0, 0, w, h));
dc.DrawRectangle(new VisualBrush(MapCanvas), null, new Rect(0, 0, w, h));
}
var rtb = new RenderTargetBitmap(
(int)Math.Ceiling(w), (int)Math.Ceiling(h),
96, 96, PixelFormats.Pbgra32);
rtb.Render(visual);
var enc = new PngBitmapEncoder();
enc.Frames.Add(BitmapFrame.Create(rtb));
using var fs = File.OpenWrite(dlg.FileName);
enc.Save(fs);
SetStatus($"이미지 저장 완료: {dlg.FileName} ({(int)w}×{(int)h}px)");
MessageBox.Show($"저장 완료!\n{dlg.FileName}\n\n크기: {(int)w} × {(int)h} px",
"저장 완료", MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex) { ShowError("SaveImage", ex); }
}
private void Filter_Changed(object sender, RoutedEventArgs e)
{
if (!IsLoaded) return;
try { DrawElements(); }
catch (Exception ex) { ShowError("Filter", ex); }
}
// ── Core scan ─────────────────────────────────────────────────────────────
private async Task DoRefreshAsync()
{
_cts.Cancel();
_cts = new CancellationTokenSource();
var ct = _cts.Token;
SetStatus("SUT 스캔 중...");
MapCanvas.Children.Clear();
_elements.Clear();
var procs = Process.GetProcessesByName("EG-BIM Modeler");
if (procs.Length == 0)
{
SetStatus("SUT 없음 — EG-BIM Modeler를 먼저 실행하세요.");
return;
}
int pid = procs[0].Id;
var buf = new List<UiEl>();
double sl = 0, st = 0, sw = 0, sh = 0;
string title = "EG-BIM Modeler";
string? err = null;
SetStatus($"PID {pid} 스캔 중...");
try
{
await Task.Run(() =>
{
try
{
using var auto = new UIA3Automation();
var app = FlaUI.Core.Application.Attach(pid);
var main = app.GetMainWindow(auto, TimeSpan.FromSeconds(8));
if (main is null) { err = "메인 윈도우를 가져올 수 없습니다."; return; }
var wb = main.BoundingRectangle;
sl = wb.Left; st = wb.Top; sw = wb.Width; sh = wb.Height;
title = SafeStr(() => main.Name) is { Length: > 0 } t ? t : title;
WalkTree(main, sl, st, 0, buf, ct);
}
catch (OperationCanceledException) { /* cancelled */ }
catch (Exception ex) { err = $"{ex.GetType().Name}: {ex.Message}"; }
}, ct);
}
catch (OperationCanceledException) { SetStatus("취소됨."); return; }
catch (Exception ex) { ShowError("Task.Run", ex); return; }
if (ct.IsCancellationRequested) { SetStatus("취소됨."); return; }
if (err is not null) { SetStatus($"오류: {err}"); MessageBox.Show(err, "스캔 오류", MessageBoxButton.OK, MessageBoxImage.Warning); return; }
// ── UI update (back on UI thread) ─────────────────────────────────
if (sw > 0 && sh > 0)
{
Width = sw / _dpiX;
Height = sh / _dpiY;
Left = sl / _dpiX;
Top = st / _dpiY;
}
TitleText.Text = $"UI 컨트롤 맵 — {title}";
_elements = buf;
DrawElements();
}
// ── UIA tree walk (background thread) ────────────────────────────────────
private static void WalkTree(
FlaUIAutomationElement el,
double ox, double oy, int depth,
List<UiEl> out_, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
if (depth > 25 || out_.Count >= 3000) return;
try
{
var b = el.BoundingRectangle;
if (b.Width > 0 && b.Height > 0)
{
var ct2 = ControlType.Unknown;
try { ct2 = el.ControlType; } catch { /* ignore */ }
out_.Add(new UiEl(
ClassName: SafeStr(() => el.ClassName),
AutomationId: SafeStr(() => el.AutomationId),
Name: SafeStr(() => el.Name),
CtrlType: ct2,
Bounds: new Rect(b.Left - ox, b.Top - oy, b.Width, b.Height),
Depth: depth));
}
}
catch { return; }
FlaUIAutomationElement[] kids;
try { kids = el.FindAllChildren(); }
catch { return; }
foreach (var k in kids)
WalkTree(k, ox, oy, depth + 1, out_, ct);
}
// ── Canvas rendering ──────────────────────────────────────────────────────
private void DrawElements()
{
if (MapCanvas is null) return;
MapCanvas.Children.Clear();
if (_elements is null || _elements.Count == 0)
{
SetStatus("표시할 컨트롤 없음.");
return;
}
bool idOnly = FilterIdOnly?.IsChecked == true;
bool showCont = ShowContainers?.IsChecked == true;
double cw = 0, ch = 0;
foreach (var e in _elements)
{
if (e.Bounds.Right > cw) cw = e.Bounds.Right;
if (e.Bounds.Bottom > ch) ch = e.Bounds.Bottom;
}
MapCanvas.Width = Math.Max(cw / _dpiX, 100);
MapCanvas.Height = Math.Max(ch / _dpiY, 100);
// containers first so they render behind leaf controls
var sorted = _elements
.OrderBy(e => IsContainer(e.CtrlType) ? 0 : 1)
.ThenBy(e => e.Depth)
.ToList();
int drawn = 0;
foreach (var el in sorted)
{
try
{
if (!ShouldDraw(el, idOnly, showCont)) continue;
drawn++;
double x = el.Bounds.Left / _dpiX;
double y = el.Bounds.Top / _dpiY;
double w = Math.Max(el.Bounds.Width / _dpiX, 2);
double h = Math.Max(el.Bounds.Height / _dpiY, 2);
var (fill, stroke, isCont) = StyleFor(el);
var border = new Border
{
Width = w,
Height = h,
Background = isCont ? Brushes.Transparent : fill,
BorderBrush = stroke,
BorderThickness = new Thickness(isCont ? 1 : 1.5),
ToolTip = MakeTooltip(el),
Cursor = Cursors.Hand,
};
if (w >= 24 && h >= 12)
{
var lbl = BuildLabel(el);
if (lbl.Length > 0)
{
border.Child = new TextBlock
{
Text = lbl,
Foreground = isCont ? stroke : Brushes.White,
FontSize = 9,
FontWeight = FontWeights.SemiBold,
TextTrimming = TextTrimming.CharacterEllipsis,
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Center,
Padding = new Thickness(2, 0, 2, 0),
IsHitTestVisible = false,
};
}
}
Canvas.SetLeft(border, x);
Canvas.SetTop(border, y);
Canvas.SetZIndex(border, el.Depth * 2 + (isCont ? 0 : 1));
MapCanvas.Children.Add(border);
}
catch { /* skip bad element */ }
}
SetStatus($"컨트롤 {drawn}개 표시 (전체 {_elements.Count}개) | DPI {_dpiX:0.#}×");
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static bool IsContainer(ControlType ct) => ct is
ControlType.Window or ControlType.Pane or ControlType.Group or
ControlType.Tab or ControlType.ToolBar or ControlType.StatusBar or
ControlType.MenuBar;
private static bool ShouldDraw(UiEl el, bool idOnly, bool showCont)
{
if (IsContainer(el.CtrlType) && !showCont) return false;
if (idOnly && !IsContainer(el.CtrlType))
if (string.IsNullOrEmpty(el.AutomationId) && string.IsNullOrEmpty(el.Name))
return false;
return true;
}
private static (Brush fill, Brush stroke, bool isContainer) StyleFor(UiEl el)
{
if (IsContainer(el.CtrlType))
return (Brushes.Transparent,
new SolidColorBrush(Color.FromArgb(120, 200, 180, 0)), true);
var (f, s) = el.CtrlType switch
{
ControlType.Button =>
(Color.FromArgb(180, 100, 149, 237), Color.FromArgb(230, 30, 80, 180)),
ControlType.Edit =>
(Color.FromArgb(180, 76, 175, 80), Color.FromArgb(230, 27, 120, 40)),
ControlType.ComboBox =>
(Color.FromArgb(180, 255, 152, 0), Color.FromArgb(230,180, 90, 0)),
ControlType.List or ControlType.ListItem or ControlType.DataGrid =>
(Color.FromArgb(180, 156, 39, 176), Color.FromArgb(230,100, 0, 140)),
ControlType.CheckBox or ControlType.RadioButton =>
(Color.FromArgb(180, 0, 188, 212), Color.FromArgb(230, 0, 130, 150)),
ControlType.Text =>
(Color.FromArgb(100, 158, 158, 158), Color.FromArgb(160,100, 100, 100)),
ControlType.Custom =>
(Color.FromArgb(180, 255, 87, 34), Color.FromArgb(230,180, 40, 0)),
_ =>
(Color.FromArgb(150, 120, 144, 156), Color.FromArgb(200, 70, 90, 100)),
};
return (new SolidColorBrush(f), new SolidColorBrush(s), false);
}
private static string BuildLabel(UiEl el)
{
if (!string.IsNullOrEmpty(el.AutomationId)) return el.AutomationId;
if (!string.IsNullOrEmpty(el.Name)) return el.Name;
return el.ClassName;
}
private static ToolTip MakeTooltip(UiEl el)
{
var lines = new List<string>
{
$"Type : {el.CtrlType}",
$"Class: {el.ClassName}",
};
if (!string.IsNullOrEmpty(el.AutomationId)) lines.Add($"Id : {el.AutomationId}");
if (!string.IsNullOrEmpty(el.Name)) lines.Add($"Name : {el.Name}");
lines.Add($"Rect : {el.Bounds.X:0},{el.Bounds.Y:0} {el.Bounds.Width:0}×{el.Bounds.Height:0}px");
lines.Add($"Depth: {el.Depth}");
return new ToolTip
{
Content = new TextBlock
{
Text = string.Join("\n", lines),
FontFamily = new FontFamily("Consolas"),
FontSize = 11,
},
};
}
private void SetStatus(string msg)
{
if (StatusText is not null) StatusText.Text = msg;
}
private void ShowError(string ctx, Exception ex)
{
var msg = $"[{ctx}] {ex.GetType().Name}:\n{ex.Message}\n\n{ex.StackTrace}";
SetStatus($"오류: {ex.Message}");
MessageBox.Show(msg, $"UI 분석 오류 — {ctx}",
MessageBoxButton.OK, MessageBoxImage.Error);
}
private static string SafeStr(Func<string?> f)
{
try { return f() ?? string.Empty; } catch { return string.Empty; }
}
}

View File

@@ -1,3 +1,4 @@
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
@@ -134,6 +135,20 @@ public static class Normalizer
}
break;
}
case "sort_array_elements":
{
if (isJson && jsonNode is not null)
{
var (n, c) = Rules.SortArrayElements(jsonNode);
jsonNode = n;
log.Add(new RuleApplication(rule, c));
}
else
{
log.Add(new RuleApplication(rule, 0));
}
break;
}
default:
throw new InvalidOperationException($"Unknown rule: {rule}");
}
@@ -142,7 +157,11 @@ public static class Normalizer
string output;
if (isJson && jsonNode is not null)
{
output = jsonNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
output = jsonNode.ToJsonString(new JsonSerializerOptions
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
});
}
else
{

View File

@@ -326,4 +326,51 @@ public static class Rules
}
return null;
}
/// <summary>
/// Sorts JSON array elements lexicographically (by their JSON serialization).
/// Recurses into objects and nested arrays. Counts the number of arrays sorted.
/// Use after mask_guids so that GUID-based IDs are order-independent.
/// </summary>
public static (JsonNode? node, int count) SortArrayElements(JsonNode? node)
{
int count = 0;
var result = SortArraysInternal(node, ref count);
return (result, count);
}
private static JsonNode? SortArraysInternal(JsonNode? node, ref int count)
{
if (node is JsonObject obj)
{
var newObj = new JsonObject();
foreach (var kv in obj)
{
newObj[kv.Key] = SortArraysInternal(kv.Value, ref count);
}
return newObj;
}
if (node is JsonArray arr)
{
count++;
// Recursively process children first
var processed = new List<JsonNode?>(arr.Count);
foreach (var item in arr)
processed.Add(SortArraysInternal(item, ref count));
// Sort by JSON serialization
processed.Sort((a, b) =>
StringComparer.Ordinal.Compare(
a?.ToJsonString() ?? "null",
b?.ToJsonString() ?? "null"));
var newArr = new JsonArray();
foreach (var item in processed)
newArr.Add(item);
return newArr;
}
if (node is JsonValue v)
{
return JsonNode.Parse(v.ToJsonString());
}
return null;
}
}

View File

@@ -0,0 +1,16 @@
name: engine-state
# Sidecar JSON profile: {"scene":{...}, "camera":{...}, "selection":{...}}
#
# Rules applied in order:
# 1. normalize_paths — mask document_path (user/temp dirs)
# 2. mask_guids — selected_ids GUIDs → <GUID> (order becomes irrelevant)
# 3. sort_array_elements — sort selected_ids after masking
# 4. round_floats — camera eye/target/up coords with 2 decimal places
# 5. sort_json_keys — deterministic key order for clean diff
float_decimals: 2
rules:
- normalize_paths
- mask_guids
- sort_array_elements
- round_floats
- sort_json_keys

View File

@@ -25,4 +25,17 @@ public interface IPlayerHost
void CaptureCheckpoint(int afterStep, string saveAs);
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);
/// <summary>
/// Attempt to restore camera state before the first step.
/// Called only when the scenario has a recorded <c>camera_snapshot</c>.
/// Implementations that do not support camera restore return false and the
/// engine continues normally. Default: returns false (no-op).
/// </summary>
bool TryRestoreCamera(double[] eye, double[] target, double[] up, double fov) => false;
}

View File

@@ -5,11 +5,22 @@ public sealed class Scenario
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public SutInfo Sut { get; set; } = new();
/// <summary>Camera state captured at recording start. When present, the player
/// restores it via the engine-bridge sidecar before the first step.</summary>
public ScenarioCameraSnapshot? CameraSnapshot { get; set; }
public List<Step> Steps { get; set; } = new();
public List<Checkpoint> Checkpoints { get; set; } = new();
public List<Baseline> Baselines { get; set; } = new();
}
public sealed class ScenarioCameraSnapshot
{
public double[] Eye { get; set; } = new double[3];
public double[] Target { get; set; } = new double[3];
public double[] Up { get; set; } = new double[3];
public double Fov { get; set; } = 45.0;
}
public sealed class SutInfo
{
public string Exe { get; set; } = string.Empty;

View File

@@ -22,6 +22,12 @@ public sealed class Step
public string? WaitFor { get; set; }
public int? AfterStep { 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

View File

@@ -1,3 +1,4 @@
using System.Linq;
using Recordingtest.Player.Model;
namespace Recordingtest.Player;
@@ -6,6 +7,14 @@ public sealed class PlayerEngineOptions
{
public TimeSpan ResolveTimeout { get; set; } = TimeSpan.FromSeconds(10);
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);
/// <summary>Speed multiplier. 2.0 = 2x faster (delays halved), 0.5 = half speed.</summary>
public double SpeedMultiplier { get; set; } = 1.0;
}
public sealed class PlayerEngine
@@ -17,14 +26,104 @@ public sealed class PlayerEngine
_options = options ?? new PlayerEngineOptions();
}
public void Run(Scenario scenario, IPlayerHost host)
public void Run(Scenario scenario, IPlayerHost host,
System.Threading.CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(scenario);
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;
}
// Issue #14: strip trailing recorder-stop noise. The common recording
// stop sequence is: alt+tab (switch back to recorder terminal) →
// optional click (focus terminal) → ctrl+c (stop recorder).
// Replaying this after real work is done would shift focus out of the
// SUT and send ctrl+c to a random window (browser, etc.).
// Pattern: from the end, remove ctrl+c hotkeys, then remove at most one
// click, then remove at most one alt+tab — but ONLY if at least one
// alt+tab was found (i.e. the pattern starts with focus-switch noise).
int end = scenario.Steps.Count;
{
int t = end - 1;
// strip trailing ctrl+c
int ctrlcCount = 0;
while (t >= start &&
scenario.Steps[t].Kind == StepKind.Hotkey &&
string.Equals(scenario.Steps[t].Value, "ctrl+c", StringComparison.OrdinalIgnoreCase))
{
t--;
ctrlcCount++;
}
// strip optional trailing click
if (t >= start && scenario.Steps[t].Kind == StepKind.Click)
t--;
// strip trailing alt+tab — only commit the trim if we found one
if (t >= start &&
scenario.Steps[t].Kind == StepKind.Hotkey &&
string.Equals(scenario.Steps[t].Value, "alt+tab", StringComparison.OrdinalIgnoreCase) &&
ctrlcCount > 0)
{
end = t; // exclude alt+tab and everything after
Console.WriteLine(
$"[player] info: stripped trailing recorder-stop noise (steps {t}..{scenario.Steps.Count - 1}) (issue #14)");
}
}
// Restore camera snapshot before the first step if one was captured
// at recording time. Best-effort: if the sidecar is unreachable or
// the host does not implement TryRestoreCamera, playback continues.
if (scenario.CameraSnapshot is { } cs)
{
var restored = host.TryRestoreCamera(cs.Eye, cs.Target, cs.Up, cs.Fov);
Console.WriteLine(restored
? $"[player] camera restored: eye=[{string.Join(",", cs.Eye.Select(v => v.ToString("F2")))}]"
: "[player] camera restore skipped (host does not support it or sidecar unavailable)");
}
// 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 < end && scenario.Steps[start].Ts is long firstTs
? firstTs - (long)_options.MinStepDelay.TotalMilliseconds
: null;
for (int i = start; i < end; i++)
{
ct.ThrowIfCancellationRequested();
var step = scenario.Steps[i];
if (_options.PreserveTiming && step.Ts is long ts)
{
if (prevTs is long p)
{
var delta = (long)((ts - p) / _options.SpeedMultiplier);
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
{
ExecuteStep(i, step, host);
@@ -56,6 +155,13 @@ public sealed class PlayerEngine
}
}
// Focus is a no-op regardless of whether a target is present (issue #11).
if (step.Kind == StepKind.Focus)
{
Console.WriteLine($"[player] info: focus step {index} — no-op (issue #11)");
return;
}
ResolvedElement? element = null;
ScreenPoint point = default;
if (step.Target is not null && !string.IsNullOrEmpty(step.Target.UiaPath))
@@ -63,19 +169,63 @@ public sealed class PlayerEngine
element = host.ResolveElement(step.Target.UiaPath, _options.ResolveTimeout);
if (element is null)
{
throw new InvalidOperationException(
$"failed to resolve uia_path '{step.Target.UiaPath}' at step {index}");
// Safety fallback for Click: if the anchor path failed to resolve
// (e.g. window layout changed), fall back to recorded raw_coord with a warning.
if (step.Kind == StepKind.Click && step.RawCoord is { Length: >= 2 })
{
Console.WriteLine(
$"[player] warn: step {index} uia_path '{step.Target.UiaPath}' unresolvable — falling back to raw_coord ({step.RawCoord[0]},{step.RawCoord[1]})");
point = new ScreenPoint(step.RawCoord[0], step.RawCoord[1]);
}
else if (step.Kind == StepKind.Type)
{
// CommandBox 등 AutomationPeer 미부착 컨트롤은 UIA로 접근 불가.
// 현재 포커스된 엘리먼트에 그대로 타이핑 (null-target Type과 동일 동작).
Console.WriteLine(
$"[player] warn: step {index} uia_path '{step.Target.UiaPath}' unresolvable — typing into focused element");
}
else if (step.Kind == StepKind.Drag && step.RawCoord is { Length: >= 2 })
{
Console.WriteLine(
$"[player] warn: step {index} uia_path '{step.Target.UiaPath}' unresolvable — falling back to raw_coord ({step.RawCoord[0]},{step.RawCoord[1]})");
point = new ScreenPoint(step.RawCoord[0], step.RawCoord[1]);
}
else
{
throw new InvalidOperationException(
$"failed to resolve uia_path '{step.Target.UiaPath}' at step {index}");
}
}
else
{
point = ComputeScreenPoint(element.Value.Bounds, step.Target.Offset);
}
point = ComputeScreenPoint(element.Value.Bounds, step.Target.Offset);
}
else if (StepRequiresTarget(step.Kind))
{
// Issue #11: recorder may emit Click/Drag/Type/Focus steps with
// null target. Never click/drag/type at (0,0) on the desktop —
// skip with a warning instead.
Console.WriteLine(
$"[player] warn: skipping step {index} kind={step.Kind} — target is null (issue #11)");
return;
// Issue #14: recorder emits Type/Click with null target when the
// focused element / UIA path at record time could not be resolved
// (e.g. typing into a CommandBox before any mouse click, clicks on
// canvas children that don't expose AutomationId). Fall back to:
// - Type → send keystrokes to whatever currently has focus
// - 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)
@@ -130,7 +280,7 @@ public sealed class PlayerEngine
StepKind.Click => true,
StepKind.Drag => true,
StepKind.Type => true,
StepKind.Focus => true,
StepKind.Focus => false, // no-op (issue #11) — target resolve not needed
_ => false,
};

View File

@@ -59,6 +59,13 @@ else
}
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();
try
{

View File

@@ -12,4 +12,7 @@
<PackageReference Include="FlaUI.UIA3" Version="4.0.0" />
<PackageReference Include="YamlDotNet" Version="16.1.3" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Recordingtest.Player.Tests" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Linq;
using FlaUI.Core;
using FlaUI.Core.AutomationElements;
using FlaUI.Core.Input;
@@ -18,12 +19,14 @@ public sealed class UiaPlayerHost : IPlayerHost, IDisposable
private readonly UIA3Automation _automation;
private readonly Application? _app;
private readonly string _artifactDir;
private readonly string? _sidecarUrl;
public UiaPlayerHost(Application? app, string artifactDir)
public UiaPlayerHost(Application? app, string artifactDir, string? sidecarUrl = "http://localhost:38080")
{
_automation = new UIA3Automation();
_app = app;
_artifactDir = artifactDir;
_sidecarUrl = sidecarUrl;
Directory.CreateDirectory(_artifactDir);
}
@@ -67,19 +70,27 @@ public sealed class UiaPlayerHost : IPlayerHost, IDisposable
return result.Result;
}
public void Click(ScreenPoint point) =>
public void Click(ScreenPoint point)
{
EnsureSutForegroundQuick();
Mouse.Click(new System.Drawing.Point(point.X, point.Y));
}
public void Type(string text) => Keyboard.Type(text);
public void Type(string text)
{
EnsureSutForegroundQuick();
Keyboard.Type(text);
}
public void Drag(ScreenPoint from, ScreenPoint to) =>
Mouse.Drag(
new System.Drawing.Point(from.X, from.Y),
new System.Drawing.Point(to.X, to.Y));
public void Hotkey(string keys)
internal sealed record ParsedHotkey(IReadOnlyList<VirtualKeyShort> Modifiers, VirtualKeyShort? Main);
internal static ParsedHotkey ParseHotkey(string keys)
{
// Minimal: support "ctrl+s" style.
var parts = keys.Split('+', StringSplitOptions.RemoveEmptyEntries);
var modifiers = new List<VirtualKeyShort>();
VirtualKeyShort? main = null;
@@ -90,17 +101,46 @@ public sealed class UiaPlayerHost : IPlayerHost, IDisposable
case "ctrl": modifiers.Add(VirtualKeyShort.CONTROL); break;
case "shift": modifiers.Add(VirtualKeyShort.SHIFT); break;
case "alt": modifiers.Add(VirtualKeyShort.ALT); break;
case "win": modifiers.Add(VirtualKeyShort.LWIN); break;
case "enter": main = VirtualKeyShort.RETURN; break;
case "return": main = VirtualKeyShort.RETURN; break;
case "tab": main = VirtualKeyShort.TAB; break;
case "escape": main = VirtualKeyShort.ESCAPE; break;
case "esc": main = VirtualKeyShort.ESCAPE; break;
case "space": main = VirtualKeyShort.SPACE; break;
case "backspace": main = VirtualKeyShort.BACK; break;
case "delete": main = VirtualKeyShort.DELETE; break;
case "del": main = VirtualKeyShort.DELETE; break;
case "home": main = VirtualKeyShort.HOME; break;
case "end": main = VirtualKeyShort.END; break;
case "pageup": main = VirtualKeyShort.PRIOR; break;
case "pagedown": main = VirtualKeyShort.NEXT; break;
case "up": main = VirtualKeyShort.UP; break;
case "down": main = VirtualKeyShort.DOWN; break;
case "left": main = VirtualKeyShort.LEFT; break;
case "right": main = VirtualKeyShort.RIGHT; break;
default:
if (p.Length == 1)
{
main = (VirtualKeyShort)char.ToUpperInvariant(p[0]);
}
else if (p.Length == 2 && p[0] == 'f' && char.IsDigit(p[1]))
{
main = (VirtualKeyShort)(0x70 + (p[1] - '0') - 1); // F1..F9
}
break;
}
}
foreach (var m in modifiers) Keyboard.Press(m);
if (main is not null) Keyboard.Type(main.Value);
foreach (var m in modifiers) Keyboard.Release(m);
return new ParsedHotkey(modifiers, main);
}
public void Hotkey(string keys)
{
EnsureSutForegroundQuick();
var parsed = ParseHotkey(keys);
foreach (var m in parsed.Modifiers) Keyboard.Press(m);
if (parsed.Main is not null) Keyboard.Type(parsed.Main.Value);
foreach (var m in parsed.Modifiers) Keyboard.Release(m);
}
public void CaptureCheckpoint(int afterStep, string saveAs)
@@ -177,6 +217,97 @@ 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();
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern bool SetForegroundWindow(IntPtr hWnd);
// Cached SUT HWND after first BringSutToForeground call.
private IntPtr _sutHwnd = IntPtr.Zero;
public void BringSutToForeground()
{
try
{
var w = _app?.GetMainWindow(_automation, TimeSpan.FromSeconds(5));
if (w is null) return;
_sutHwnd = 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.
var deadline = DateTime.UtcNow.AddSeconds(2);
while (DateTime.UtcNow < deadline)
{
if (_sutHwnd != IntPtr.Zero && GetForegroundWindow() == _sutHwnd)
break;
System.Threading.Thread.Sleep(25);
}
System.Threading.Thread.Sleep(100);
}
catch { /* best-effort */ }
}
/// <summary>
/// Re-ensure SUT is in foreground before each input step.
/// Called from Click/Type/Hotkey. Quick check (≤300ms) so it
/// doesn't slow normal playback when focus is already correct.
/// </summary>
private void EnsureSutForegroundQuick()
{
if (_sutHwnd == IntPtr.Zero) return;
if (GetForegroundWindow() == _sutHwnd) return;
SetForegroundWindow(_sutHwnd);
var deadline = DateTime.UtcNow.AddMilliseconds(300);
while (DateTime.UtcNow < deadline)
{
if (GetForegroundWindow() == _sutHwnd) break;
System.Threading.Thread.Sleep(20);
}
}
public void Delay(TimeSpan duration)
{
if (duration > TimeSpan.Zero)
System.Threading.Thread.Sleep(duration);
}
/// <summary>
/// POST to sidecar /camera/restore with the recorded camera state.
/// Returns true when the sidecar responds with 200 OK, false otherwise.
/// </summary>
public bool TryRestoreCamera(double[] eye, double[] target, double[] up, double fov)
{
if (string.IsNullOrEmpty(_sidecarUrl)) return false;
try
{
static string Vec(double[] v) =>
"[" + string.Join(",", v.Select(d => d.ToString("R", System.Globalization.CultureInfo.InvariantCulture))) + "]";
var body = "{\"eye\":" + Vec(eye) +
",\"target\":" + Vec(target) +
",\"up\":" + Vec(up) +
",\"fov\":" + fov.ToString("R", System.Globalization.CultureInfo.InvariantCulture) + "}";
using var http = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromSeconds(3) };
var content = new System.Net.Http.StringContent(body, System.Text.Encoding.UTF8, "application/json");
using var resp = http.PostAsync(_sidecarUrl.TrimEnd('/') + "/camera/restore", content)
.GetAwaiter().GetResult();
return resp.IsSuccessStatusCode;
}
catch
{
return false;
}
}
public void Dispose()
{
_automation.Dispose();

View File

@@ -35,6 +35,11 @@ public sealed class DragCollapser
var typeBuf = new System.Text.StringBuilder();
RawEvent? typeFirst = 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.
var modsDown = new HashSet<string>(StringComparer.Ordinal);
@@ -69,7 +74,7 @@ public sealed class DragCollapser
}
else
{
var fallback = lastFocusPath ?? lastMousePath;
var fallback = typeFocusPath ?? lastFocusPath ?? lastMousePath;
if (!string.IsNullOrEmpty(fallback))
{
step.Target = new ScenarioTarget
@@ -83,6 +88,7 @@ public sealed class DragCollapser
typeBuf.Clear();
typeFirst = null;
typeRes = null;
typeFocusPath = null;
}
foreach (var ev in events)
@@ -129,7 +135,7 @@ public sealed class DragCollapser
}
if (useSq >= threshSq)
{
// drag step
// drag step — use anchored path so viewport drags resolve
var step = new ScenarioStep
{
Kind = "drag",
@@ -139,13 +145,13 @@ public sealed class DragCollapser
};
if (downRes is not null)
{
var (sx, sy) = OffsetNormalizer.Normalize(
downRes.Snapshot.BoundingRectangle, down.X, down.Y);
var (ex, ey) = OffsetNormalizer.Normalize(
downRes.Snapshot.BoundingRectangle, ev.X, ev.Y);
var (anchorPath, sx, sy) = ElementPathBuilder.BuildAnchored(
downRes.Snapshot, down.X, down.Y);
var (_, ex, ey) = ElementPathBuilder.BuildAnchored(
downRes.Snapshot, ev.X, ev.Y);
step.Target = new ScenarioTarget
{
UiaPath = downRes.UiaPath,
UiaPath = anchorPath,
Offset = new[] { sx, sy },
};
step.EndOffset = new[] { ex, ey };
@@ -154,7 +160,7 @@ public sealed class DragCollapser
}
else
{
// click step at down point
// click step — use anchored path so viewport clicks resolve
var step = new ScenarioStep
{
Kind = "click",
@@ -163,11 +169,11 @@ public sealed class DragCollapser
};
if (downRes is not null)
{
var (ox, oy) = OffsetNormalizer.Normalize(
downRes.Snapshot.BoundingRectangle, down.X, down.Y);
var (anchorPath, ox, oy) = ElementPathBuilder.BuildAnchored(
downRes.Snapshot, down.X, down.Y);
step.Target = new ScenarioTarget
{
UiaPath = downRes.UiaPath,
UiaPath = anchorPath,
Offset = new[] { ox, oy },
};
if (MaskPolicy.IsMasked(downRes.Snapshot))
@@ -195,11 +201,11 @@ public sealed class DragCollapser
};
if (res is not null)
{
var (ox, oy) = OffsetNormalizer.Normalize(
res.Snapshot.BoundingRectangle, ev.X, ev.Y);
var (anchorPath, ox, oy) = ElementPathBuilder.BuildAnchored(
res.Snapshot, ev.X, ev.Y);
step.Target = new ScenarioTarget
{
UiaPath = res.UiaPath,
UiaPath = anchorPath,
Offset = new[] { ox, oy },
};
}
@@ -265,6 +271,9 @@ public sealed class DragCollapser
{
typeFirst = ev;
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);
break;
@@ -303,11 +312,11 @@ public sealed class DragCollapser
};
if (res is not null)
{
var (ox, oy) = OffsetNormalizer.Normalize(
res.Snapshot.BoundingRectangle, ev.X, ev.Y);
var (anchorPath, ox, oy) = ElementPathBuilder.BuildAnchored(
res.Snapshot, ev.X, ev.Y);
step.Target = new ScenarioTarget
{
UiaPath = res.UiaPath,
UiaPath = anchorPath,
Offset = new[] { ox, oy },
};
}

View File

@@ -58,4 +58,72 @@ public static class ElementPathBuilder
}
private static string Escape(string s) => s.Replace("'", "&apos;");
/// <summary>
/// Walk up from <paramref name="element"/> to the nearest "identifiable"
/// ancestor (inclusive) and compute (clickX, clickY) as a [0..1] offset
/// within that anchor's bounding rectangle.
///
/// An ancestor is identifiable when it has a non-empty AutomationId OR a
/// distinctive ClassName (i.e. not a generic WPF layout container such as
/// Canvas/Grid/Border). For example <c>HmEGViewport</c> has no AutomationId
/// but its ClassName is unique enough to resolve reliably at replay time.
///
/// Falls back to the full path of <paramref name="element"/> when no
/// identifiable ancestor is found.
/// </summary>
public static (string Path, double OffX, double OffY) BuildAnchored(
IElementSnapshot element, double clickX, double clickY)
{
IElementSnapshot? anchor = element;
while (anchor is not null && !IsIdentifiable(anchor))
anchor = anchor.Parent;
if (anchor is null)
{
var fallbackPath = Build(element);
var (ox, oy) = NormalizeOffset(element.BoundingRectangle, clickX, clickY);
return (fallbackPath, ox, oy);
}
var anchorPath = Build(anchor);
var (aox, aoy) = NormalizeOffset(anchor.BoundingRectangle, clickX, clickY);
return (anchorPath, aox, aoy);
}
/// <summary>
/// Returns true when the element can serve as a reliable path anchor:
/// it has a non-empty AutomationId, or its ClassName is distinctive
/// (not a common WPF layout-container class that appears dozens of times
/// in a typical tree without any unique attribute).
/// </summary>
private static bool IsIdentifiable(IElementSnapshot e)
{
if (!string.IsNullOrEmpty(e.AutomationId)) return true;
return !string.IsNullOrEmpty(e.ClassName) && !IsGenericWpfClass(e.ClassName);
}
// Common WPF layout/decorator classes that offer no uniqueness by themselves.
// Custom control classes (e.g. HmEGViewport, EGViewport) are NOT in this list
// and will be treated as identifiable anchors even without an AutomationId.
private static bool IsGenericWpfClass(string cls) => cls is
"Canvas" or "Grid" or "Border" or
"StackPanel" or "DockPanel" or "WrapPanel" or
"UniformGrid" or "ContentPresenter" or "ItemsPresenter" or
"ScrollViewer" or "ScrollContentPresenter" or
"Decorator" or "AdornerDecorator" or "AdornerLayer" or
"Panel" or "FrameworkElement" or "UIElement" or
"Visual" or "Popup" or "Rectangle" or
"Ellipse" or "Path" or "Shape";
private static (double ox, double oy) NormalizeOffset(
(double Left, double Top, double Width, double Height) b, double x, double y)
{
if (b.Width <= 0 || b.Height <= 0) return (0.0, 0.0);
double ox = (x - b.Left) / b.Width;
double oy = (y - b.Top) / b.Height;
if (ox < 0) ox = 0; if (ox > 1) ox = 1;
if (oy < 0) oy = 0; if (oy > 1) oy = 1;
return (ox, oy);
}
}

View File

@@ -0,0 +1,16 @@
namespace Recordingtest.Recorder;
/// <summary>
/// Pure decision for UIA focus_change events: keep only if the element belongs
/// to the attached SUT process. Used to avoid flooding scenarios with focus
/// events from VS Code / PowerShell / other foreground apps (issue #13 Gap F).
/// </summary>
public static class FocusEventFilter
{
public static bool ShouldAccept(int candidatePid, int sutPid)
{
if (sutPid <= 0) return true; // unknown SUT: permissive
if (candidatePid <= 0) return false; // unknown element pid: drop
return candidatePid == sutPid;
}
}

View File

@@ -27,6 +27,14 @@ public sealed class LowLevelHook : IDisposable
/// </summary>
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)
{
_channel = channel;
@@ -83,7 +91,8 @@ public sealed class LowLevelHook : IDisposable
NativeMethods.WM_KEYUP or NativeMethods.WM_SYSKEYUP => "key_up",
_ => "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))
{
_channel.Writer.TryWrite(ev);

View File

@@ -34,12 +34,13 @@ public static class Program
}
}
internal sealed record CliArgs(string OutputPath, string Attach);
internal sealed record CliArgs(string OutputPath, string Attach, string SidecarUrl);
internal static CliArgs? ParseArgs(string[] args)
{
string? output = null;
string? attach = null;
string? sidecarUrl = null;
for (int i = 0; i < args.Length; i++)
{
switch (args[i])
@@ -50,11 +51,15 @@ public static class Program
case "--attach" when i + 1 < args.Length:
attach = args[++i];
break;
case "--sidecar-url" when i + 1 < args.Length:
sidecarUrl = args[++i];
break;
}
}
if (string.IsNullOrEmpty(attach)) return null;
if (string.IsNullOrEmpty(output)) output = "scenarios/recorded.yaml";
return new CliArgs(output!, attach!);
if (string.IsNullOrEmpty(sidecarUrl)) sidecarUrl = "http://localhost:38080";
return new CliArgs(output!, attach!, sidecarUrl!);
}
internal static void PrintUsage()
@@ -72,10 +77,12 @@ public static class Program
Application? app = null;
UIA3Automation? automation = null;
AutomationElement? mainWindow = null;
int sutPid = 0;
try
{
(app, automation, mainWindow) = TryAttach(args.Attach);
if (app is not null) sutPid = app.ProcessId;
}
catch (Exception ex)
{
@@ -86,7 +93,6 @@ public static class Program
// any window not owned by the SUT process.
if (app is not null)
{
int sutPid = app.ProcessId;
hook.Filter = new SutProcessWindowFilter(
sutPid,
processFromPoint: (x, y) =>
@@ -106,10 +112,20 @@ public static class Program
Console.WriteLine($"[recorder] window filter active for pid={sutPid}");
}
// Capture camera state BEFORE recording starts via the engine-bridge sidecar.
// Best-effort: if the sidecar is unreachable the scenario is recorded without
// a camera_snapshot and the player will skip the restore step.
var cameraSnapshot = TryCaptureCamera(args.SidecarUrl);
if (cameraSnapshot is not null)
Console.WriteLine($"[recorder] camera snapshot captured: eye=[{string.Join(",", cameraSnapshot.Eye)}]");
else
Console.WriteLine("[recorder] camera snapshot unavailable (sidecar unreachable — OK)");
var scenario = new Scenario
{
Name = System.IO.Path.GetFileNameWithoutExtension(args.OutputPath),
Description = "Recorded session",
CameraSnapshot = cameraSnapshot,
};
var cts = new CancellationTokenSource();
@@ -119,6 +135,26 @@ public static class Program
cts.Cancel();
};
// When stdin is redirected (e.g. from LauncherUI pipe), watch for EOF
// so the UI can close stdin to trigger a graceful stop instead of
// sending Ctrl+C.
if (Console.IsInputRedirected)
{
_ = Task.Run(() =>
{
try
{
while (true)
{
int b = Console.In.Read();
if (b == -1) break; // EOF → parent closed stdin
}
}
catch { /* pipe broken */ }
cts.Cancel();
});
}
// Register UIA focus changed event. The callback only captures the
// element path and pushes a synthetic RawEvent into the same queue;
// it does NOT compute anything else inside the UIA callback.
@@ -131,6 +167,11 @@ public static class Program
try
{
if (el is null) return;
// Issue #13 Gap F — drop focus events from non-SUT processes.
int elPid = 0;
try { elPid = el.Properties.ProcessId.ValueOrDefault; }
catch { elPid = 0; }
if (!FocusEventFilter.ShouldAccept(elPid, sutPid)) return;
var snap = new FlaUiSnapshot(el);
var path = ElementPathBuilder.Build(snap);
channel.Writer.TryWrite(new RawEvent(
@@ -149,6 +190,63 @@ public static class Program
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.");
int eventCount = 0;
int unresolvedPaths = 0; // resolver ran but returned null
@@ -168,12 +266,20 @@ public static class Program
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.
var collapser = new DragCollapser();
UiaResolution? Resolve(RawEvent ev)
{
// Key events have no meaningful coordinate — resolver cannot attempt
// a point-based lookup. Count them separately from genuine misses.
// Issue #14 Gap I-1 — key events: Resolve() runs at collapse time
// (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")
{
noResolverAttempt++;
@@ -186,7 +292,8 @@ public static class Program
}
try
{
var snap = ResolveAt(automation, ev.X, ev.Y);
var source = new FlaUiPointSource(automation, mainWindow);
var snap = WindowPointResolver.Resolve(source, ev.X, ev.Y, sutPid);
if (snap is null)
{
unresolvedPaths++;
@@ -218,6 +325,11 @@ public static class Program
$"[recorder] done. events={eventCount} elapsed={sw.Elapsed} " +
$"unresolved_paths={unresolvedPaths} no_resolver_attempt={noResolverAttempt} " +
$"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();
return 0;
@@ -258,6 +370,49 @@ public static class Program
return (app, automation, main);
}
/// <summary>
/// GET /camera from the engine-bridge sidecar and return a <see cref="RecordedCameraSnapshot"/>
/// or null if the sidecar is unreachable or returns unexpected data.
/// Uses a short timeout (2 s) so it does not delay recording startup.
/// </summary>
internal static RecordedCameraSnapshot? TryCaptureCamera(string sidecarUrl)
{
try
{
using var http = new System.Net.Http.HttpClient
{
Timeout = TimeSpan.FromSeconds(2),
};
var resp = http.GetAsync(sidecarUrl.TrimEnd('/') + "/camera").GetAwaiter().GetResult();
if (!resp.IsSuccessStatusCode) return null;
var json = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult();
using var doc = System.Text.Json.JsonDocument.Parse(json);
var r = doc.RootElement;
if (r.TryGetProperty("error", out _)) return null; // sidecar error response
static double[] ToArr(System.Text.Json.JsonElement e)
{
var a = new double[e.GetArrayLength()];
int i = 0;
foreach (var item in e.EnumerateArray()) a[i++] = item.GetDouble();
return a;
}
return new RecordedCameraSnapshot
{
Eye = r.TryGetProperty("eye", out var eyeEl) ? ToArr(eyeEl) : new double[3],
Target = r.TryGetProperty("target", out var tgtEl) ? ToArr(tgtEl) : new double[3],
Up = r.TryGetProperty("up", out var upEl) ? ToArr(upEl) : new double[3],
Fov = r.TryGetProperty("fov", out var fovEl) ? fovEl.GetDouble() : 45.0,
};
}
catch
{
return null;
}
}
private static async Task ConsumeAsync(
ChannelReader<RawEvent> reader,
System.Collections.Generic.List<RawEvent> buffer,
@@ -274,11 +429,59 @@ public static class Program
}
}
private static IElementSnapshot? ResolveAt(UIA3Automation automation, int x, int y)
/// <summary>
/// FlaUI/Win32-backed <see cref="IWindowPointSource"/>. The SUT-scope
/// fallback is a best-effort stub (returns null) pending live verification
/// in smoke 3 — the load-bearing piece of Gap G is the pure
/// <see cref="WindowPointResolver"/> rule which falls back to the primary
/// result when the fallback returns null.
/// </summary>
private sealed class FlaUiPointSource : IWindowPointSource
{
var raw = automation.FromPoint(new System.Drawing.Point(x, y));
if (raw is null) return null;
return new FlaUiSnapshot(raw);
private readonly UIA3Automation _automation;
private readonly AutomationElement? _mainWindow;
public FlaUiPointSource(UIA3Automation automation, AutomationElement? mainWindow)
{
_automation = automation;
_mainWindow = mainWindow;
}
public int? GetProcessIdAt(int x, int y)
{
try
{
var hwnd = NativeMethods.WindowFromPoint(new NativeMethods.POINT { x = x, y = y });
if (hwnd == IntPtr.Zero) return null;
NativeMethods.GetWindowThreadProcessId(hwnd, out var pid);
return (int)pid;
}
catch
{
return null;
}
}
public IElementSnapshot? GetElementAt(int x, int y)
{
try
{
var raw = _automation.FromPoint(new System.Drawing.Point(x, y));
return raw is null ? null : new FlaUiSnapshot(raw);
}
catch
{
return null;
}
}
public IElementSnapshot? GetElementFromSutScope(int x, int y)
{
// Partial Gap G: honest stub. Returning null lets WindowPointResolver
// fall back to the primary element as a last resort. Full hit-test
// walker to be implemented once smoke 3 validates the surface.
return null;
}
}
}

View File

@@ -13,4 +13,7 @@
<PackageReference Include="FlaUI.UIA3" Version="4.0.0" />
<PackageReference Include="YamlDotNet" Version="16.1.3" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Recordingtest.Recorder.Tests" />
</ItemGroup>
</Project>

View File

@@ -7,9 +7,21 @@ public sealed class Scenario
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public ScenarioSut Sut { get; set; } = new();
/// <summary>Camera state captured at recording start via the engine-bridge sidecar.
/// Null when the sidecar was unreachable. The player uses this to restore the
/// viewport before the first step so replays are position-independent.</summary>
public RecordedCameraSnapshot? CameraSnapshot { get; set; }
public List<ScenarioStep> Steps { get; set; } = new();
}
public sealed class RecordedCameraSnapshot
{
public double[] Eye { get; set; } = new double[3];
public double[] Target { get; set; } = new double[3];
public double[] Up { get; set; } = new double[3];
public double Fov { get; set; } = 45.0;
}
public sealed class ScenarioSut
{
public string Exe { get; set; } = "EG-BIM Modeler/EG-BIM Modeler.exe";

View File

@@ -0,0 +1,38 @@
namespace Recordingtest.Recorder;
/// <summary>
/// Pluggable point-to-element lookup so the SUT-scoped fallback rule (issue
/// #13 Gap G) can be unit tested without live UIA or Win32.
/// </summary>
public interface IWindowPointSource
{
/// <summary>Owning process id of the top-level window at (x,y), or null if unknown.</summary>
int? GetProcessIdAt(int x, int y);
/// <summary>Primary UIA lookup — may return an element belonging to any process.</summary>
IElementSnapshot? GetElementAt(int x, int y);
/// <summary>SUT-scoped fallback — hit-test inside the attached SUT main window only.</summary>
IElementSnapshot? GetElementFromSutScope(int x, int y);
}
/// <summary>
/// Pure decision for viewport picking. If the primary lookup lands in a
/// foreign process, try an SUT-scoped descendant hit-test and prefer that.
/// If the foreign-process fallback returns null, fall back to the primary as
/// a last resort (documented semantic, covered by tests).
/// </summary>
public static class WindowPointResolver
{
public static IElementSnapshot? Resolve(IWindowPointSource source, int x, int y, int sutPid)
{
var primary = source.GetElementAt(x, y);
var pid = source.GetProcessIdAt(x, y);
if (pid is null || pid.Value == 0 || pid.Value == sutPid)
{
return primary;
}
var fallback = source.GetElementFromSutScope(x, y);
return fallback ?? primary;
}
}

View File

@@ -0,0 +1,95 @@
using System.Net.Http;
namespace Recordingtest.Runner;
/// <summary>
/// Generic (SUT-neutral) abstraction for capturing the engine state sidecar
/// at the end of a scenario playback. Implementations usually hit an
/// in-process HTTP bridge exposed by a plugin inside the SUT. Returns the
/// JSON body as a single string (pre-concatenated across endpoints) or
/// <c>null</c> if the bridge is unreachable, so the runner can degrade
/// gracefully when the sidecar is simply not available.
/// </summary>
public interface IEngineStateSnapshotClient
{
/// <summary>
/// Best-effort capture. Never throws — returns null on any failure.
/// </summary>
string? TryCapture();
}
/// <summary>
/// Default HTTP implementation. GETs the three well-known read-only
/// endpoints (/scene, /camera, /selection) off a base URL and composes the
/// three responses into one JSON object:
///
/// { "scene": {...}, "camera": {...}, "selection": {...} }
///
/// The Runner is the Generic tier, so this client must not know about any
/// HmEG-specific response shape — it simply forwards the raw JSON strings.
/// Normalization and tolerance rules are applied downstream by Normalizer.
/// </summary>
public sealed class HttpEngineStateSnapshotClient : IEngineStateSnapshotClient, IDisposable
{
public const string DefaultBaseUrl = "http://localhost:38080";
private readonly string _baseUrl;
private readonly HttpClient _http;
private readonly bool _ownsClient;
public HttpEngineStateSnapshotClient(
string baseUrl = DefaultBaseUrl,
HttpClient? httpClient = null,
TimeSpan? timeout = null)
{
_baseUrl = baseUrl.TrimEnd('/');
if (httpClient is null)
{
_http = new HttpClient { Timeout = timeout ?? TimeSpan.FromSeconds(2) };
_ownsClient = true;
}
else
{
_http = httpClient;
if (timeout.HasValue) _http.Timeout = timeout.Value;
_ownsClient = false;
}
}
public string? TryCapture()
{
try
{
var scene = Get("/scene");
var camera = Get("/camera");
var selection = Get("/selection");
if (scene is null || camera is null || selection is null) return null;
// Stable ordering (scene, camera, selection) → diff-friendly.
return "{\"scene\":" + scene +
",\"camera\":" + camera +
",\"selection\":" + selection + "}";
}
catch
{
return null;
}
}
private string? Get(string endpoint)
{
try
{
using var resp = _http.GetAsync(_baseUrl + endpoint).GetAwaiter().GetResult();
if (!resp.IsSuccessStatusCode) return null;
return resp.Content.ReadAsStringAsync().GetAwaiter().GetResult();
}
catch
{
return null;
}
}
public void Dispose()
{
if (_ownsClient) _http.Dispose();
}
}

View File

@@ -5,6 +5,8 @@ public static class Program
public static int Main(string[] args)
{
var options = new RunnerOptions();
string? sidecarUrl = null;
bool noSidecar = false;
for (int i = 0; i < args.Length; i++)
{
switch (args[i])
@@ -14,9 +16,17 @@ public static class Program
case "--out": options.OutDir = args[++i]; break;
case "--profile": options.Profile = args[++i]; break;
case "--no-launch": options.NoLaunch = true; break;
case "--sidecar-url": sidecarUrl = args[++i]; break;
case "--no-sidecar": noSidecar = true; break;
case "--sidecar-profile": options.SidecarProfile = args[++i]; break;
case "--scenario": options.ScenarioFilter = args[++i]; break;
case "-h":
case "--help":
Console.WriteLine("Usage: Recordingtest.Runner --scenarios <dir> --baselines <dir> --out <dir> [--profile <name>] [--no-launch]");
Console.WriteLine("Usage: Recordingtest.Runner --scenarios <dir> --baselines <dir> --out <dir>");
Console.WriteLine(" [--profile <name>] [--no-launch]");
Console.WriteLine(" [--sidecar-url http://localhost:38080] [--no-sidecar]");
Console.WriteLine(" [--sidecar-profile engine-state]");
Console.WriteLine(" [--scenario <name>] (run only this scenario)");
return 0;
}
}
@@ -29,12 +39,24 @@ public static class Program
return 2;
}
// engine-bridge v3 — default to the well-known localhost bridge port
// unless the caller explicitly opts out or overrides the URL.
IEngineStateSnapshotClient? sidecar = null;
if (!noSidecar)
{
sidecar = new HttpEngineStateSnapshotClient(
sidecarUrl ?? HttpEngineStateSnapshotClient.DefaultBaseUrl);
}
var runner = new TestRunner();
var report = runner.RunAll(
options,
new DefaultHostFactory(),
new DefaultNormalizer(),
new DefaultDiffer());
new DefaultDiffer(),
sidecarClient: sidecar);
(sidecar as IDisposable)?.Dispose();
Console.WriteLine($"Total: {report.Total}, Passed: {report.Passed}, Failed: {report.Failed}, Errored: {report.Errored}");
return TestRunner.ToExitCode(report);

View File

@@ -18,4 +18,12 @@ public sealed class ScenarioResult
public int CheckpointCount { get; set; }
public string ArtifactDir { get; set; } = string.Empty;
public string? Error { get; set; }
// engine-bridge sidecar (v3): optional semantic-state diff.
// - Captured — sidecar JSON was successfully fetched from the bridge
// - Hunks — diff hunks against the engine-state baseline
// - Status — "pass" / "fail" / "missing_baseline" / "unavailable" / "skipped"
public bool SidecarCaptured { get; set; }
public int SidecarHunks { get; set; }
public string SidecarStatus { get; set; } = "skipped";
}

View File

@@ -6,5 +6,9 @@ public sealed class RunnerOptions
public string BaselinesDir { get; set; } = string.Empty;
public string OutDir { get; set; } = string.Empty;
public string Profile { get; set; } = "default";
/// <summary>Profile name for engine-state sidecar normalization. Defaults to "engine-state".</summary>
public string SidecarProfile { get; set; } = "engine-state";
public bool NoLaunch { get; set; }
/// <summary>If set, only run scenarios whose name matches this value (no extension).</summary>
public string? ScenarioFilter { get; set; }
}

View File

@@ -13,7 +13,8 @@ public sealed class TestRunner
RunnerOptions options,
IRunnerHostFactory hostFactory,
INormalizer normalizer,
IDiffer differ)
IDiffer differ,
IEngineStateSnapshotClient? sidecarClient = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(hostFactory);
@@ -26,6 +27,8 @@ public sealed class TestRunner
var yamlFiles = Directory.Exists(options.ScenariosDir)
? Directory.GetFiles(options.ScenariosDir, "*.yaml", SearchOption.TopDirectoryOnly)
.Where(p => options.ScenarioFilter is null ||
Path.GetFileNameWithoutExtension(p).Equals(options.ScenarioFilter, StringComparison.OrdinalIgnoreCase))
.OrderBy(p => p, StringComparer.Ordinal).ToArray()
: Array.Empty<string>();
@@ -50,6 +53,19 @@ public sealed class TestRunner
var host = hostFactory.Create(scenario, artifactDir);
var engine = new PlayerEngine();
engine.Run(scenario, host);
// engine-bridge v3 sidecar: capture the engine state AFTER
// playback completes, before the host releases the SUT. The
// client is best-effort — null return or exception means the
// bridge is unreachable and we keep going without a sidecar.
CaptureAndDiffSidecar(
sidecarClient,
options,
scenarioName,
artifactDir,
normalizer,
differ,
sr);
}
catch (Exception ex)
{
@@ -132,6 +148,98 @@ public sealed class TestRunner
return report;
}
/// <summary>
/// engine-bridge v3 — capture engine state sidecar at scenario end and
/// diff it against the approved engine-state baseline (if any). Updates
/// <paramref name="sr"/> with the sidecar status and hunk count, and
/// promotes the scenario status to "fail" if the sidecar diverges.
/// The bridge is treated as strictly optional: a missing client, a
/// null capture, or a missing baseline degrade to benign statuses
/// instead of failing the run.
/// </summary>
private static void CaptureAndDiffSidecar(
IEngineStateSnapshotClient? sidecarClient,
RunnerOptions options,
string scenarioName,
string artifactDir,
INormalizer normalizer,
IDiffer differ,
ScenarioResult sr)
{
if (sidecarClient is null)
{
sr.SidecarStatus = "skipped";
return;
}
string? captured;
try { captured = sidecarClient.TryCapture(); }
catch { captured = null; }
if (string.IsNullOrEmpty(captured))
{
sr.SidecarStatus = "unavailable";
return;
}
sr.SidecarCaptured = true;
var receivedPath = Path.Combine(artifactDir, "engine-state.received.json");
File.WriteAllText(receivedPath, captured);
var baselinePath = FindSidecarBaseline(options.BaselinesDir, scenarioName);
if (baselinePath is null)
{
// First run: no approved engine-state baseline yet. Keep the
// received file around so the user can promote it with /approve.
sr.SidecarStatus = "missing_baseline";
return;
}
try
{
var receivedRaw = File.ReadAllText(receivedPath);
var approvedRaw = File.ReadAllText(baselinePath);
var receivedNorm = normalizer.Normalize(receivedRaw, options.SidecarProfile, null);
var approvedNorm = normalizer.Normalize(approvedRaw, options.SidecarProfile, null);
var receivedNormPath = Path.Combine(artifactDir, "engine-state.received.normalized");
var approvedNormPath = Path.Combine(artifactDir, "engine-state.approved.normalized");
File.WriteAllText(receivedNormPath, receivedNorm);
File.WriteAllText(approvedNormPath, approvedNorm);
var diff = differ.Compare(approvedNormPath, receivedNormPath);
sr.SidecarHunks = diff.Hunks.Count;
sr.SidecarStatus = diff.Identical ? "pass" : "fail";
// Sidecar divergence promotes the scenario status. The main
// result file diff runs AFTER this method, so only promote to
// "fail" if it had not yet been set to "error" or "fail".
if (!diff.Identical && sr.Status == "pass")
{
sr.Status = "fail";
}
}
catch (Exception ex)
{
sr.SidecarStatus = "error";
sr.Error ??= "sidecar: " + ex.Message;
}
}
private static string? FindSidecarBaseline(string baselinesDir, string scenarioName)
{
if (string.IsNullOrEmpty(baselinesDir) || !Directory.Exists(baselinesDir))
return null;
var candidates = new[]
{
Path.Combine(baselinesDir, scenarioName + ".engine-state.approved.json"),
Path.Combine(baselinesDir, scenarioName + ".engine-state.json"),
};
foreach (var c in candidates)
if (File.Exists(c)) return c;
return null;
}
private static string? FindBaseline(string baselinesDir, string scenarioName, string preferredExt)
{
if (string.IsNullOrEmpty(baselinesDir) || !Directory.Exists(baselinesDir))
@@ -171,13 +279,15 @@ public sealed class TestRunner
.Append(" | Failed: ").Append(report.Failed)
.Append(" | Errored: ").AppendLine(report.Errored.ToString());
sb.AppendLine();
sb.AppendLine("| Scenario | Status | Hunks | Checkpoints | Artifacts |");
sb.AppendLine("|----------|--------|-------|-------------|-----------|");
sb.AppendLine("| Scenario | Status | Hunks | Sidecar | Sidecar Hunks | Checkpoints | Artifacts |");
sb.AppendLine("|----------|--------|-------|---------|---------------|-------------|-----------|");
foreach (var s in report.Scenarios)
{
sb.Append("| ").Append(s.Name)
.Append(" | ").Append(s.Status)
.Append(" | ").Append(s.Hunks)
.Append(" | ").Append(s.SidecarStatus)
.Append(" | ").Append(s.SidecarHunks)
.Append(" | ").Append(s.CheckpointCount)
.Append(" | ").Append(s.ArtifactDir)
.AppendLine(" |");

View File

@@ -1,7 +1,7 @@
using System.Net;
using System.Text;
namespace Recordingtest.EgPlugin;
namespace Recordingtest.Sut.EgBim.PluginHost;
/// <summary>
/// Hosts an HttpListener that delegates path routing to <see cref="StateRouter"/>.
@@ -41,8 +41,20 @@ public sealed class BridgeHttpServer : IDisposable
catch { return; }
try
{
var path = ctx.Request.Url?.AbsolutePath ?? "/";
var (status, body) = _router.Route(path);
var path = ctx.Request.Url?.AbsolutePath ?? "/";
var method = ctx.Request.HttpMethod ?? "GET";
string requestBody = string.Empty;
if (ctx.Request.HasEntityBody)
{
try
{
using var reader = new System.IO.StreamReader(
ctx.Request.InputStream, Encoding.UTF8);
requestBody = reader.ReadToEnd();
}
catch { /* ignore body read failures */ }
}
var (status, body) = _router.Route(method, path, requestBody);
var bytes = Encoding.UTF8.GetBytes(body);
ctx.Response.StatusCode = (int)status;
ctx.Response.ContentType = "application/json";

View File

@@ -0,0 +1,65 @@
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();
/// <summary>
/// Camera writes go to the primary only (HmegDirectStateProvider).
/// The reflection fallback does not support writes; chaining writes would
/// risk applying a stale camera twice.
/// </summary>
public void SetCamera(CameraSnapshot snapshot)
{
try { _primary.SetCamera(snapshot); }
catch { /* never throw from sidecar thread */ }
}
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,130 @@
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 = () =>
{
// EditorPlugin.View is only populated when the plugin is actually
// Run() by a user command; our bridge plugin lives as a long-
// running HTTP server and never runs a trigger, so View stays
// null. Instead pull the active viewport from the global
// ViewportManager, preferring FocusedViewport, then falling back
// to any registered viewport. EGViewport implements HmEGViewport.
try
{
var vm = AppManager?.ViewportManager;
if (vm is null) return null;
var focused = vm.FocusedViewport;
if (focused is not null) return focused;
var any = vm.Viewports;
if (any is null) return null;
foreach (var v in any)
{
if (v is not null) return v;
}
return null;
}
catch { return null; }
};
Func<string?> documentPathProvider = () =>
{
try
{
var path = AppManager?.FileManager?.CurrentFile;
return string.IsNullOrEmpty(path) ? null : path;
}
catch { return null; }
};
// Dispatch camera writes onto the WPF UI thread so DependencyProperty
// setters on CameraCore are called from the correct thread.
Action<Action>? uiDispatch = null;
try
{
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher is not null)
uiDispatch = action => dispatcher.Invoke(action);
}
catch { /* best-effort: leave null, SetCamera falls back to direct call */ }
var direct = new HmegDirectStateProvider(spaceProvider, viewportProvider, documentPathProvider, uiDispatch);
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,89 @@
// 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;
}
public void SetCamera(CameraSnapshot snapshot)
{
// Reflection fallback does not implement camera write; silently no-op.
// The primary HmegDirectStateProvider handles this in production.
}
}

View File

@@ -1,4 +1,4 @@
namespace Recordingtest.EgPlugin;
namespace Recordingtest.Sut.EgBim.PluginHost;
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,10 @@
using System.Globalization;
using System.Net;
using System.Text;
using System.Text.Json;
using Recordingtest.Bridge;
namespace Recordingtest.EgPlugin;
namespace Recordingtest.Sut.EgBim.PluginHost;
/// <summary>
/// Pure logic router: maps a request path to (status, json body).
@@ -19,12 +21,19 @@ public sealed class StateRouter
_port = port;
}
public (HttpStatusCode Status, string Body) Route(string path)
/// <summary>Backwards-compatible GET overload for unit tests.</summary>
public (HttpStatusCode Status, string Body) Route(string path) => Route("GET", path, "");
public (HttpStatusCode Status, string Body) Route(string method, string path, string body = "")
{
var p = (path ?? "/").TrimEnd('/');
if (p.Length == 0) p = "/";
var m = (method ?? "GET").ToUpperInvariant();
try
{
if (m == "POST" && p == "/camera/restore")
return (HttpStatusCode.OK, RestoreCamera(body));
return p switch
{
"/health" => (HttpStatusCode.OK, $"{{\"status\":\"ok\",\"port\":{_port}}}"),
@@ -41,6 +50,34 @@ public sealed class StateRouter
}
}
private string RestoreCamera(string body)
{
try
{
using var doc = JsonDocument.Parse(body);
var r = doc.RootElement;
var eye = ReadVecFromJson(r, "eye");
var target = ReadVecFromJson(r, "target");
var up = ReadVecFromJson(r, "up");
double fov = r.TryGetProperty("fov", out var fovEl) ? fovEl.GetDouble() : 45.0;
_provider.SetCamera(new CameraSnapshot(eye, target, up, fov));
return "{\"ok\":true}";
}
catch (Exception ex)
{
return $"{{\"ok\":false,\"error\":{JsonString(ex.Message)}}}";
}
}
private static double[] ReadVecFromJson(JsonElement root, string key)
{
if (!root.TryGetProperty(key, out var arr)) return new double[3];
var result = new double[arr.GetArrayLength()];
int i = 0;
foreach (var e in arr.EnumerateArray()) result[i++] = e.GetDouble();
return result;
}
private string BuildSelection()
{
var ids = _provider.GetSelectedIds();

Some files were not shown because too many files have changed in this diff Show More