Compare commits
22 Commits
b20ec32c36
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11eb92b2b2 | ||
|
|
6bc71afd32 | ||
|
|
39f70dfb56 | ||
|
|
a310ca2ce4 | ||
|
|
612cc8ac51 | ||
|
|
190cc6e596 | ||
|
|
800ea9c175 | ||
|
|
4c5f81c87e | ||
|
|
e28a029704 | ||
|
|
062a285462 | ||
|
|
9fe053619f | ||
|
|
03fb504eea | ||
|
|
f6b6e7449e | ||
|
|
a771352bcb | ||
|
|
98d801442b | ||
|
|
70bf5703b3 | ||
|
|
4ba5b3d74b | ||
|
|
b139f2b169 | ||
|
|
7db9cd08e1 | ||
|
|
2428827df6 | ||
|
|
eeee3c2a03 | ||
|
|
0f0324efb5 |
45
.claude/agents/evaluator-ko.md
Normal file
45
.claude/agents/evaluator-ko.md
Normal 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 조각 |
|
||||||
|
|
||||||
|
## 비고
|
||||||
|
<자유 형식 관찰, 엣지 케이스, 후속 조치>
|
||||||
|
```
|
||||||
55
.claude/agents/planner-ko.md
Normal file
55
.claude/agents/planner-ko.md
Normal 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개 이하로 유지한다. 그 이상이면 범위를 분할해야 한다는 신호다.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"app_key": "A-SH-7756143445",
|
"app_key": "A-SH-1673443719",
|
||||||
"aptabase_host": "https://aptabase.hmac.kr",
|
"aptabase_host": "https://aptabase.hmac.kr",
|
||||||
"user_name": "김민성(b16213)",
|
"user_name": "김민성(b16213)",
|
||||||
"git_repositories": [
|
"git_repositories": [
|
||||||
|
|||||||
1
.claude/hooks/token-usage/.gitignore
vendored
Normal file
1
.claude/hooks/token-usage/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
aptabase.json
|
||||||
@@ -60,6 +60,33 @@
|
|||||||
"command": "bash .claude/hooks/aptabase-accumulate.sh"
|
"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": [
|
"PostToolUse": [
|
||||||
@@ -71,6 +98,65 @@
|
|||||||
"command": "bash .claude/hooks/aptabase-commit.sh"
|
"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
1
.gitignore
vendored
@@ -20,3 +20,4 @@ baselines/**/*.received.*
|
|||||||
# Local smoke test output
|
# Local smoke test output
|
||||||
artifacts/
|
artifacts/
|
||||||
scenarios/
|
scenarios/
|
||||||
|
.usage/
|
||||||
|
|||||||
182
CLAUDE.md
182
CLAUDE.md
@@ -1,169 +1,59 @@
|
|||||||
# CLAUDE.md — recordingtest
|
# 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) — 지금까지 *무엇이 끝났는가*. 모듈별 진행 상태, 최근 완료 작업, 현재 차단 이슈.
|
비자명한 작업은 반드시 3단계:
|
||||||
2. [PLAN.md](PLAN.md) — 앞으로 *무엇을 해야 하는가*. 모듈별 To-Do, 담당 에이전트, 우선순위, 의존 관계.
|
|
||||||
|
|
||||||
읽고 나서 자신이 맡을 작업을 PLAN.md에서 고르고, 시작 시 PROGRESS.md에 "in progress"로 표시한다. 작업이 끝나면:
|
1. `/contract <name>` → `docs/contracts/<name>.md` (Goal, DoD, Interfaces, Risks)
|
||||||
- PROGRESS.md 의 해당 항목을 "done"으로 옮기고 날짜·결과·산출물 경로 기록
|
2. Generator — DoD만 충족, 스코프 이탈 금지
|
||||||
- PLAN.md 의 완료 항목 제거 또는 다음 단계로 갱신
|
3. `/evaluate <name>` → `docs/contracts/<name>.evaluation.md` — **pass여야만** PROGRESS.md Done 이동
|
||||||
- `docs/history/YYYY-MM-DD_{작업명}.md` 히스토리 파일 작성 (소요 시간·Context 사용량 필수)
|
|
||||||
|
|
||||||
**원칙:** 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로 변환.
|
전략: 입력 레코딩 → 리플레이 → 결과물을 `*.approved.*` 베이스라인과 diff (ApprovalTests 패턴).
|
||||||
- 산출물: `docs/contracts/<name>.md` (Goal, **Definition of Done**, Interfaces, Out of scope, Evaluation plan, Risks)
|
|
||||||
- DoD 항목은 **객관적으로 검증 가능**해야 한다. "잘 동작한다"는 금지.
|
|
||||||
- `PLAN.md`에 해당 항목 추가.
|
|
||||||
|
|
||||||
2. **Generator** — Sprint Contract를 계약으로 삼고 실제 구현. 일반 세션 또는 전용 구현 에이전트가 수행.
|
## 코드 계층 (의무)
|
||||||
- 계약을 읽고 DoD 항목만 충족시키는 데 집중.
|
|
||||||
- 스코프 이탈 금지. 범위 변경이 필요하면 planner를 다시 호출.
|
|
||||||
|
|
||||||
3. **Evaluator (`/evaluate <name>`)** — 독립된 `evaluator` 서브에이전트가 계약 기준으로 채점.
|
3개 계층, 단방향 의존:
|
||||||
- 산출물: `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 코드 변경 협조를 최소화하기 위한 의도적 선택.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
[수동 테스트] → 입력 레코드 + 결과 파일 A (baseline)
|
App-specific (Sut/EgBim/) → HmEG-aware (Hmeg/) → Generic (src/ 직속)
|
||||||
[회귀 시점] → 입력 리플레이 → 결과 파일 B → normalize → diff(A, B)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**우선순위:**
|
| 계층 | 참조 가능 | 위치 | 네임스페이스 |
|
||||||
- 1순위: recorder, player, 정규화(normalizer), diff-reporter
|
|---|---|---|---|
|
||||||
- 2순위: engine-bridge (엔진 상태 sidecar JSON 덤프)
|
| Generic | .NET BCL, FlaUI, Win32, YAML만 | `src/` | `Recordingtest.*` |
|
||||||
- 후순위: viewport-verifier (픽셀/이미지 비교) — golden-file로 못 잡는 케이스 보강용
|
| 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 추가 시 `/contract` 필수. 폴더 레이아웃: [docs/architecture.md](docs/architecture.md)
|
||||||
|------|------|
|
|
||||||
| `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 비교 |
|
|
||||||
|
|
||||||
각 모듈은 독립 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. 반드시 지킬 설계 원칙
|
- Origin: https://gitea.hmac.kr/kimminsung/recordingtest (이슈 트래커 동일)
|
||||||
|
- 커밋·PR에 이슈 번호(#N) 참조
|
||||||
1. **결정성(Determinism) 우선** — 비결정적 요소(시각·랜덤·경로·GUID·부동소수점·컬렉션 순서)는 정규화 파이프라인을 통과해야 한다. 새 필드 추가 시 정규화 규칙 동시 등록.
|
- 기술 결정 근거: `docs/history/` + Claude 메모리
|
||||||
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_*`)에 분산 저장된다. 새 결정 시 반드시 둘 다 갱신한다.
|
|
||||||
|
|||||||
14
PLAN.md
14
PLAN.md
@@ -5,13 +5,17 @@
|
|||||||
|
|
||||||
## P0 — 지금 바로
|
## P0 — 지금 바로
|
||||||
|
|
||||||
1. **훅 동작 검증** — SessionStart/Stop/Guard 3개 shell 스크립트를 실제로 트리거시켜 확인
|
_(없음 — 훅 동작 확인 완료: jq 설치 ✓, SessionStart/Stop 훅 실 동작 확인 ✓)_
|
||||||
- 의존: jq 설치 여부 확인
|
|
||||||
|
|
||||||
## P1 — 라이브 검증 (사용자 환경 필요)
|
## P1 — 다음 통합 단계
|
||||||
|
|
||||||
4. **라이브 SUT smoke test 실행** — `docs/guides/smoke-test.md` 따라 수동 수행
|
1. **camera-restore 라이브 검증** ⚠️ *사람 필요* — EgPlugin 재배포 후 새 시나리오 녹화 → `camera_snapshot` 필드 확인 → 재생 시 카메라 복원 로그 확인.
|
||||||
5. **engine-bridge v3** — ReflectionEngineStateProvider 실매핑 (smoke test 이후)
|
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)
|
## Follow-ups (non-blocking)
|
||||||
|
|
||||||
|
|||||||
27
PROGRESS.md
27
PROGRESS.md
@@ -39,10 +39,19 @@
|
|||||||
| 2026-04-07 | Smoke gap fix + Evaluator pass (#11) — STAThread, KeyTranslator, 60 tests, regression trap 검증 | commit `139fbbc` |
|
| 2026-04-07 | Smoke gap fix + Evaluator pass (#11) — STAThread, KeyTranslator, 60 tests, regression trap 검증 | commit `139fbbc` |
|
||||||
| 2026-04-07 | Smoke test 1회차 — recorder PID attach + UIA target 정상 (box-v4), player 재생 부분 실패 | `docs/history/2026-04-07_smoke-1회차-결과.md`, scenarios/box-v4*.yaml |
|
| 2026-04-07 | Smoke test 1회차 — recorder PID attach + UIA target 정상 (box-v4), player 재생 부분 실패 | `docs/history/2026-04-07_smoke-1회차-결과.md`, scenarios/box-v4*.yaml |
|
||||||
| 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 | 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` |
|
||||||
## In progress
|
| 2026-04-07 | normalizer follow-ups + Evaluator pass — float epsilon 구성화 + JSON-path 마스크 스코핑, 77 tests | commit `eeee3c2` |
|
||||||
|
| 2026-04-08 | **Smoke test 2회차 — 첫 E2E 성공** 🎉 Box geometry 생성 확인 | `docs/history/2026-04-08_smoke-2회차-첫-e2e-성공.md`, `scenarios/box-v5*.yaml` |
|
||||||
_(없음 — Smoke 2회차 라이브 검증 대기)_
|
| 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
|
## In progress
|
||||||
|
|
||||||
@@ -50,14 +59,16 @@ _(없음)_
|
|||||||
|
|
||||||
## Follow-ups
|
## Follow-ups
|
||||||
|
|
||||||
- [ ] sut-prober JSON naming을 `JsonNamingPolicy.SnakeCaseLower`로 변경 (contract 엄격 준수). non-blocking.
|
- [x] ~~sut-prober JSON naming snake_case~~ — commit `0f0324e`
|
||||||
- [ ] diff-reporter: 실제 `diff-triager` 에이전트 통합 테스트 (현재 schema 단위 테스트로 대체, DoD #8 partial). non-blocking.
|
- [x] ~~normalizer: mask_volatile_settings JSON-path 스코핑~~ — commit `eeee3c2`
|
||||||
- [ ] normalizer: `mask_volatile_settings` 규칙을 JSON-path 스코핑으로 제한 (현재는 필드명 전역 매칭). non-blocking risk.
|
- [x] ~~normalizer: float epsilon 구성화~~ — commit `eeee3c2`
|
||||||
- [ ] normalizer: float epsilon 구성화 (현재 6 decimals 하드코딩). contract risks 섹션.
|
- [ ] diff-reporter: 실제 `diff-triager` 에이전트 통합 테스트. non-blocking.
|
||||||
- [ ] recorder/player: **라이브 SUT 수동 smoke test** — 60 FPS / 10회 중 9회 reliability DoD는 샌드박스 unit test 불가, 실제 환경에서 검증 필요.
|
- [ ] recorder/player: **라이브 SUT 수동 smoke test** — 60 FPS / 10회 중 9회 reliability DoD는 샌드박스 unit test 불가, 실제 환경에서 검증 필요.
|
||||||
- [ ] player: `wait_for` UIA 이벤트 매핑 강화 (현재 host passthrough).
|
- [ ] player: `wait_for` UIA 이벤트 매핑 강화 (현재 host passthrough).
|
||||||
- [ ] player: `UiaPlayerHost` uia_path resolver가 마지막 `@AutomationId`만 사용 — 전체 ancestor chain 지원 필요.
|
- [ ] player: `UiaPlayerHost` uia_path resolver가 마지막 `@AutomationId`만 사용 — 전체 ancestor chain 지원 필요.
|
||||||
- [ ] recorder: IME 조합 키 처리 (contract risks).
|
- [ ] recorder: IME 조합 키 처리 (contract risks).
|
||||||
|
- [x] ~~player: foreground settle 안정화~~ — 능동 대기(`GetForegroundWindow` polling 2s + 100ms settle)로 전환, 1차 재생 성공 확인
|
||||||
|
- [~] recorder Gap I-1 — UIA `Automation.FocusedElement` 폴링 PoC 시도(commit pending). 결과: SUT의 CommandBox 등 AutomationPeer 미부착 컨트롤은 UIA 외부에서 본질적으로 못 봄. **deferred** — generic WPF DLL injection 또는 SUT-side AutomationPeer 부착 PoC가 필요. 현재는 Player fallback(null target → OS 키 입력 / raw_coord 클릭)이 공식 전략.
|
||||||
|
|
||||||
## Blocked
|
## Blocked
|
||||||
|
|
||||||
|
|||||||
81
README.md
81
README.md
@@ -12,18 +12,45 @@
|
|||||||
[회귀 시점] → 입력 리플레이 → 결과 파일 B → normalize → diff(A, B)
|
[회귀 시점] → 입력 리플레이 → 결과 파일 B → normalize → diff(A, B)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 3계층 아키텍처
|
||||||
|
|
||||||
|
본 도구는 **EG-BIM Modeler 외 다양한 WPF 응용**을 대상으로 하고, 사용자 WPF 응용군 대다수가 자체 3D 엔진 **HmEG**를 공유한다. 따라서 코드는 엄격히 3계층으로 분리된다 (의존 방향 단방향, 자세한 규칙은 [CLAUDE.md §8.1](CLAUDE.md)):
|
||||||
|
|
||||||
|
```
|
||||||
|
App-specific (e.g. EgBim) ──→ HmEG-aware ──→ Generic
|
||||||
|
(특정 앱만) (HmEG 호스팅 앱 공통) (임의 WPF 앱)
|
||||||
|
```
|
||||||
|
|
||||||
|
`Recordingtest.Architecture.Tests` 가 `Assembly.GetReferencedAssemblies()` 검사로 빌드 시점에 규칙을 강제한다. 새 SUT를 추가할 때는 App-specific 계층에 플러그인 진입점 + 어댑터만 작성하고 Generic + HmEG-aware 전부 재사용.
|
||||||
|
|
||||||
## 모듈 구성
|
## 모듈 구성
|
||||||
|
|
||||||
|
### Generic tier — 임의 WPF 응용
|
||||||
|
|
||||||
| 모듈 | 책임 | 상태 |
|
| 모듈 | 책임 | 상태 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
|
| [Recordingtest.Bridge.Abstractions](src/Recordingtest.Bridge.Abstractions/) | `IEngineStateProvider`/`CameraSnapshot`/`SceneSnapshot` 등 SUT-중립 인터페이스 | ✓ |
|
||||||
| [Recordingtest.SutProber](src/Recordingtest.SutProber/) | SUT 정적 probe (plugin/Json/assembly 카탈로그) | PoC pass |
|
| [Recordingtest.SutProber](src/Recordingtest.SutProber/) | SUT 정적 probe (plugin/Json/assembly 카탈로그) | PoC pass |
|
||||||
| [Recordingtest.Recorder](src/Recordingtest.Recorder/) | 입력 캡처 (UIA element path + offset + 키/마우스/포커스) | PoC pass |
|
| [Recordingtest.Recorder](src/Recordingtest.Recorder/) | 입력 캡처 (UIA element path + offset + 키/마우스/포커스) | PoC pass |
|
||||||
| [Recordingtest.Player](src/Recordingtest.Player/) | 시나리오 재생, 비동기 동기화 | PoC pass |
|
| [Recordingtest.Player](src/Recordingtest.Player/) | 시나리오 재생, 비동기 동기화, null-target fallback | PoC pass |
|
||||||
| [Recordingtest.Normalizer](src/Recordingtest.Normalizer/) | 결과 파일 정규화 (timestamp/GUID/path/float/order) | PoC pass |
|
| [Recordingtest.Normalizer](src/Recordingtest.Normalizer/) | 결과 파일 정규화 (timestamp/GUID/path/float/order) | PoC pass |
|
||||||
| [Recordingtest.DiffReporter](src/Recordingtest.DiffReporter/) | approved vs received diff 리포트 | PoC pass |
|
| [Recordingtest.DiffReporter](src/Recordingtest.DiffReporter/) | approved vs received diff 리포트 | PoC pass |
|
||||||
| [Recordingtest.EngineBridge.Client](src/Recordingtest.EngineBridge.Client/) + [Recordingtest.EgPlugin](src/Recordingtest.EgPlugin/) | HmEG 내부 상태 sidecar (MEF plugin masquerade + HttpListener) | v2 pass |
|
|
||||||
| [Recordingtest.Runner](src/Recordingtest.Runner/) | 5-모듈 E2E 파이프라인 + 실패 triage | PoC pass |
|
| [Recordingtest.Runner](src/Recordingtest.Runner/) | 5-모듈 E2E 파이프라인 + 실패 triage | PoC pass |
|
||||||
|
|
||||||
|
### HmEG-aware tier — HmEG 호스팅 앱 공통
|
||||||
|
|
||||||
|
| 모듈 | 책임 | 상태 |
|
||||||
|
|------|------|------|
|
||||||
|
| [Recordingtest.Hmeg.Bridge](src/Hmeg/Recordingtest.Hmeg.Bridge/) | `HmegDirectStateProvider` — HmEG 공개 API(Space/HmEGViewport/ISelectable)로 상태 읽기. 앱에 람다로 진입점 주입 | v3 wired |
|
||||||
|
| [Recordingtest.Hmeg.Catalog](src/Hmeg/Recordingtest.Hmeg.Catalog/) | HmEG 정적 분석 카탈로그 | PoC pass |
|
||||||
|
| [Recordingtest.Hmeg.Bridge.Client](src/Hmeg/Recordingtest.Hmeg.Bridge.Client/) | `/scene` `/camera` `/selection` HTTP 클라이언트 | v2 pass |
|
||||||
|
|
||||||
|
### App-specific tier — 현재 SUT: EG-BIM Modeler
|
||||||
|
|
||||||
|
| 모듈 | 책임 | 상태 |
|
||||||
|
|------|------|------|
|
||||||
|
| [Recordingtest.Sut.EgBim.PluginHost](src/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost/) | MEF 진입점(`EditorPlugin` 상속), `BridgeHttpServer` 부팅, `HmegDirectStateProvider` 람다에 `RootSpace`/`View`/`AppManager.FileManager.CurrentFile` 주입 | v3 wired |
|
||||||
|
|
||||||
## 작업 사이클 — Planner / Generator / Evaluator
|
## 작업 사이클 — Planner / Generator / Evaluator
|
||||||
|
|
||||||
Anthropic harness design 원칙을 채택. 같은 에이전트가 생성과 평가를 겸하지 않는다.
|
Anthropic harness design 원칙을 채택. 같은 에이전트가 생성과 평가를 겸하지 않는다.
|
||||||
@@ -39,20 +66,52 @@ Anthropic harness design 원칙을 채택. 같은 에이전트가 생성과 평
|
|||||||
- **시나리오 포맷**: YAML/JSON (git diff 친화적)
|
- **시나리오 포맷**: YAML/JSON (git diff 친화적)
|
||||||
- **베이스라인**: `*.approved.{ext}` / `*.received.{ext}`
|
- **베이스라인**: `*.approved.{ext}` / `*.received.{ext}`
|
||||||
|
|
||||||
|
## 주요 이정표
|
||||||
|
|
||||||
|
- **2026-04-08 — 첫 E2E 성공** 🎉 Smoke 2회차에서 수동 테스트 시나리오 → 재생 → SUT에 Box geometry 생성 확인 (box-v5-clean.yaml).
|
||||||
|
- **2026-04-08 — Raw 시나리오 E2E 성공** 🎉 수동 cleanup 없이 recorder 원본 `box-v6.yaml`이 그대로 재생되어 Box 생성 (이슈 #14). Player에 null-target fallback, SUT foreground 능동 대기, 선두 alt+tab 노이즈 자동 skip, 스텝 간 타이밍 보존 추가.
|
||||||
|
- **2026-04-09 — 3-tier 분리 완료** — Generic / HmEG-aware / App-specific 물리적 분리, `Recordingtest.Architecture.Tests`가 의존 그래프 강제. 같은 날 engine-bridge v3 EgBim 람다 실 wire-up (코드 쪽 완료, 라이브 검증 대기).
|
||||||
|
- **126 단위 테스트** green (Recorder/Player/Normalizer/DiffReporter/Runner/Hmeg.* /Sut.EgBim.* /Architecture)
|
||||||
|
|
||||||
|
자세한 과정은 [docs/history/](docs/history/), 결정 근거는 [docs/contracts/](docs/contracts/) + [docs/hmeg-api-survey.md](docs/hmeg-api-survey.md) 참고.
|
||||||
|
|
||||||
|
## Gap 현황 (주요)
|
||||||
|
|
||||||
|
- **Gap A~H** (smoke 1~2회차) — 전부 fix (#11/#12/#13/#14)
|
||||||
|
- **Gap I (recorder root-cause)** — EG-BIM Modeler의 CommandBox 등 핵심 입력 컨트롤이 AutomationPeer를 노출하지 않아 UIA 외부에서 본질적으로 식별 불가. **deferred**. 현재는 Player null-target fallback(Type→OS 포커스 / Click→raw_coord)이 공식 우회 전략. 근본 해소는 generic WPF DLL injection 또는 SUT-side AutomationPeer 부착 PoC가 선결.
|
||||||
|
|
||||||
## 디렉터리
|
## 디렉터리
|
||||||
|
|
||||||
```
|
```
|
||||||
recordingtest/
|
recordingtest/
|
||||||
├── src/ # 모듈별 C# 프로젝트
|
├── src/
|
||||||
├── scenarios/ # 시나리오 YAML
|
│ ├── Recordingtest.Bridge.Abstractions/ # Generic — 인터페이스
|
||||||
|
│ ├── Recordingtest.Recorder/ # Generic
|
||||||
|
│ ├── Recordingtest.Player/ # Generic
|
||||||
|
│ ├── Recordingtest.Normalizer/ # Generic
|
||||||
|
│ ├── Recordingtest.DiffReporter/ # Generic
|
||||||
|
│ ├── Recordingtest.Runner/ # Generic
|
||||||
|
│ ├── Recordingtest.SutProber/ # Generic
|
||||||
|
│ ├── Hmeg/
|
||||||
|
│ │ ├── Recordingtest.Hmeg.Bridge/ # HmEG-aware
|
||||||
|
│ │ ├── Recordingtest.Hmeg.Catalog/ # HmEG-aware
|
||||||
|
│ │ ├── Recordingtest.Hmeg.Bridge.Client/ # HmEG-aware
|
||||||
|
│ │ └── Recordingtest.Hmeg.Catalog.Probe/ # HmEG-aware CLI
|
||||||
|
│ └── Sut/
|
||||||
|
│ └── EgBim/
|
||||||
|
│ └── Recordingtest.Sut.EgBim.PluginHost/ # App-specific
|
||||||
|
├── tests/ # 같은 계층 구조로 미러링 + Architecture.Tests
|
||||||
|
├── scenarios/ # 시나리오 YAML (box-v*.yaml)
|
||||||
├── docs/
|
├── docs/
|
||||||
│ ├── contracts/ # Sprint Contracts + evaluations
|
│ ├── contracts/ # Sprint Contracts + evaluations
|
||||||
│ ├── history/ # 작업 히스토리
|
│ ├── history/ # 작업 히스토리
|
||||||
│ ├── sut-catalog/ # sut-prober 산출물
|
│ ├── sut-catalog/ # sut-prober 산출물
|
||||||
│ └── guides/ # smoke test, deploy 가이드
|
│ ├── engine-catalog/ # HmEG 후보 카탈로그 (정적 분석)
|
||||||
├── CLAUDE.md # 에이전트 운영 지침
|
│ ├── hmeg-api-survey.md # HmEG public API 조사 메모
|
||||||
├── PROGRESS.md # 완료 상태
|
│ └── guides/ # smoke test, deploy 가이드
|
||||||
└── PLAN.md # 우선순위 큐
|
├── CLAUDE.md # 에이전트 운영 지침 + §8.1 3-tier 규칙
|
||||||
|
├── PROGRESS.md # 완료 상태 (세션 간 공유 메모리)
|
||||||
|
└── PLAN.md # 우선순위 큐
|
||||||
```
|
```
|
||||||
|
|
||||||
## 저장소
|
## 저장소
|
||||||
|
|||||||
45
docs/architecture.md
Normal file
45
docs/architecture.md
Normal 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 |
|
||||||
26
docs/contracts/camera-restore.evaluation.md
Normal file
26
docs/contracts/camera-restore.evaluation.md
Normal 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 167–201: `SetCamera` computes lookDir from target-eye, calls `WriteVec3`/`WriteDouble` via reflection. Dispatches via `_uiDispatch` when not null (lines 192–195). `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 46–56: 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 34–35 and 53–70: 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 94–115: 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 90–96: 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 287–308: `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.
|
||||||
68
docs/contracts/camera-restore.md
Normal file
68
docs/contracts/camera-restore.md
Normal 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. |
|
||||||
84
docs/contracts/engine-bridge-v3.md
Normal file
84
docs/contracts/engine-bridge-v3.md
Normal 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)에 시간 쏠릴 가능성.
|
||||||
140
docs/contracts/generic-sut-split.md
Normal file
140
docs/contracts/generic-sut-split.md
Normal 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회 → 자동 중단.
|
||||||
38
docs/contracts/normalizer-followups.evaluation.md
Normal file
38
docs/contracts/normalizer-followups.evaluation.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# normalizer-followups — Evaluation
|
||||||
|
|
||||||
|
**Verdict: PASS**
|
||||||
|
**Generator commit:** eeee3c2
|
||||||
|
**Evaluator date:** 2026-04-07
|
||||||
|
|
||||||
|
## Verdict table
|
||||||
|
|
||||||
|
| # | Criterion | Evidence | Result |
|
||||||
|
|---|-----------|----------|--------|
|
||||||
|
| 1 | `dotnet build recordingtest.sln` — 0 warn / 0 err | Build succeeded, 0 Warning(s), 0 Error(s) | pass |
|
||||||
|
| 2 | `dotnet test` total 77 pass | 16+17+16+5+5+6+6+6 = 77 passed, 0 failed | pass |
|
||||||
|
| A1 | `Profile.FloatDecimals` int? with YAML alias `float_decimals` | `Profile.cs:14-15` `[YamlMember(Alias="float_decimals")] public int? FloatDecimals` | pass |
|
||||||
|
| A2 | `RoundFloatsInNode` accepts decimals parameter | `Rules.cs:102` `RoundFloatsInNode(JsonNode?, int decimals)` + default overload using `DefaultFloatDecimals` | pass |
|
||||||
|
| A3 | `DefaultFloatDecimals = 6` | `Rules.cs:97` `public const int DefaultFloatDecimals = 6` | pass |
|
||||||
|
| A4 | Profile decimals flows via `Normalizer` | `Normalizer.cs:95` `profile.FloatDecimals ?? Rules.DefaultFloatDecimals` | pass |
|
||||||
|
| A5 | Omitted `float_decimals` defaults to 6 | Test `Profile_OmittedFloatDecimals_DefaultsTo6` asserts `profile.FloatDecimals == null` and output rounds to 3.141593 | pass |
|
||||||
|
| A6 | Configurable decimals actually applied | Test `RoundFloats_ProfileWithDecimals3_RoundsTo3` writes temp profile, expects 3.142 | pass |
|
||||||
|
| B1 | `ParseJsonPathLite` exists, rejects `*` and `[...]` | `Rules.cs:200-222` throws on wildcards/indexers, requires leading `$.` | pass |
|
||||||
|
| B2 | `MaskVolatileSettings(node, paths)` walks with path stack | `Rules.cs:227-289` pre-parses allowlist, maintains `stack` list, exact-chain compare in `PathMatches()` | pass |
|
||||||
|
| B3 | `DefaultVolatileSettingPaths` has 16 entries | `Rules.cs:176-194` — counted 16 paths | pass |
|
||||||
|
| B4 | `default.yaml` migrated to list form | `profiles/default.yaml:10-26` — YAML sequence of 16 `$.<path>` strings; `float_decimals: 6` present | pass |
|
||||||
|
| B5 | Regression trap: `SameNameInUnrelatedSubtree_NotMasked` | `RuleTests.cs:174-183` — input `{"GridSnap":true,"Foo":{"GridSnap":false}}` with `["$.GridSnap"]`; asserts root masked and `n["Foo"]["GridSnap"].GetValue<bool>() == false`. Pre-fix name-based fallback would have masked both, causing `GetValue<bool>()` to throw InvalidOperationException on `<VOLATILE>` string → test is load-bearing | pass |
|
||||||
|
| B6 | Nested path mask works | `MaskVolatileSettings_NestedPath_MasksCorrectly` — `$.GridColor.R` masks only R, leaves G | pass |
|
||||||
|
| B7 | Root mask works | `MaskVolatileSettings_RootField_Masks` | pass |
|
||||||
|
| B8 | No leftover `VolatileSettingFieldNames` fallback | Grep in `src/` — no matches anywhere | pass |
|
||||||
|
| 9 | CoverageTests still green | Normalizer.Tests dll 16 passed (includes coverage tests) | pass |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Regression trap load-bearing: confirmed. The old `VolatileSettingFieldNames.Contains(kv.Key)` approach would mask both `GridSnap` occurrences → nested `.GetValue<bool>()` on a `"<VOLATILE>"` JsonValue would throw. The test would fail loudly.
|
||||||
|
- Test count for Normalizer.Tests went from 10 → 16 as claimed (6 new tests present and accounted for).
|
||||||
|
- Default-on behavior preserved: `default.yaml` both specifies `float_decimals: 6` explicitly AND the omitted-profile test proves the `?? DefaultFloatDecimals` fallback path.
|
||||||
|
- Count of `DefaultVolatileSettingPaths`: 16 entries confirmed (CanOverrideWireColorWithFace, IsSidePanelVisible, OverrideFaceColor, Solar_IsLocalTime, VisibleGrid, GridSnap, MidpointOsnap, GridSpacing, GridColor.{ALPHA,BLUE,GREEN,RED}, MajorGridColor.{ALPHA,BLUE,GREEN,RED}).
|
||||||
|
|
||||||
|
## Partial / gaps
|
||||||
|
|
||||||
|
None. Both follow-ups are complete with no residual fallback code.
|
||||||
39
docs/contracts/smoke3-gap-fix.evaluation.md
Normal file
39
docs/contracts/smoke3-gap-fix.evaluation.md
Normal 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".
|
||||||
25
docs/history/2026-04-07_normalizer-followups-evaluator.md
Normal file
25
docs/history/2026-04-07_normalizer-followups-evaluator.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# 2026-04-07 normalizer-followups — Evaluator
|
||||||
|
|
||||||
|
- **작업**: Evaluator 채점 — Follow-up A (float epsilon 구성화) + Follow-up B (JSON-path mask scoping)
|
||||||
|
- **Generator commit**: eeee3c2
|
||||||
|
- **Verdict**: PASS
|
||||||
|
- **관련 이슈**: normalizer follow-ups (see PLAN.md)
|
||||||
|
- **소요 시간**: ~6분
|
||||||
|
- **Context 사용량**: ~35k 토큰 (single session, no compaction)
|
||||||
|
|
||||||
|
## 검증 결과
|
||||||
|
|
||||||
|
- `dotnet build recordingtest.sln`: 0 warn / 0 err
|
||||||
|
- `dotnet test recordingtest.sln --no-build`: 77 passed / 0 failed (Player 16, Recorder 17, Normalizer 16, EgPlugin 5, DiffReporter 5, Runner 6, EngineBridge.Integration 6, EngineBridge 6)
|
||||||
|
- Normalizer.Tests 10 → 16 확인 (6 신규)
|
||||||
|
- `VolatileSettingFieldNames` 잔존 없음 (grep src/ empty)
|
||||||
|
- Regression trap `SameNameInUnrelatedSubtree_NotMasked` load-bearing 확인 — 구버전 name-based 매칭이면 nested bool GetValue에서 throw
|
||||||
|
|
||||||
|
## 산출물
|
||||||
|
|
||||||
|
- `docs/contracts/normalizer-followups.evaluation.md` (verdict table)
|
||||||
|
- 본 히스토리 파일
|
||||||
|
|
||||||
|
## 참고
|
||||||
|
|
||||||
|
PROGRESS.md 갱신은 호출자가 수행 (evaluator는 touch 금지). Generator 코드 수정 없음.
|
||||||
28
docs/history/2026-04-07_normalizer-followups-generator.md
Normal file
28
docs/history/2026-04-07_normalizer-followups-generator.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# 2026-04-07 — normalizer follow-ups (Generator)
|
||||||
|
|
||||||
|
## 작업
|
||||||
|
normalizer PoC v2(#4, `05c7a3f`)에서 Evaluator가 비차단 risk로 남긴 두 항목을 구현.
|
||||||
|
|
||||||
|
- **Follow-up A** Float epsilon 구성화: `NormalizeProfile.float_decimals` (YAML, optional, default 6) → `Rules.RoundFloatsInNode(node, decimals)` 오버로드 → `Normalizer.Normalize`가 프로파일에서 읽어 주입.
|
||||||
|
- **Follow-up B** `mask_volatile_settings` JSON-path 스코핑: 필드명 HashSet → JSONPath-lite 화이트리스트(`$.a.b.c`). `Rules.ParseJsonPathLite`로 세그먼트 파싱, 정확 경로 매칭. 같은 이름의 무관한 서브트리 보호.
|
||||||
|
|
||||||
|
## 변경 파일
|
||||||
|
- `src/Recordingtest.Normalizer/Profile.cs` — `FloatDecimals`, `MaskVolatileSettings` 필드 추가. `IgnoreUnmatchedProperties()`.
|
||||||
|
- `src/Recordingtest.Normalizer/Rules.cs` — `RoundFloatsInNode(node, decimals)`, `MaskVolatileSettings(node, IReadOnlyList<string>)`, `ParseJsonPathLite`, `DefaultVolatileSettingPaths`.
|
||||||
|
- `src/Recordingtest.Normalizer/Normalizer.cs` — `round_floats`/`mask_volatile_settings` 케이스에서 프로파일 옵션 전달.
|
||||||
|
- `src/Recordingtest.Normalizer/profiles/default.yaml` — `float_decimals: 6` + 16개 `$.<path>` 항목.
|
||||||
|
- `tests/Recordingtest.Normalizer.Tests/RuleTests.cs` — 테스트 6개 추가.
|
||||||
|
|
||||||
|
## 결과
|
||||||
|
- Build: 0 warnings, 0 errors (TreatWarningsAsErrors).
|
||||||
|
- Normalizer tests: 10 → 16 (+6 신규, 모두 green).
|
||||||
|
- 솔루션 전체: 77 passed / 0 failed.
|
||||||
|
|
||||||
|
## Regression trap (Follow-up B)
|
||||||
|
`MaskVolatileSettings_SameNameInUnrelatedSubtree_NotMasked`는 `{GridSnap, Foo:{GridSnap}}` 입력에 `$.GridSnap` 화이트리스트를 적용. 수정 전 코드는 이름 기반 HashSet으로 `Foo.GridSnap`까지 마스킹했을 것이고 테스트가 실패했을 것이다. 신규 path 매칭은 stack 깊이/세그먼트가 정확히 일치할 때만 마스킹하므로 root 만 변경되고 nested boolean은 보존됨.
|
||||||
|
|
||||||
|
## 메타
|
||||||
|
- 소요 시간: 약 25분
|
||||||
|
- Context 사용량: 약 47k tokens (단일 세션)
|
||||||
|
- 관련 이슈: #2 (normalizer follow-ups), #4 후속
|
||||||
|
- 마커: non-issue / follow-up only — Sprint Contract DoD 변경 없음, PROGRESS/PLAN은 Evaluator/handoff에서 갱신.
|
||||||
69
docs/history/2026-04-07_scaffolding-review-1회차.md
Normal file
69
docs/history/2026-04-07_scaffolding-review-1회차.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# 2026-04-07 Scaffolding Review 1회차
|
||||||
|
|
||||||
|
- **작업**: `.claude/` 비계 주기 감사 (harness design 원칙)
|
||||||
|
- **소요 시간**: ~10분
|
||||||
|
- **Context 사용량**: ~420k tokens (orchestrator 누적)
|
||||||
|
|
||||||
|
## 범위
|
||||||
|
|
||||||
|
PoC 8개 완료 + smoke 2차 fix 완료 시점에서 `.claude/` 하위 agents/commands/skills/hooks 인벤토리 감사.
|
||||||
|
|
||||||
|
## 인벤토리
|
||||||
|
|
||||||
|
### Agents (5) — 모두 유효
|
||||||
|
| Agent | 사용 이력 | 평가 |
|
||||||
|
|-------|----------|------|
|
||||||
|
| planner | 간접 사용 (orchestrator가 contract 직접 작성) | 유지 — `/contract` 커맨드 경유 가치 있음 |
|
||||||
|
| evaluator | 10+ Evaluator 사이클에서 사용 | 핵심 |
|
||||||
|
| sut-explorer | 미사용 (sut-prober 코드로 대체) | **검토 대상**: 실행 이력 없음. 향후 dynamic 분석 필요 시 부활 |
|
||||||
|
| diff-triager | 미사용 (아직 실제 실패 triage 미발생) | 유지 — 첫 회귀 실패 시 필요 |
|
||||||
|
| scenario-author | 미사용 | 유지 — 향후 자연어 → yaml 변환 시 필요 |
|
||||||
|
|
||||||
|
### Commands (7) — 대부분 미사용
|
||||||
|
| Command | 사용 이력 | 평가 |
|
||||||
|
|---------|----------|------|
|
||||||
|
| contract | 미사용 (orchestrator 직접 작성) | **검토**: 실질 가치 재검토 |
|
||||||
|
| evaluate | 미사용 (Agent tool 직접 호출로 대체) | **검토**: 실질 가치 재검토 |
|
||||||
|
| sut-probe | 미사용 (sut-prober exe 직접 실행) | **검토** |
|
||||||
|
| regress | 미사용 (test-runner 미배포) | 유지 — 러너 배포 후 사용 예정 |
|
||||||
|
| approve | 미사용 | 유지 — baseline 승격 워크플로에 필요 |
|
||||||
|
| handoff | 미사용 (직접 PROGRESS 편집) | **검토** |
|
||||||
|
| progress | 미사용 (orchestrator가 직접 read) | **검토** |
|
||||||
|
|
||||||
|
**관찰**: 커맨드는 명시적 슬래시 호출이 필요한데 orchestrator 세션에서는 일반 도구 호출이 더 빠름. 커맨드는 **사용자 직접 호출 용도**로 한정 가치.
|
||||||
|
|
||||||
|
### Skills (3) — 적절
|
||||||
|
| Skill | 평가 |
|
||||||
|
|-------|------|
|
||||||
|
| flaui-cookbook | 유지 — recorder/player 작업 시 참조 |
|
||||||
|
| golden-file-normalizer | 유지 — normalizer 규칙 저작 시 참조 |
|
||||||
|
| aptabase | 별도 플러그인 (외부 서비스), 유지 |
|
||||||
|
|
||||||
|
### Hooks (7 active) — 핵심
|
||||||
|
| Hook | 사용 이력 | 평가 |
|
||||||
|
|------|----------|------|
|
||||||
|
| session-start-progress.sh | 세션마다 PROGRESS/PLAN 주입 | 핵심 |
|
||||||
|
| stop-handoff-reminder.sh | 세션 종료 시 경고 | 유지 |
|
||||||
|
| guard-sut-folder.sh | SUT 폴더 보호 | 핵심 |
|
||||||
|
| guard-sut-launch.sh | SUT 실행 경고 | 유지 |
|
||||||
|
| aptabase-* | 외부 텔레메트리 | 별도 관리 |
|
||||||
|
| install-git-hook.sh | 설치 스크립트 | 유지 |
|
||||||
|
|
||||||
|
## 권고 (실행 보류, 검토만)
|
||||||
|
|
||||||
|
1. **미사용 커맨드 4개** (`contract`, `evaluate`, `handoff`, `progress`): orchestrator 세션 관점에선 redundant. 단 **사용자가 직접 `/handoff` 같은 걸 치는 시나리오**는 유효. 삭제 대신 문서화로 보완 권장.
|
||||||
|
2. **`sut-explorer` 에이전트**: 미사용. 정적 분석은 `sut-prober` 코드가 담당. 제거 후보.
|
||||||
|
3. **`diff-triager` 에이전트**: 아직 트리거된 적 없지만 첫 실 regression 실패 시 필요 → 유지.
|
||||||
|
4. **`scenario-author` 에이전트**: 향후 자연어 시나리오 변환 시 가치. 유지.
|
||||||
|
|
||||||
|
## 이번 감사에서 정리한 것
|
||||||
|
- 없음 (감사만, 삭제 보류)
|
||||||
|
|
||||||
|
## 다음 감사 권장 시점
|
||||||
|
- engine-bridge v3 완료 후
|
||||||
|
- smoke 2회차 pass 후
|
||||||
|
- 또는 PoC 모듈이 3개 더 추가됐을 때
|
||||||
|
|
||||||
|
## 추가 follow-up
|
||||||
|
|
||||||
|
`.claude/settings.json`의 hooks 섹션에 `aptabase-*` 관련 등록 상태와 현재 운영 일치 여부는 aptabase skill 담당 범위라 감사 제외.
|
||||||
27
docs/history/2026-04-07_sut-prober-snake-case.md
Normal file
27
docs/history/2026-04-07_sut-prober-snake-case.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# 2026-04-07 sut-prober snake_case JSON naming
|
||||||
|
|
||||||
|
- **작업**: follow-up — contract 엄격 준수
|
||||||
|
- **소요 시간**: ~5분
|
||||||
|
- **Context 사용량**: ~420k (orchestrator 누적)
|
||||||
|
|
||||||
|
## 변경
|
||||||
|
|
||||||
|
`src/Recordingtest.SutProber/Program.cs`의 `JsonSerializerOptions`에 `PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower` 추가.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- `dotnet build src/Recordingtest.SutProber`: green (0/0)
|
||||||
|
- `dotnet run`: exit 0, 카탈로그 재생성
|
||||||
|
- 출력 확인: `"name"`, `"path"`, `"dlls"`, `"size_bytes"` (이전 `"SizeBytes"` 에서 변경)
|
||||||
|
- `size_bytes`, `has_pdb` 스네이크 케이스 확인됨
|
||||||
|
|
||||||
|
## 영향
|
||||||
|
|
||||||
|
- sut-prober contract DoD 엄격 준수 (이전 Evaluator "or equivalent" 완화 조항 제거)
|
||||||
|
- `docs/sut-catalog/*.json` 재생성 필요 (이번 실행에서 덮어씀)
|
||||||
|
- 다른 모듈은 sut-catalog JSON을 **필드 이름으로 참조** 하는 곳이 있다면 영향 — normalizer의 coverage test (`CoverageTests.cs`)가 `suspectedNondeterministicFields` 또는 `suspected_nondeterministic_fields` 중 어느 쪽을 파싱하는지 확인 필요
|
||||||
|
|
||||||
|
## Follow-up
|
||||||
|
|
||||||
|
- normalizer Coverage test에 영향이 있는지 다음 dotnet test run에서 검증 필요
|
||||||
|
- PROGRESS.md Follow-ups 섹션에서 이 항목 제거 예정 (orchestrator 마무리 시)
|
||||||
44
docs/history/2026-04-08_gap-i1-deferred.md
Normal file
44
docs/history/2026-04-08_gap-i1-deferred.md
Normal 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`) 그대로 재사용 가능.
|
||||||
20
docs/history/2026-04-08_gitea-mcp-access-qa.md
Normal file
20
docs/history/2026-04-08_gitea-mcp-access-qa.md
Normal 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__*` 누락 → 매번 허용 프롬프트
|
||||||
65
docs/history/2026-04-08_smoke-2회차-첫-e2e-성공.md
Normal file
65
docs/history/2026-04-08_smoke-2회차-첫-e2e-성공.md
Normal 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.
|
||||||
22
docs/history/2026-04-08_이슈13-smoke3-fix-evaluator.md
Normal file
22
docs/history/2026-04-08_이슈13-smoke3-fix-evaluator.md
Normal 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` (본 문서)
|
||||||
69
docs/history/2026-04-08_이슈13-smoke3-fix-generator.md
Normal file
69
docs/history/2026-04-08_이슈13-smoke3-fix-generator.md
Normal 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가 이어받아 마무리 가능.
|
||||||
43
docs/history/2026-04-08_이슈13-smoke3-orchestration.md
Normal file
43
docs/history/2026-04-08_이슈13-smoke3-orchestration.md
Normal 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가 실제로 동작하는지 검증.
|
||||||
70
docs/history/2026-04-08_이슈14-raw-시나리오-e2e-성공.md
Normal file
70
docs/history/2026-04-08_이슈14-raw-시나리오-e2e-성공.md
Normal 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
|
||||||
108
docs/history/2026-04-09_3tier-split-step1.md
Normal file
108
docs/history/2026-04-09_3tier-split-step1.md
Normal 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`
|
||||||
138
docs/history/2026-04-09_3tier-split-step2-and-v3-wireup.md
Normal file
138
docs/history/2026-04-09_3tier-split-step2-and-v3-wireup.md
Normal 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)
|
||||||
110
docs/history/2026-04-09_engine-bridge-v3-live-success.md
Normal file
110
docs/history/2026-04-09_engine-bridge-v3-live-success.md
Normal 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 완성)
|
||||||
70
docs/history/2026-04-09_engine-bridge-v3-scaffold.md
Normal file
70
docs/history/2026-04-09_engine-bridge-v3-scaffold.md
Normal 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` (신규)
|
||||||
24
docs/history/2026-04-09_readme-refresh-and-push.md
Normal file
24
docs/history/2026-04-09_readme-refresh-and-push.md
Normal 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`
|
||||||
117
docs/history/2026-04-09_runner-sidecar-integration.md
Normal file
117
docs/history/2026-04-09_runner-sidecar-integration.md
Normal 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 신규 테스트)
|
||||||
47
docs/history/2026-04-10_camera-restore.md
Normal file
47
docs/history/2026-04-10_camera-restore.md
Normal 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
205
docs/hmeg-api-survey.md
Normal 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 소스 사본을 보관하지 **않는다**. 본 문서는 외부 소스에 대한 인터페이스 추출 메모일 뿐. 코드 발췌도 시그니처/주석 수준으로만 인용한다.
|
||||||
297
docs/research/webgpu-porting-feasibility.md
Normal file
297
docs/research/webgpu-porting-feasibility.md
Normal 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 용도**: 충분히 현실적, 건축 모델 수백만 폴리곤 실시간 가시화 가능
|
||||||
@@ -1,87 +1,87 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"Name": "Editor.AI01.HttpConnector.dll",
|
"name": "Editor.AI01.HttpConnector.dll",
|
||||||
"SizeBytes": 15872,
|
"size_bytes": 15872,
|
||||||
"HasPdb": true
|
"has_pdb": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "Editor01.Localization.dll",
|
"name": "Editor01.Localization.dll",
|
||||||
"SizeBytes": 357888,
|
"size_bytes": 357888,
|
||||||
"HasPdb": true
|
"has_pdb": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "Editor02.HmEGAppManager.dll",
|
"name": "Editor02.HmEGAppManager.dll",
|
||||||
"SizeBytes": 529408,
|
"size_bytes": 529408,
|
||||||
"HasPdb": true
|
"has_pdb": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "Editor03.PluginInterface.dll",
|
"name": "Editor03.PluginInterface.dll",
|
||||||
"SizeBytes": 347136,
|
"size_bytes": 347136,
|
||||||
"HasPdb": true
|
"has_pdb": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "Editor04.CommandControl.dll",
|
"name": "Editor04.CommandControl.dll",
|
||||||
"SizeBytes": 60416,
|
"size_bytes": 60416,
|
||||||
"HasPdb": true
|
"has_pdb": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "Editor05.CommandCore.dll",
|
"name": "Editor05.CommandCore.dll",
|
||||||
"SizeBytes": 99840,
|
"size_bytes": 99840,
|
||||||
"HasPdb": true
|
"has_pdb": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "Editor06.CommandCustom.dll",
|
"name": "Editor06.CommandCustom.dll",
|
||||||
"SizeBytes": 29184,
|
"size_bytes": 29184,
|
||||||
"HasPdb": true
|
"has_pdb": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "Editor07.WidgetPluginInterface.dll",
|
"name": "Editor07.WidgetPluginInterface.dll",
|
||||||
"SizeBytes": 8704,
|
"size_bytes": 8704,
|
||||||
"HasPdb": true
|
"has_pdb": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "EditorCore.dll",
|
"name": "EditorCore.dll",
|
||||||
"SizeBytes": 57636352,
|
"size_bytes": 57636352,
|
||||||
"HasPdb": true
|
"has_pdb": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "HmCommonBridge.dll",
|
"name": "HmCommonBridge.dll",
|
||||||
"SizeBytes": 72192,
|
"size_bytes": 72192,
|
||||||
"HasPdb": false
|
"has_pdb": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "HmCommonUI.dll",
|
"name": "HmCommonUI.dll",
|
||||||
"SizeBytes": 1710592,
|
"size_bytes": 1710592,
|
||||||
"HasPdb": false
|
"has_pdb": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "HmEG.dll",
|
"name": "HmEG.dll",
|
||||||
"SizeBytes": 242715136,
|
"size_bytes": 242715136,
|
||||||
"HasPdb": true
|
"has_pdb": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "HmEG3DMouse.dll",
|
"name": "HmEG3DMouse.dll",
|
||||||
"SizeBytes": 40448,
|
"size_bytes": 40448,
|
||||||
"HasPdb": false
|
"has_pdb": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "HmGeometry.V2.dll",
|
"name": "HmGeometry.V2.dll",
|
||||||
"SizeBytes": 2985472,
|
"size_bytes": 2985472,
|
||||||
"HasPdb": true
|
"has_pdb": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "HmGeometry.dll",
|
"name": "HmGeometry.dll",
|
||||||
"SizeBytes": 1863168,
|
"size_bytes": 1863168,
|
||||||
"HasPdb": true
|
"has_pdb": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "HmPG.dll",
|
"name": "HmPG.dll",
|
||||||
"SizeBytes": 34816,
|
"size_bytes": 34816,
|
||||||
"HasPdb": false
|
"has_pdb": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "HmTriangle.dll",
|
"name": "HmTriangle.dll",
|
||||||
"SizeBytes": 195584,
|
"size_bytes": 195584,
|
||||||
"HasPdb": true
|
"has_pdb": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"Name": "CategoryCommands.json",
|
"name": "CategoryCommands.json",
|
||||||
"TopLevelKeys": [
|
"top_level_keys": [
|
||||||
"CategoryCommands[0]",
|
"CategoryCommands[0]",
|
||||||
"CategoryCommands[10]",
|
"CategoryCommands[10]",
|
||||||
"CategoryCommands[11]",
|
"CategoryCommands[11]",
|
||||||
@@ -75,11 +75,11 @@
|
|||||||
"ViewerCategoryCommands[7]",
|
"ViewerCategoryCommands[7]",
|
||||||
"ViewerCategoryCommands[8]"
|
"ViewerCategoryCommands[8]"
|
||||||
],
|
],
|
||||||
"SuspectedNondeterministicFields": []
|
"suspected_nondeterministic_fields": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "CommandAlias.json",
|
"name": "CommandAlias.json",
|
||||||
"TopLevelKeys": [
|
"top_level_keys": [
|
||||||
"3F",
|
"3F",
|
||||||
"A",
|
"A",
|
||||||
"C",
|
"C",
|
||||||
@@ -101,11 +101,11 @@
|
|||||||
"UNITE",
|
"UNITE",
|
||||||
"Z"
|
"Z"
|
||||||
],
|
],
|
||||||
"SuspectedNondeterministicFields": []
|
"suspected_nondeterministic_fields": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "DefaultCategoryCommands.json",
|
"name": "DefaultCategoryCommands.json",
|
||||||
"TopLevelKeys": [
|
"top_level_keys": [
|
||||||
"CategoryCommands[0]",
|
"CategoryCommands[0]",
|
||||||
"CategoryCommands[10]",
|
"CategoryCommands[10]",
|
||||||
"CategoryCommands[11]",
|
"CategoryCommands[11]",
|
||||||
@@ -179,11 +179,11 @@
|
|||||||
"ViewerCategoryCommands[7]",
|
"ViewerCategoryCommands[7]",
|
||||||
"ViewerCategoryCommands[8]"
|
"ViewerCategoryCommands[8]"
|
||||||
],
|
],
|
||||||
"SuspectedNondeterministicFields": []
|
"suspected_nondeterministic_fields": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "DefaultCommandAlias.json",
|
"name": "DefaultCommandAlias.json",
|
||||||
"TopLevelKeys": [
|
"top_level_keys": [
|
||||||
"3F",
|
"3F",
|
||||||
"A",
|
"A",
|
||||||
"C",
|
"C",
|
||||||
@@ -205,11 +205,11 @@
|
|||||||
"UNITE",
|
"UNITE",
|
||||||
"Z"
|
"Z"
|
||||||
],
|
],
|
||||||
"SuspectedNondeterministicFields": []
|
"suspected_nondeterministic_fields": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "DefaultKeyShortCut.json",
|
"name": "DefaultKeyShortCut.json",
|
||||||
"TopLevelKeys": [
|
"top_level_keys": [
|
||||||
"Ctrl_0",
|
"Ctrl_0",
|
||||||
"Ctrl_1",
|
"Ctrl_1",
|
||||||
"Ctrl_2",
|
"Ctrl_2",
|
||||||
@@ -374,11 +374,11 @@
|
|||||||
"PageUp",
|
"PageUp",
|
||||||
"Tab"
|
"Tab"
|
||||||
],
|
],
|
||||||
"SuspectedNondeterministicFields": []
|
"suspected_nondeterministic_fields": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "DefaultMouseSnap.json",
|
"name": "DefaultMouseSnap.json",
|
||||||
"TopLevelKeys": [
|
"top_level_keys": [
|
||||||
"AdditionalAngleStr",
|
"AdditionalAngleStr",
|
||||||
"ApplyIncrementAngle",
|
"ApplyIncrementAngle",
|
||||||
"CenterOsnap",
|
"CenterOsnap",
|
||||||
@@ -400,14 +400,14 @@
|
|||||||
"TangentOsnap",
|
"TangentOsnap",
|
||||||
"VertexOsnap"
|
"VertexOsnap"
|
||||||
],
|
],
|
||||||
"SuspectedNondeterministicFields": [
|
"suspected_nondeterministic_fields": [
|
||||||
"GridSnap",
|
"GridSnap",
|
||||||
"MidpointOsnap"
|
"MidpointOsnap"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "DefaultSettings.json",
|
"name": "DefaultSettings.json",
|
||||||
"TopLevelKeys": [
|
"top_level_keys": [
|
||||||
"AmbientLightColor.ALPHA",
|
"AmbientLightColor.ALPHA",
|
||||||
"AmbientLightColor.BLUE",
|
"AmbientLightColor.BLUE",
|
||||||
"AmbientLightColor.GREEN",
|
"AmbientLightColor.GREEN",
|
||||||
@@ -549,7 +549,7 @@
|
|||||||
"WireframeColor.RED",
|
"WireframeColor.RED",
|
||||||
"ZoomSensitivity"
|
"ZoomSensitivity"
|
||||||
],
|
],
|
||||||
"SuspectedNondeterministicFields": [
|
"suspected_nondeterministic_fields": [
|
||||||
"AutoSaveFilePath",
|
"AutoSaveFilePath",
|
||||||
"AutoSave_RecentFileName",
|
"AutoSave_RecentFileName",
|
||||||
"CanOverrideWireColorWithFace",
|
"CanOverrideWireColorWithFace",
|
||||||
@@ -569,8 +569,8 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "DefaultStartupCommand.json",
|
"name": "DefaultStartupCommand.json",
|
||||||
"TopLevelKeys": [
|
"top_level_keys": [
|
||||||
"NeverRepeatCommands[0]",
|
"NeverRepeatCommands[0]",
|
||||||
"NeverRepeatCommands[10]",
|
"NeverRepeatCommands[10]",
|
||||||
"NeverRepeatCommands[11]",
|
"NeverRepeatCommands[11]",
|
||||||
@@ -585,11 +585,11 @@
|
|||||||
"NeverRepeatCommands[8]",
|
"NeverRepeatCommands[8]",
|
||||||
"NeverRepeatCommands[9]"
|
"NeverRepeatCommands[9]"
|
||||||
],
|
],
|
||||||
"SuspectedNondeterministicFields": []
|
"suspected_nondeterministic_fields": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "DefaultUnits.json",
|
"name": "DefaultUnits.json",
|
||||||
"TopLevelKeys": [
|
"top_level_keys": [
|
||||||
"BaseUnit",
|
"BaseUnit",
|
||||||
"CurrentUnit",
|
"CurrentUnit",
|
||||||
"Denominator",
|
"Denominator",
|
||||||
@@ -597,11 +597,11 @@
|
|||||||
"ImperialDenominator",
|
"ImperialDenominator",
|
||||||
"SignificantDigit"
|
"SignificantDigit"
|
||||||
],
|
],
|
||||||
"SuspectedNondeterministicFields": []
|
"suspected_nondeterministic_fields": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "KeyShortCut.json",
|
"name": "KeyShortCut.json",
|
||||||
"TopLevelKeys": [
|
"top_level_keys": [
|
||||||
"Ctrl_0",
|
"Ctrl_0",
|
||||||
"Ctrl_1",
|
"Ctrl_1",
|
||||||
"Ctrl_2",
|
"Ctrl_2",
|
||||||
@@ -766,16 +766,16 @@
|
|||||||
"PageUp",
|
"PageUp",
|
||||||
"Tab"
|
"Tab"
|
||||||
],
|
],
|
||||||
"SuspectedNondeterministicFields": []
|
"suspected_nondeterministic_fields": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "Materials.json",
|
"name": "Materials.json",
|
||||||
"TopLevelKeys": [],
|
"top_level_keys": [],
|
||||||
"SuspectedNondeterministicFields": []
|
"suspected_nondeterministic_fields": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "MouseSnap.json",
|
"name": "MouseSnap.json",
|
||||||
"TopLevelKeys": [
|
"top_level_keys": [
|
||||||
"AdditionalAngleStr",
|
"AdditionalAngleStr",
|
||||||
"ApplyIncrementAngle",
|
"ApplyIncrementAngle",
|
||||||
"CenterOsnap",
|
"CenterOsnap",
|
||||||
@@ -797,19 +797,19 @@
|
|||||||
"TangentOsnap",
|
"TangentOsnap",
|
||||||
"VertexOsnap"
|
"VertexOsnap"
|
||||||
],
|
],
|
||||||
"SuspectedNondeterministicFields": [
|
"suspected_nondeterministic_fields": [
|
||||||
"GridSnap",
|
"GridSnap",
|
||||||
"MidpointOsnap"
|
"MidpointOsnap"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "RecentFiles.json",
|
"name": "RecentFiles.json",
|
||||||
"TopLevelKeys": [],
|
"top_level_keys": [],
|
||||||
"SuspectedNondeterministicFields": []
|
"suspected_nondeterministic_fields": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "Settings.json",
|
"name": "Settings.json",
|
||||||
"TopLevelKeys": [
|
"top_level_keys": [
|
||||||
"AmbientLightColor.ALPHA",
|
"AmbientLightColor.ALPHA",
|
||||||
"AmbientLightColor.BLUE",
|
"AmbientLightColor.BLUE",
|
||||||
"AmbientLightColor.GREEN",
|
"AmbientLightColor.GREEN",
|
||||||
@@ -951,7 +951,7 @@
|
|||||||
"WireframeColor.RED",
|
"WireframeColor.RED",
|
||||||
"ZoomSensitivity"
|
"ZoomSensitivity"
|
||||||
],
|
],
|
||||||
"SuspectedNondeterministicFields": [
|
"suspected_nondeterministic_fields": [
|
||||||
"AutoSaveFilePath",
|
"AutoSaveFilePath",
|
||||||
"AutoSave_RecentFileName",
|
"AutoSave_RecentFileName",
|
||||||
"CanOverrideWireColorWithFace",
|
"CanOverrideWireColorWithFace",
|
||||||
@@ -971,8 +971,8 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "StartupCommand.json",
|
"name": "StartupCommand.json",
|
||||||
"TopLevelKeys": [
|
"top_level_keys": [
|
||||||
"NeverRepeatCommands[0]",
|
"NeverRepeatCommands[0]",
|
||||||
"NeverRepeatCommands[10]",
|
"NeverRepeatCommands[10]",
|
||||||
"NeverRepeatCommands[11]",
|
"NeverRepeatCommands[11]",
|
||||||
@@ -987,11 +987,11 @@
|
|||||||
"NeverRepeatCommands[8]",
|
"NeverRepeatCommands[8]",
|
||||||
"NeverRepeatCommands[9]"
|
"NeverRepeatCommands[9]"
|
||||||
],
|
],
|
||||||
"SuspectedNondeterministicFields": []
|
"suspected_nondeterministic_fields": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "Units.json",
|
"name": "Units.json",
|
||||||
"TopLevelKeys": [
|
"top_level_keys": [
|
||||||
"BaseUnit",
|
"BaseUnit",
|
||||||
"CurrentUnit",
|
"CurrentUnit",
|
||||||
"Denominator",
|
"Denominator",
|
||||||
@@ -999,6 +999,6 @@
|
|||||||
"ImperialDenominator",
|
"ImperialDenominator",
|
||||||
"SignificantDigit"
|
"SignificantDigit"
|
||||||
],
|
],
|
||||||
"SuspectedNondeterministicFields": []
|
"suspected_nondeterministic_fields": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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"
|
|
||||||
@@ -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
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"_comment": "각 프로젝트에서 이 파일을 복사해 history_path 만 재정의하세요.",
|
|
||||||
"history_path": "docs/history"
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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 사용량** (누락 시 저장 차단됨)"
|
|
||||||
@@ -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
|
|
||||||
@@ -27,19 +27,37 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Runner", "src
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Runner.Tests", "tests\Recordingtest.Runner.Tests\Recordingtest.Runner.Tests.csproj", "{6F9973EA-977A-4185-AF24-4E76D9D851C8}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Runner.Tests", "tests\Recordingtest.Runner.Tests\Recordingtest.Runner.Tests.csproj", "{6F9973EA-977A-4185-AF24-4E76D9D851C8}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EngineBridge", "src\Recordingtest.EngineBridge\Recordingtest.EngineBridge.csproj", "{938D464B-B810-425F-83B6-52877B584DE2}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Bridge.Abstractions", "src\Recordingtest.Bridge.Abstractions\Recordingtest.Bridge.Abstractions.csproj", "{E9192225-E9F6-44EB-A18E-7F61F1093DA8}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EngineBridge.Probe", "src\Recordingtest.EngineBridge.Probe\Recordingtest.EngineBridge.Probe.csproj", "{B1EAD466-9C07-4C07-907C-3D5794F6689D}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Hmeg.Bridge", "src\Hmeg\Recordingtest.Hmeg.Bridge\Recordingtest.Hmeg.Bridge.csproj", "{33D35B3C-9572-432F-8675-6AD7CDF1C0EB}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EngineBridge.Tests", "tests\Recordingtest.EngineBridge.Tests\Recordingtest.EngineBridge.Tests.csproj", "{0811AC32-E2A4-4BFD-A29A-6451F5756F10}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EgPlugin", "src\Recordingtest.EgPlugin\Recordingtest.EgPlugin.csproj", "{51D7B803-5F6E-4B78-9A5D-326F28CD934F}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Hmeg", "Hmeg", "{FA0FB21B-DC6D-6187-86C3-94DFEB22505D}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EngineBridge.Client", "src\Recordingtest.EngineBridge.Client\Recordingtest.EngineBridge.Client.csproj", "{45D80D0C-A8A1-4173-B28C-68F0628EE346}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Hmeg.Bridge.Tests", "tests\Hmeg\Recordingtest.Hmeg.Bridge.Tests\Recordingtest.Hmeg.Bridge.Tests.csproj", "{20FB4AD7-3414-436D-880C-B2D95280DA3D}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EngineBridge.IntegrationTests", "tests\Recordingtest.EngineBridge.IntegrationTests\Recordingtest.EngineBridge.IntegrationTests.csproj", "{BA346F72-6F9C-4D68-9CDD-DD05F9687095}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Sut", "Sut", "{79DA188A-9C91-3DBA-2827-6072BD5E3D4F}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EgPlugin.Tests", "tests\Recordingtest.EgPlugin.Tests\Recordingtest.EgPlugin.Tests.csproj", "{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EgBim", "EgBim", "{7CC28442-33DD-D811-CEDA-9CC787317768}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Sut.EgBim.PluginHost", "src\Sut\EgBim\Recordingtest.Sut.EgBim.PluginHost\Recordingtest.Sut.EgBim.PluginHost.csproj", "{0A800F25-64B6-4F05-BB8E-68E317862CED}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Hmeg.Catalog", "src\Hmeg\Recordingtest.Hmeg.Catalog\Recordingtest.Hmeg.Catalog.csproj", "{23D628DC-D98D-427A-B0C0-470E70CC6DD2}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Hmeg.Bridge.Client", "src\Hmeg\Recordingtest.Hmeg.Bridge.Client\Recordingtest.Hmeg.Bridge.Client.csproj", "{4E0274C5-39C2-436E-90AA-87DD1C675B4C}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Hmeg.Catalog.Probe", "src\Hmeg\Recordingtest.Hmeg.Catalog.Probe\Recordingtest.Hmeg.Catalog.Probe.csproj", "{A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Sut.EgBim.PluginHost.Tests", "tests\Sut\EgBim\Recordingtest.Sut.EgBim.PluginHost.Tests\Recordingtest.Sut.EgBim.PluginHost.Tests.csproj", "{5D5C57B2-D9BC-4E27-8EB1-49FE2FD78207}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Hmeg.Catalog.Tests", "tests\Hmeg\Recordingtest.Hmeg.Catalog.Tests\Recordingtest.Hmeg.Catalog.Tests.csproj", "{A9894277-E1F3-4B86-AAE4-041116FBBE1D}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Hmeg.Catalog.IntegrationTests", "tests\Hmeg\Recordingtest.Hmeg.Catalog.IntegrationTests\Recordingtest.Hmeg.Catalog.IntegrationTests.csproj", "{3D981C63-0D1E-466C-9BD6-3DAF46936A45}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Architecture.Tests", "tests\Recordingtest.Architecture.Tests\Recordingtest.Architecture.Tests.csproj", "{D35B233B-267B-40DB-87EF-689AEE5C9399}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.LauncherUI", "src\Recordingtest.LauncherUI\Recordingtest.LauncherUI.csproj", "{7D85A705-8410-41FC-AFB9-10B8F9D2DDF6}"
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
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|x64.Build.0 = Release|Any CPU
|
||||||
{6F9973EA-977A-4185-AF24-4E76D9D851C8}.Release|x86.ActiveCfg = Release|Any CPU
|
{6F9973EA-977A-4185-AF24-4E76D9D851C8}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{6F9973EA-977A-4185-AF24-4E76D9D851C8}.Release|x86.Build.0 = Release|Any CPU
|
{6F9973EA-977A-4185-AF24-4E76D9D851C8}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{938D464B-B810-425F-83B6-52877B584DE2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{938D464B-B810-425F-83B6-52877B584DE2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{938D464B-B810-425F-83B6-52877B584DE2}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
{938D464B-B810-425F-83B6-52877B584DE2}.Debug|x64.Build.0 = Debug|Any CPU
|
{E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
{938D464B-B810-425F-83B6-52877B584DE2}.Debug|x86.ActiveCfg = Debug|Any CPU
|
{E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
{938D464B-B810-425F-83B6-52877B584DE2}.Debug|x86.Build.0 = Debug|Any CPU
|
{E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{938D464B-B810-425F-83B6-52877B584DE2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{938D464B-B810-425F-83B6-52877B584DE2}.Release|Any CPU.Build.0 = Release|Any CPU
|
{E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{938D464B-B810-425F-83B6-52877B584DE2}.Release|x64.ActiveCfg = Release|Any CPU
|
{E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
{938D464B-B810-425F-83B6-52877B584DE2}.Release|x64.Build.0 = Release|Any CPU
|
{E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{938D464B-B810-425F-83B6-52877B584DE2}.Release|x86.ActiveCfg = Release|Any CPU
|
{E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{938D464B-B810-425F-83B6-52877B584DE2}.Release|x86.Build.0 = Release|Any CPU
|
{E9192225-E9F6-44EB-A18E-7F61F1093DA8}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Debug|x64.Build.0 = Debug|Any CPU
|
{33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
{33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Debug|x86.Build.0 = Debug|Any CPU
|
{33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Release|Any CPU.Build.0 = Release|Any CPU
|
{33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Release|x64.ActiveCfg = Release|Any CPU
|
{33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Release|x64.Build.0 = Release|Any CPU
|
{33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Release|x86.ActiveCfg = Release|Any CPU
|
{33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{B1EAD466-9C07-4C07-907C-3D5794F6689D}.Release|x86.Build.0 = Release|Any CPU
|
{33D35B3C-9572-432F-8675-6AD7CDF1C0EB}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{20FB4AD7-3414-436D-880C-B2D95280DA3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{20FB4AD7-3414-436D-880C-B2D95280DA3D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{20FB4AD7-3414-436D-880C-B2D95280DA3D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Debug|x64.Build.0 = Debug|Any CPU
|
{20FB4AD7-3414-436D-880C-B2D95280DA3D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Debug|x86.ActiveCfg = Debug|Any CPU
|
{20FB4AD7-3414-436D-880C-B2D95280DA3D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Debug|x86.Build.0 = Debug|Any CPU
|
{20FB4AD7-3414-436D-880C-B2D95280DA3D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{20FB4AD7-3414-436D-880C-B2D95280DA3D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Release|Any CPU.Build.0 = Release|Any CPU
|
{20FB4AD7-3414-436D-880C-B2D95280DA3D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Release|x64.ActiveCfg = Release|Any CPU
|
{20FB4AD7-3414-436D-880C-B2D95280DA3D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Release|x64.Build.0 = Release|Any CPU
|
{20FB4AD7-3414-436D-880C-B2D95280DA3D}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Release|x86.ActiveCfg = Release|Any CPU
|
{20FB4AD7-3414-436D-880C-B2D95280DA3D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Release|x86.Build.0 = Release|Any CPU
|
{20FB4AD7-3414-436D-880C-B2D95280DA3D}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{0A800F25-64B6-4F05-BB8E-68E317862CED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{0A800F25-64B6-4F05-BB8E-68E317862CED}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{0A800F25-64B6-4F05-BB8E-68E317862CED}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|x64.Build.0 = Debug|Any CPU
|
{0A800F25-64B6-4F05-BB8E-68E317862CED}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|x86.ActiveCfg = Debug|Any CPU
|
{0A800F25-64B6-4F05-BB8E-68E317862CED}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|x86.Build.0 = Debug|Any CPU
|
{0A800F25-64B6-4F05-BB8E-68E317862CED}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{0A800F25-64B6-4F05-BB8E-68E317862CED}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|Any CPU.Build.0 = Release|Any CPU
|
{0A800F25-64B6-4F05-BB8E-68E317862CED}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|x64.ActiveCfg = Release|Any CPU
|
{0A800F25-64B6-4F05-BB8E-68E317862CED}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|x64.Build.0 = Release|Any CPU
|
{0A800F25-64B6-4F05-BB8E-68E317862CED}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|x86.ActiveCfg = Release|Any CPU
|
{0A800F25-64B6-4F05-BB8E-68E317862CED}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|x86.Build.0 = Release|Any CPU
|
{0A800F25-64B6-4F05-BB8E-68E317862CED}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|x64.Build.0 = Debug|Any CPU
|
{23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|x86.ActiveCfg = Debug|Any CPU
|
{23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|x86.Build.0 = Debug|Any CPU
|
{23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|Any CPU.Build.0 = Release|Any CPU
|
{23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|x64.ActiveCfg = Release|Any CPU
|
{23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|x64.Build.0 = Release|Any CPU
|
{23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|x86.ActiveCfg = Release|Any CPU
|
{23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|x86.Build.0 = Release|Any CPU
|
{23D628DC-D98D-427A-B0C0-470E70CC6DD2}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|x64.Build.0 = Debug|Any CPU
|
{4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|x86.ActiveCfg = Debug|Any CPU
|
{4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|x86.Build.0 = Debug|Any CPU
|
{4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|Any CPU.Build.0 = Release|Any CPU
|
{4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|x64.ActiveCfg = Release|Any CPU
|
{4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|x64.Build.0 = Release|Any CPU
|
{4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|x86.ActiveCfg = Release|Any CPU
|
{4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|x86.Build.0 = Release|Any CPU
|
{4E0274C5-39C2-436E-90AA-87DD1C675B4C}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|x64.Build.0 = Debug|Any CPU
|
{A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
{A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|x86.Build.0 = Debug|Any CPU
|
{A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|Any CPU.Build.0 = Release|Any CPU
|
{A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|x64.ActiveCfg = Release|Any CPU
|
{A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|x64.Build.0 = Release|Any CPU
|
{A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|x86.ActiveCfg = Release|Any CPU
|
{A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|x86.Build.0 = Release|Any CPU
|
{A5765A50-21FC-4BC6-97E6-3FE3A1AE6008}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{5D5C57B2-D9BC-4E27-8EB1-49FE2FD78207}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{5D5C57B2-D9BC-4E27-8EB1-49FE2FD78207}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{5D5C57B2-D9BC-4E27-8EB1-49FE2FD78207}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{5D5C57B2-D9BC-4E27-8EB1-49FE2FD78207}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{5D5C57B2-D9BC-4E27-8EB1-49FE2FD78207}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{5D5C57B2-D9BC-4E27-8EB1-49FE2FD78207}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{5D5C57B2-D9BC-4E27-8EB1-49FE2FD78207}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{5D5C57B2-D9BC-4E27-8EB1-49FE2FD78207}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{5D5C57B2-D9BC-4E27-8EB1-49FE2FD78207}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{5D5C57B2-D9BC-4E27-8EB1-49FE2FD78207}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{5D5C57B2-D9BC-4E27-8EB1-49FE2FD78207}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{5D5C57B2-D9BC-4E27-8EB1-49FE2FD78207}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{A9894277-E1F3-4B86-AAE4-041116FBBE1D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A9894277-E1F3-4B86-AAE4-041116FBBE1D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A9894277-E1F3-4B86-AAE4-041116FBBE1D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{A9894277-E1F3-4B86-AAE4-041116FBBE1D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{A9894277-E1F3-4B86-AAE4-041116FBBE1D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{A9894277-E1F3-4B86-AAE4-041116FBBE1D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{A9894277-E1F3-4B86-AAE4-041116FBBE1D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{A9894277-E1F3-4B86-AAE4-041116FBBE1D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{A9894277-E1F3-4B86-AAE4-041116FBBE1D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{A9894277-E1F3-4B86-AAE4-041116FBBE1D}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{A9894277-E1F3-4B86-AAE4-041116FBBE1D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{A9894277-E1F3-4B86-AAE4-041116FBBE1D}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{3D981C63-0D1E-466C-9BD6-3DAF46936A45}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{3D981C63-0D1E-466C-9BD6-3DAF46936A45}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{3D981C63-0D1E-466C-9BD6-3DAF46936A45}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{3D981C63-0D1E-466C-9BD6-3DAF46936A45}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{3D981C63-0D1E-466C-9BD6-3DAF46936A45}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{3D981C63-0D1E-466C-9BD6-3DAF46936A45}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{3D981C63-0D1E-466C-9BD6-3DAF46936A45}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{3D981C63-0D1E-466C-9BD6-3DAF46936A45}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{3D981C63-0D1E-466C-9BD6-3DAF46936A45}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{3D981C63-0D1E-466C-9BD6-3DAF46936A45}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{3D981C63-0D1E-466C-9BD6-3DAF46936A45}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{3D981C63-0D1E-466C-9BD6-3DAF46936A45}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{D35B233B-267B-40DB-87EF-689AEE5C9399}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{D35B233B-267B-40DB-87EF-689AEE5C9399}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{D35B233B-267B-40DB-87EF-689AEE5C9399}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{D35B233B-267B-40DB-87EF-689AEE5C9399}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{D35B233B-267B-40DB-87EF-689AEE5C9399}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{D35B233B-267B-40DB-87EF-689AEE5C9399}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{D35B233B-267B-40DB-87EF-689AEE5C9399}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{D35B233B-267B-40DB-87EF-689AEE5C9399}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{D35B233B-267B-40DB-87EF-689AEE5C9399}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{D35B233B-267B-40DB-87EF-689AEE5C9399}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{D35B233B-267B-40DB-87EF-689AEE5C9399}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{D35B233B-267B-40DB-87EF-689AEE5C9399}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -295,12 +373,20 @@ Global
|
|||||||
{7A5C0D53-BDFC-4AF6-8F4D-49E7EB8245F5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{7A5C0D53-BDFC-4AF6-8F4D-49E7EB8245F5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{DADF0474-9EF3-4E8D-8139-93504E4F745D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{DADF0474-9EF3-4E8D-8139-93504E4F745D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{6F9973EA-977A-4185-AF24-4E76D9D851C8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{6F9973EA-977A-4185-AF24-4E76D9D851C8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{938D464B-B810-425F-83B6-52877B584DE2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{E9192225-E9F6-44EB-A18E-7F61F1093DA8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{B1EAD466-9C07-4C07-907C-3D5794F6689D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{33D35B3C-9572-432F-8675-6AD7CDF1C0EB} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{0811AC32-E2A4-4BFD-A29A-6451F5756F10} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{FA0FB21B-DC6D-6187-86C3-94DFEB22505D} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
{51D7B803-5F6E-4B78-9A5D-326F28CD934F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{20FB4AD7-3414-436D-880C-B2D95280DA3D} = {FA0FB21B-DC6D-6187-86C3-94DFEB22505D}
|
||||||
{45D80D0C-A8A1-4173-B28C-68F0628EE346} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{79DA188A-9C91-3DBA-2827-6072BD5E3D4F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{BA346F72-6F9C-4D68-9CDD-DD05F9687095} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{7CC28442-33DD-D811-CEDA-9CC787317768} = {79DA188A-9C91-3DBA-2827-6072BD5E3D4F}
|
||||||
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{0A800F25-64B6-4F05-BB8E-68E317862CED} = {7CC28442-33DD-D811-CEDA-9CC787317768}
|
||||||
|
{23D628DC-D98D-427A-B0C0-470E70CC6DD2} = {7CC28442-33DD-D811-CEDA-9CC787317768}
|
||||||
|
{4E0274C5-39C2-436E-90AA-87DD1C675B4C} = {7CC28442-33DD-D811-CEDA-9CC787317768}
|
||||||
|
{A5765A50-21FC-4BC6-97E6-3FE3A1AE6008} = {7CC28442-33DD-D811-CEDA-9CC787317768}
|
||||||
|
{5D5C57B2-D9BC-4E27-8EB1-49FE2FD78207} = {7CC28442-33DD-D811-CEDA-9CC787317768}
|
||||||
|
{A9894277-E1F3-4B86-AAE4-041116FBBE1D} = {7CC28442-33DD-D811-CEDA-9CC787317768}
|
||||||
|
{3D981C63-0D1E-466C-9BD6-3DAF46936A45} = {7CC28442-33DD-D811-CEDA-9CC787317768}
|
||||||
|
{D35B233B-267B-40DB-87EF-689AEE5C9399} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
|
{7D85A705-8410-41FC-AFB9-10B8F9D2DDF6} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
85
scripts/deploy-egbim-plugin.bat
Normal file
85
scripts/deploy-egbim-plugin.bat
Normal 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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Recordingtest.EngineBridge.Client;
|
namespace Recordingtest.Hmeg.Bridge.Client;
|
||||||
|
|
||||||
public sealed class EngineBridgeException : Exception
|
public sealed class EngineBridgeException : Exception
|
||||||
{
|
{
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
|
using System.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Recordingtest.Hmeg.Catalog;
|
||||||
|
|
||||||
namespace Recordingtest.EngineBridge.Client;
|
namespace Recordingtest.Hmeg.Bridge.Client;
|
||||||
|
|
||||||
public sealed class HmEgHttpSnapshot : IEngineSnapshot, IDisposable
|
public sealed class HmEgHttpSnapshot : IEngineSnapshot, IDisposable
|
||||||
{
|
{
|
||||||
@@ -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)
|
private JsonDocument Get(string endpoint)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -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>
|
||||||
315
src/Hmeg/Recordingtest.Hmeg.Bridge/HmegDirectStateProvider.cs
Normal file
315
src/Hmeg/Recordingtest.Hmeg.Bridge/HmegDirectStateProvider.cs
Normal 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 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using Recordingtest.EngineBridge;
|
using Recordingtest.Hmeg.Catalog;
|
||||||
|
|
||||||
namespace Recordingtest.EngineBridge.Probe;
|
namespace Recordingtest.Hmeg.Catalog.Probe;
|
||||||
|
|
||||||
internal static class Program
|
internal static class Program
|
||||||
{
|
{
|
||||||
@@ -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>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace Recordingtest.EngineBridge;
|
namespace Recordingtest.Hmeg.Catalog;
|
||||||
|
|
||||||
public sealed record Candidate(
|
public sealed record Candidate(
|
||||||
string Category,
|
string Category,
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace Recordingtest.EngineBridge;
|
namespace Recordingtest.Hmeg.Catalog;
|
||||||
|
|
||||||
public sealed record TypeEntry(string Assembly, string TypeName, bool IsPublic, string Namespace);
|
public sealed record TypeEntry(string Assembly, string TypeName, bool IsPublic, string Namespace);
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Recordingtest.EngineBridge;
|
namespace Recordingtest.Hmeg.Catalog;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Skeleton implementation of <see cref="IEngineSnapshot"/> for HmEG.
|
/// Skeleton implementation of <see cref="IEngineSnapshot"/> for HmEG.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Recordingtest.EngineBridge;
|
namespace Recordingtest.Hmeg.Catalog;
|
||||||
|
|
||||||
public interface IEngineSnapshot
|
public interface IEngineSnapshot
|
||||||
{
|
{
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
namespace Recordingtest.EngineBridge;
|
namespace Recordingtest.Hmeg.Catalog;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Thin wrapper around <see cref="MetadataLoadContext"/>. This class is
|
/// Thin wrapper around <see cref="MetadataLoadContext"/>. This class is
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<AssemblyName>Recordingtest.EngineBridge</AssemblyName>
|
<AssemblyName>Recordingtest.Hmeg.Catalog</AssemblyName>
|
||||||
<RootNamespace>Recordingtest.EngineBridge</RootNamespace>
|
<RootNamespace>Recordingtest.Hmeg.Catalog</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="System.Reflection.MetadataLoadContext" Version="8.0.0" />
|
<PackageReference Include="System.Reflection.MetadataLoadContext" Version="8.0.0" />
|
||||||
@@ -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 */ }
|
||||||
|
}
|
||||||
@@ -4,9 +4,6 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
<RootNamespace>Recordingtest.EngineBridge.Client</RootNamespace>
|
<RootNamespace>Recordingtest.Bridge</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\Recordingtest.EngineBridge\Recordingtest.EngineBridge.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
16
src/Recordingtest.LauncherUI/App.xaml
Normal file
16
src/Recordingtest.LauncherUI/App.xaml
Normal 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>
|
||||||
5
src/Recordingtest.LauncherUI/App.xaml.cs
Normal file
5
src/Recordingtest.LauncherUI/App.xaml.cs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace Recordingtest.LauncherUI;
|
||||||
|
|
||||||
|
public partial class App : Application { }
|
||||||
148
src/Recordingtest.LauncherUI/MainWindow.xaml
Normal file
148
src/Recordingtest.LauncherUI/MainWindow.xaml
Normal 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>
|
||||||
379
src/Recordingtest.LauncherUI/MainWindow.xaml.cs
Normal file
379
src/Recordingtest.LauncherUI/MainWindow.xaml.cs
Normal 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 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/Recordingtest.LauncherUI/Recordingtest.LauncherUI.csproj
Normal file
20
src/Recordingtest.LauncherUI/Recordingtest.LauncherUI.csproj
Normal 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>
|
||||||
106
src/Recordingtest.LauncherUI/UiAnalysisWindow.xaml
Normal file
106
src/Recordingtest.LauncherUI/UiAnalysisWindow.xaml
Normal 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>
|
||||||
417
src/Recordingtest.LauncherUI/UiAnalysisWindow.xaml.cs
Normal file
417
src/Recordingtest.LauncherUI/UiAnalysisWindow.xaml.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text.Encodings.Web;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
@@ -92,7 +93,8 @@ public static class Normalizer
|
|||||||
{
|
{
|
||||||
if (isJson && jsonNode is not null)
|
if (isJson && jsonNode is not null)
|
||||||
{
|
{
|
||||||
var (n, c) = Rules.RoundFloatsInNode(jsonNode);
|
var decimals = profile.FloatDecimals ?? Rules.DefaultFloatDecimals;
|
||||||
|
var (n, c) = Rules.RoundFloatsInNode(jsonNode, decimals);
|
||||||
jsonNode = n;
|
jsonNode = n;
|
||||||
log.Add(new RuleApplication(rule, c));
|
log.Add(new RuleApplication(rule, c));
|
||||||
}
|
}
|
||||||
@@ -106,7 +108,10 @@ public static class Normalizer
|
|||||||
{
|
{
|
||||||
if (isJson && jsonNode is not null)
|
if (isJson && jsonNode is not null)
|
||||||
{
|
{
|
||||||
var (n, c) = Rules.MaskVolatileSettings(jsonNode);
|
var paths = (profile.MaskVolatileSettings is { Count: > 0 })
|
||||||
|
? (IReadOnlyList<string>)profile.MaskVolatileSettings
|
||||||
|
: Rules.DefaultVolatileSettingPaths;
|
||||||
|
var (n, c) = Rules.MaskVolatileSettings(jsonNode, paths);
|
||||||
jsonNode = n;
|
jsonNode = n;
|
||||||
log.Add(new RuleApplication(rule, c));
|
log.Add(new RuleApplication(rule, c));
|
||||||
}
|
}
|
||||||
@@ -130,6 +135,20 @@ public static class Normalizer
|
|||||||
}
|
}
|
||||||
break;
|
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:
|
default:
|
||||||
throw new InvalidOperationException($"Unknown rule: {rule}");
|
throw new InvalidOperationException($"Unknown rule: {rule}");
|
||||||
}
|
}
|
||||||
@@ -138,7 +157,11 @@ public static class Normalizer
|
|||||||
string output;
|
string output;
|
||||||
if (isJson && jsonNode is not null)
|
if (isJson && jsonNode is not null)
|
||||||
{
|
{
|
||||||
output = jsonNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
|
output = jsonNode.ToJsonString(new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,19 @@ public sealed class Profile
|
|||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
public List<string> Rules { get; set; } = new();
|
public List<string> Rules { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional float decimals for round_floats. Null means use default (6).
|
||||||
|
/// </summary>
|
||||||
|
[YamlMember(Alias = "float_decimals", ApplyNamingConventions = false)]
|
||||||
|
public int? FloatDecimals { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional JSON-path allowlist for mask_volatile_settings.
|
||||||
|
/// Each entry is a JSONPath-lite string like "$.GridSnap" or "$.Viewport.GridColor.R".
|
||||||
|
/// </summary>
|
||||||
|
[YamlMember(Alias = "mask_volatile_settings", ApplyNamingConventions = false)]
|
||||||
|
public List<string>? MaskVolatileSettings { get; set; }
|
||||||
|
|
||||||
public static Profile Load(string profileName)
|
public static Profile Load(string profileName)
|
||||||
{
|
{
|
||||||
var baseDir = AppContext.BaseDirectory;
|
var baseDir = AppContext.BaseDirectory;
|
||||||
@@ -22,6 +35,7 @@ public sealed class Profile
|
|||||||
var yaml = File.ReadAllText(path);
|
var yaml = File.ReadAllText(path);
|
||||||
var deserializer = new DeserializerBuilder()
|
var deserializer = new DeserializerBuilder()
|
||||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||||
|
.IgnoreUnmatchedProperties()
|
||||||
.Build();
|
.Build();
|
||||||
return deserializer.Deserialize<Profile>(yaml) ?? new Profile { Name = profileName };
|
return deserializer.Deserialize<Profile>(yaml) ?? new Profile { Name = profileName };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,12 @@ public static class Rules
|
|||||||
/// JSON-aware: parse and round all double values to 6 decimals. Operates only when input is JSON.
|
/// JSON-aware: parse and round all double values to 6 decimals. Operates only when input is JSON.
|
||||||
/// Returns (json-output, count) when input is JSON; otherwise returns input unchanged with count=0.
|
/// Returns (json-output, count) when input is JSON; otherwise returns input unchanged with count=0.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
public const int DefaultFloatDecimals = 6;
|
||||||
|
|
||||||
public static (JsonNode? node, int count) RoundFloatsInNode(JsonNode? node)
|
public static (JsonNode? node, int count) RoundFloatsInNode(JsonNode? node)
|
||||||
|
=> RoundFloatsInNode(node, DefaultFloatDecimals);
|
||||||
|
|
||||||
|
public static (JsonNode? node, int count) RoundFloatsInNode(JsonNode? node, int decimals)
|
||||||
{
|
{
|
||||||
int count = 0;
|
int count = 0;
|
||||||
if (node is null) return (null, 0);
|
if (node is null) return (null, 0);
|
||||||
@@ -109,7 +114,7 @@ public static class Rules
|
|||||||
{
|
{
|
||||||
if (kv.Value is JsonValue v && TryAsDouble(v, out var d, out var wasFloat) && wasFloat)
|
if (kv.Value is JsonValue v && TryAsDouble(v, out var d, out var wasFloat) && wasFloat)
|
||||||
{
|
{
|
||||||
var rounded = Math.Round(d, 6, MidpointRounding.AwayFromZero);
|
var rounded = Math.Round(d, decimals, MidpointRounding.AwayFromZero);
|
||||||
obj[kv.Key] = JsonValue.Create(rounded);
|
obj[kv.Key] = JsonValue.Create(rounded);
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
@@ -126,7 +131,7 @@ public static class Rules
|
|||||||
var item = arr[i];
|
var item = arr[i];
|
||||||
if (item is JsonValue v && TryAsDouble(v, out var d, out var wasFloat) && wasFloat)
|
if (item is JsonValue v && TryAsDouble(v, out var d, out var wasFloat) && wasFloat)
|
||||||
{
|
{
|
||||||
var rounded = Math.Round(d, 6, MidpointRounding.AwayFromZero);
|
var rounded = Math.Round(d, decimals, MidpointRounding.AwayFromZero);
|
||||||
arr[i] = JsonValue.Create(rounded);
|
arr[i] = JsonValue.Create(rounded);
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
@@ -164,45 +169,104 @@ public static class Rules
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Allowlist of field names whose values are known to be volatile boolean/scalar
|
/// Default JSON-path allowlist for known volatile fields, used when a profile
|
||||||
/// settings (per docs/sut-catalog/json-configs.json). The values are replaced with
|
/// does not specify its own list. Each entry is a JSONPath-lite string anchored
|
||||||
/// a deterministic placeholder so golden-file comparisons stay stable while still
|
/// at the document root.
|
||||||
/// preserving the field's presence and key order.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly HashSet<string> VolatileSettingFieldNames = new(StringComparer.Ordinal)
|
public static readonly IReadOnlyList<string> DefaultVolatileSettingPaths = new List<string>
|
||||||
{
|
{
|
||||||
"CanOverrideWireColorWithFace",
|
"$.CanOverrideWireColorWithFace",
|
||||||
"IsSidePanelVisible",
|
"$.IsSidePanelVisible",
|
||||||
"OverrideFaceColor",
|
"$.OverrideFaceColor",
|
||||||
"Solar_IsLocalTime",
|
"$.Solar_IsLocalTime",
|
||||||
"VisibleGrid",
|
"$.VisibleGrid",
|
||||||
"GridSnap",
|
"$.GridSnap",
|
||||||
"MidpointOsnap",
|
"$.MidpointOsnap",
|
||||||
"GridSpacing",
|
"$.GridSpacing",
|
||||||
"GridColor.ALPHA",
|
"$.GridColor.ALPHA",
|
||||||
"GridColor.BLUE",
|
"$.GridColor.BLUE",
|
||||||
"GridColor.GREEN",
|
"$.GridColor.GREEN",
|
||||||
"GridColor.RED",
|
"$.GridColor.RED",
|
||||||
"MajorGridColor.ALPHA",
|
"$.MajorGridColor.ALPHA",
|
||||||
"MajorGridColor.BLUE",
|
"$.MajorGridColor.BLUE",
|
||||||
"MajorGridColor.GREEN",
|
"$.MajorGridColor.GREEN",
|
||||||
"MajorGridColor.RED",
|
"$.MajorGridColor.RED",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a JSONPath-lite string of the form "$.a.b.c" into segment list ["a","b","c"].
|
||||||
|
/// Throws on malformed input. Wildcards and array indexers are not supported.
|
||||||
|
/// </summary>
|
||||||
|
public static List<string> ParseJsonPathLite(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
throw new ArgumentException("path is empty", nameof(path));
|
||||||
|
if (!path.StartsWith("$"))
|
||||||
|
throw new ArgumentException($"path must start with '$': {path}", nameof(path));
|
||||||
|
var segments = new List<string>();
|
||||||
|
var rest = path.Substring(1);
|
||||||
|
if (rest.Length == 0) return segments;
|
||||||
|
if (rest[0] != '.')
|
||||||
|
throw new ArgumentException($"path must continue with '.': {path}", nameof(path));
|
||||||
|
// split on '.' but preserve empties as errors
|
||||||
|
var parts = rest.Substring(1).Split('.');
|
||||||
|
foreach (var p in parts)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(p))
|
||||||
|
throw new ArgumentException($"empty segment in path: {path}", nameof(path));
|
||||||
|
if (p.Contains('*') || p.Contains('[') || p.Contains(']'))
|
||||||
|
throw new ArgumentException($"wildcards/indexers not supported: {path}", nameof(path));
|
||||||
|
segments.Add(p);
|
||||||
|
}
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
public static (JsonNode? node, int count) MaskVolatileSettings(JsonNode? node)
|
public static (JsonNode? node, int count) MaskVolatileSettings(JsonNode? node)
|
||||||
|
=> MaskVolatileSettings(node, DefaultVolatileSettingPaths);
|
||||||
|
|
||||||
|
public static (JsonNode? node, int count) MaskVolatileSettings(JsonNode? node, IReadOnlyList<string> jsonPaths)
|
||||||
{
|
{
|
||||||
int count = 0;
|
int count = 0;
|
||||||
if (node is null) return (null, 0);
|
if (node is null) return (null, 0);
|
||||||
|
|
||||||
|
// Pre-parse the allowlist into segment chains for exact matching.
|
||||||
|
var allow = new List<List<string>>(jsonPaths.Count);
|
||||||
|
foreach (var p in jsonPaths)
|
||||||
|
{
|
||||||
|
allow.Add(ParseJsonPathLite(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
var stack = new List<string>();
|
||||||
Walk(node);
|
Walk(node);
|
||||||
return (node, count);
|
return (node, count);
|
||||||
|
|
||||||
|
bool PathMatches()
|
||||||
|
{
|
||||||
|
foreach (var chain in allow)
|
||||||
|
{
|
||||||
|
if (chain.Count != stack.Count) continue;
|
||||||
|
bool eq = true;
|
||||||
|
for (int i = 0; i < chain.Count; i++)
|
||||||
|
{
|
||||||
|
if (!string.Equals(chain[i], stack[i], StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
eq = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (eq) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
void Walk(JsonNode n)
|
void Walk(JsonNode n)
|
||||||
{
|
{
|
||||||
if (n is JsonObject obj)
|
if (n is JsonObject obj)
|
||||||
{
|
{
|
||||||
foreach (var kv in obj.ToList())
|
foreach (var kv in obj.ToList())
|
||||||
{
|
{
|
||||||
if (VolatileSettingFieldNames.Contains(kv.Key))
|
stack.Add(kv.Key);
|
||||||
|
if (PathMatches())
|
||||||
{
|
{
|
||||||
obj[kv.Key] = JsonValue.Create("<VOLATILE>");
|
obj[kv.Key] = JsonValue.Create("<VOLATILE>");
|
||||||
count++;
|
count++;
|
||||||
@@ -211,6 +275,7 @@ public static class Rules
|
|||||||
{
|
{
|
||||||
Walk(kv.Value);
|
Walk(kv.Value);
|
||||||
}
|
}
|
||||||
|
stack.RemoveAt(stack.Count - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (n is JsonArray arr)
|
else if (n is JsonArray arr)
|
||||||
@@ -261,4 +326,51 @@ public static class Rules
|
|||||||
}
|
}
|
||||||
return null;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
name: default
|
name: default
|
||||||
|
float_decimals: 6
|
||||||
rules:
|
rules:
|
||||||
- strip_timestamps
|
- strip_timestamps
|
||||||
- mask_guids
|
- mask_guids
|
||||||
@@ -6,3 +7,20 @@ rules:
|
|||||||
- round_floats
|
- round_floats
|
||||||
- mask_volatile_settings
|
- mask_volatile_settings
|
||||||
- sort_json_keys
|
- sort_json_keys
|
||||||
|
mask_volatile_settings:
|
||||||
|
- "$.CanOverrideWireColorWithFace"
|
||||||
|
- "$.IsSidePanelVisible"
|
||||||
|
- "$.OverrideFaceColor"
|
||||||
|
- "$.Solar_IsLocalTime"
|
||||||
|
- "$.VisibleGrid"
|
||||||
|
- "$.GridSnap"
|
||||||
|
- "$.MidpointOsnap"
|
||||||
|
- "$.GridSpacing"
|
||||||
|
- "$.GridColor.ALPHA"
|
||||||
|
- "$.GridColor.BLUE"
|
||||||
|
- "$.GridColor.GREEN"
|
||||||
|
- "$.GridColor.RED"
|
||||||
|
- "$.MajorGridColor.ALPHA"
|
||||||
|
- "$.MajorGridColor.BLUE"
|
||||||
|
- "$.MajorGridColor.GREEN"
|
||||||
|
- "$.MajorGridColor.RED"
|
||||||
|
|||||||
16
src/Recordingtest.Normalizer/profiles/engine-state.yaml
Normal file
16
src/Recordingtest.Normalizer/profiles/engine-state.yaml
Normal 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
|
||||||
@@ -25,4 +25,17 @@ public interface IPlayerHost
|
|||||||
|
|
||||||
void CaptureCheckpoint(int afterStep, string saveAs);
|
void CaptureCheckpoint(int afterStep, string saveAs);
|
||||||
void CaptureFailureArtifacts(int stepIndex, string reason);
|
void CaptureFailureArtifacts(int stepIndex, string reason);
|
||||||
|
|
||||||
|
// Issue #14: delay between steps. Kept on the host (not in the engine)
|
||||||
|
// because PlayerEngine contract forbids fixed sleeps; the host is free
|
||||||
|
// to implement real time or a virtual clock for tests.
|
||||||
|
void Delay(TimeSpan duration);
|
||||||
|
|
||||||
|
/// <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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,22 @@ public sealed class Scenario
|
|||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
public SutInfo Sut { get; set; } = new();
|
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<Step> Steps { get; set; } = new();
|
||||||
public List<Checkpoint> Checkpoints { get; set; } = new();
|
public List<Checkpoint> Checkpoints { get; set; } = new();
|
||||||
public List<Baseline> Baselines { 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 sealed class SutInfo
|
||||||
{
|
{
|
||||||
public string Exe { get; set; } = string.Empty;
|
public string Exe { get; set; } = string.Empty;
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ public sealed class Step
|
|||||||
public string? WaitFor { get; set; }
|
public string? WaitFor { get; set; }
|
||||||
public int? AfterStep { get; set; }
|
public int? AfterStep { get; set; }
|
||||||
public string? SaveAs { get; set; }
|
public string? SaveAs { get; set; }
|
||||||
|
// Issue #14: recorder-captured screen-absolute coordinates used as
|
||||||
|
// fallback when Target is null (Click). Optional; null for non-mouse steps.
|
||||||
|
public int[]? RawCoord { get; set; }
|
||||||
|
// Issue #14: recorder-captured absolute timestamp (ms). Used by the
|
||||||
|
// engine to preserve inter-step pacing during playback.
|
||||||
|
public long? Ts { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class Target
|
public sealed class Target
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Linq;
|
||||||
using Recordingtest.Player.Model;
|
using Recordingtest.Player.Model;
|
||||||
|
|
||||||
namespace Recordingtest.Player;
|
namespace Recordingtest.Player;
|
||||||
@@ -6,6 +7,14 @@ public sealed class PlayerEngineOptions
|
|||||||
{
|
{
|
||||||
public TimeSpan ResolveTimeout { get; set; } = TimeSpan.FromSeconds(10);
|
public TimeSpan ResolveTimeout { get; set; } = TimeSpan.FromSeconds(10);
|
||||||
public TimeSpan WaitForTimeout { get; set; } = TimeSpan.FromSeconds(15);
|
public TimeSpan WaitForTimeout { get; set; } = TimeSpan.FromSeconds(15);
|
||||||
|
|
||||||
|
// Issue #14: preserve recorded inter-step delays (clamped). When true the
|
||||||
|
// engine sleeps step.Ts - prevStep.Ts between steps, bounded by Min/Max.
|
||||||
|
public bool PreserveTiming { get; set; } = true;
|
||||||
|
public TimeSpan MinStepDelay { get; set; } = TimeSpan.FromMilliseconds(150);
|
||||||
|
public TimeSpan MaxStepDelay { get; set; } = TimeSpan.FromSeconds(3);
|
||||||
|
/// <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
|
public sealed class PlayerEngine
|
||||||
@@ -17,14 +26,104 @@ public sealed class PlayerEngine
|
|||||||
_options = options ?? new PlayerEngineOptions();
|
_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(scenario);
|
||||||
ArgumentNullException.ThrowIfNull(host);
|
ArgumentNullException.ThrowIfNull(host);
|
||||||
|
|
||||||
for (int i = 0; i < scenario.Steps.Count; i++)
|
// Issue #14: strip leading alt+tab hotkey steps. These are recording
|
||||||
|
// startup noise (user tabbing from their editor into the SUT at the
|
||||||
|
// start of the session). At replay time the player already puts the
|
||||||
|
// SUT in the foreground, so re-running alt+tab here just switches
|
||||||
|
// focus AWAY from the SUT and breaks subsequent Type steps.
|
||||||
|
int start = 0;
|
||||||
|
while (start < scenario.Steps.Count)
|
||||||
{
|
{
|
||||||
|
var s = scenario.Steps[start];
|
||||||
|
if (s.Kind == StepKind.Hotkey &&
|
||||||
|
string.Equals(s.Value, "alt+tab", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[player] info: skipping leading alt+tab step {start} (issue #14)");
|
||||||
|
start++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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];
|
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
|
try
|
||||||
{
|
{
|
||||||
ExecuteStep(i, step, host);
|
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;
|
ResolvedElement? element = null;
|
||||||
ScreenPoint point = default;
|
ScreenPoint point = default;
|
||||||
if (step.Target is not null && !string.IsNullOrEmpty(step.Target.UiaPath))
|
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);
|
element = host.ResolveElement(step.Target.UiaPath, _options.ResolveTimeout);
|
||||||
if (element is null)
|
if (element is null)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
// Safety fallback for Click: if the anchor path failed to resolve
|
||||||
$"failed to resolve uia_path '{step.Target.UiaPath}' at step {index}");
|
// (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))
|
else if (StepRequiresTarget(step.Kind))
|
||||||
{
|
{
|
||||||
// Issue #11: recorder may emit Click/Drag/Type/Focus steps with
|
// Issue #14: recorder emits Type/Click with null target when the
|
||||||
// null target. Never click/drag/type at (0,0) on the desktop —
|
// focused element / UIA path at record time could not be resolved
|
||||||
// skip with a warning instead.
|
// (e.g. typing into a CommandBox before any mouse click, clicks on
|
||||||
Console.WriteLine(
|
// canvas children that don't expose AutomationId). Fall back to:
|
||||||
$"[player] warn: skipping step {index} kind={step.Kind} — target is null (issue #11)");
|
// - Type → send keystrokes to whatever currently has focus
|
||||||
return;
|
// - Click → use recorded raw_coord (screen-absolute) directly
|
||||||
|
// This mirrors the manual cleanup that produced box-v5-clean.yaml.
|
||||||
|
if (step.Kind == StepKind.Type)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
else if (step.Kind == StepKind.Click
|
||||||
|
&& step.RawCoord is { Length: >= 2 })
|
||||||
|
{
|
||||||
|
point = new ScreenPoint(step.RawCoord[0], step.RawCoord[1]);
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[player] info: step {index} kind=Click null target — using raw_coord ({point.X},{point.Y}) (issue #14)");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[player] warn: skipping step {index} kind={step.Kind} — target is null and no fallback (issue #14)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (step.Kind)
|
switch (step.Kind)
|
||||||
@@ -130,7 +280,7 @@ public sealed class PlayerEngine
|
|||||||
StepKind.Click => true,
|
StepKind.Click => true,
|
||||||
StepKind.Drag => true,
|
StepKind.Drag => true,
|
||||||
StepKind.Type => true,
|
StepKind.Type => true,
|
||||||
StepKind.Focus => true,
|
StepKind.Focus => false, // no-op (issue #11) — target resolve not needed
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var host = new UiaPlayerHost(app, artifactDir);
|
using var host = new UiaPlayerHost(app, artifactDir);
|
||||||
|
|
||||||
|
// Issue #14: ensure SUT is the foreground window before playback so that
|
||||||
|
// keystrokes (Type/Hotkey) land on the SUT instead of whatever shell the
|
||||||
|
// player was launched from (PowerShell, VS Code, etc.). Without this, the
|
||||||
|
// very first "BOX" type step gets typed into the launching terminal.
|
||||||
|
host.BringSutToForeground();
|
||||||
|
|
||||||
var engine = new PlayerEngine();
|
var engine = new PlayerEngine();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,4 +12,7 @@
|
|||||||
<PackageReference Include="FlaUI.UIA3" Version="4.0.0" />
|
<PackageReference Include="FlaUI.UIA3" Version="4.0.0" />
|
||||||
<PackageReference Include="YamlDotNet" Version="16.1.3" />
|
<PackageReference Include="YamlDotNet" Version="16.1.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="Recordingtest.Player.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
using FlaUI.Core;
|
using FlaUI.Core;
|
||||||
using FlaUI.Core.AutomationElements;
|
using FlaUI.Core.AutomationElements;
|
||||||
using FlaUI.Core.Input;
|
using FlaUI.Core.Input;
|
||||||
@@ -18,12 +19,14 @@ public sealed class UiaPlayerHost : IPlayerHost, IDisposable
|
|||||||
private readonly UIA3Automation _automation;
|
private readonly UIA3Automation _automation;
|
||||||
private readonly Application? _app;
|
private readonly Application? _app;
|
||||||
private readonly string _artifactDir;
|
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();
|
_automation = new UIA3Automation();
|
||||||
_app = app;
|
_app = app;
|
||||||
_artifactDir = artifactDir;
|
_artifactDir = artifactDir;
|
||||||
|
_sidecarUrl = sidecarUrl;
|
||||||
Directory.CreateDirectory(_artifactDir);
|
Directory.CreateDirectory(_artifactDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,19 +70,27 @@ public sealed class UiaPlayerHost : IPlayerHost, IDisposable
|
|||||||
return result.Result;
|
return result.Result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Click(ScreenPoint point) =>
|
public void Click(ScreenPoint point)
|
||||||
|
{
|
||||||
|
EnsureSutForegroundQuick();
|
||||||
Mouse.Click(new System.Drawing.Point(point.X, point.Y));
|
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) =>
|
public void Drag(ScreenPoint from, ScreenPoint to) =>
|
||||||
Mouse.Drag(
|
Mouse.Drag(
|
||||||
new System.Drawing.Point(from.X, from.Y),
|
new System.Drawing.Point(from.X, from.Y),
|
||||||
new System.Drawing.Point(to.X, to.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 parts = keys.Split('+', StringSplitOptions.RemoveEmptyEntries);
|
||||||
var modifiers = new List<VirtualKeyShort>();
|
var modifiers = new List<VirtualKeyShort>();
|
||||||
VirtualKeyShort? main = null;
|
VirtualKeyShort? main = null;
|
||||||
@@ -90,17 +101,46 @@ public sealed class UiaPlayerHost : IPlayerHost, IDisposable
|
|||||||
case "ctrl": modifiers.Add(VirtualKeyShort.CONTROL); break;
|
case "ctrl": modifiers.Add(VirtualKeyShort.CONTROL); break;
|
||||||
case "shift": modifiers.Add(VirtualKeyShort.SHIFT); break;
|
case "shift": modifiers.Add(VirtualKeyShort.SHIFT); break;
|
||||||
case "alt": modifiers.Add(VirtualKeyShort.ALT); 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:
|
default:
|
||||||
if (p.Length == 1)
|
if (p.Length == 1)
|
||||||
{
|
{
|
||||||
main = (VirtualKeyShort)char.ToUpperInvariant(p[0]);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
foreach (var m in modifiers) Keyboard.Press(m);
|
return new ParsedHotkey(modifiers, main);
|
||||||
if (main is not null) Keyboard.Type(main.Value);
|
}
|
||||||
foreach (var m in modifiers) Keyboard.Release(m);
|
|
||||||
|
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)
|
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()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_automation.Dispose();
|
_automation.Dispose();
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ public sealed class DragCollapser
|
|||||||
var typeBuf = new System.Text.StringBuilder();
|
var typeBuf = new System.Text.StringBuilder();
|
||||||
RawEvent? typeFirst = null;
|
RawEvent? typeFirst = null;
|
||||||
UiaResolution? typeRes = null;
|
UiaResolution? typeRes = null;
|
||||||
|
// Issue #14 Gap I-1: path captured directly from the focus poller at
|
||||||
|
// the first key_down of a type burst. Takes precedence over the older
|
||||||
|
// lastFocusPath / lastMousePath fallbacks because it's pinned to the
|
||||||
|
// instant the user actually started typing.
|
||||||
|
string? typeFocusPath = null;
|
||||||
// Active modifiers (ctrl/shift/alt/win) held down.
|
// Active modifiers (ctrl/shift/alt/win) held down.
|
||||||
var modsDown = new HashSet<string>(StringComparer.Ordinal);
|
var modsDown = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
@@ -69,7 +74,7 @@ public sealed class DragCollapser
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var fallback = lastFocusPath ?? lastMousePath;
|
var fallback = typeFocusPath ?? lastFocusPath ?? lastMousePath;
|
||||||
if (!string.IsNullOrEmpty(fallback))
|
if (!string.IsNullOrEmpty(fallback))
|
||||||
{
|
{
|
||||||
step.Target = new ScenarioTarget
|
step.Target = new ScenarioTarget
|
||||||
@@ -83,6 +88,7 @@ public sealed class DragCollapser
|
|||||||
typeBuf.Clear();
|
typeBuf.Clear();
|
||||||
typeFirst = null;
|
typeFirst = null;
|
||||||
typeRes = null;
|
typeRes = null;
|
||||||
|
typeFocusPath = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var ev in events)
|
foreach (var ev in events)
|
||||||
@@ -129,7 +135,7 @@ public sealed class DragCollapser
|
|||||||
}
|
}
|
||||||
if (useSq >= threshSq)
|
if (useSq >= threshSq)
|
||||||
{
|
{
|
||||||
// drag step
|
// drag step — use anchored path so viewport drags resolve
|
||||||
var step = new ScenarioStep
|
var step = new ScenarioStep
|
||||||
{
|
{
|
||||||
Kind = "drag",
|
Kind = "drag",
|
||||||
@@ -139,13 +145,13 @@ public sealed class DragCollapser
|
|||||||
};
|
};
|
||||||
if (downRes is not null)
|
if (downRes is not null)
|
||||||
{
|
{
|
||||||
var (sx, sy) = OffsetNormalizer.Normalize(
|
var (anchorPath, sx, sy) = ElementPathBuilder.BuildAnchored(
|
||||||
downRes.Snapshot.BoundingRectangle, down.X, down.Y);
|
downRes.Snapshot, down.X, down.Y);
|
||||||
var (ex, ey) = OffsetNormalizer.Normalize(
|
var (_, ex, ey) = ElementPathBuilder.BuildAnchored(
|
||||||
downRes.Snapshot.BoundingRectangle, ev.X, ev.Y);
|
downRes.Snapshot, ev.X, ev.Y);
|
||||||
step.Target = new ScenarioTarget
|
step.Target = new ScenarioTarget
|
||||||
{
|
{
|
||||||
UiaPath = downRes.UiaPath,
|
UiaPath = anchorPath,
|
||||||
Offset = new[] { sx, sy },
|
Offset = new[] { sx, sy },
|
||||||
};
|
};
|
||||||
step.EndOffset = new[] { ex, ey };
|
step.EndOffset = new[] { ex, ey };
|
||||||
@@ -154,7 +160,7 @@ public sealed class DragCollapser
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// click step at down point
|
// click step — use anchored path so viewport clicks resolve
|
||||||
var step = new ScenarioStep
|
var step = new ScenarioStep
|
||||||
{
|
{
|
||||||
Kind = "click",
|
Kind = "click",
|
||||||
@@ -163,11 +169,11 @@ public sealed class DragCollapser
|
|||||||
};
|
};
|
||||||
if (downRes is not null)
|
if (downRes is not null)
|
||||||
{
|
{
|
||||||
var (ox, oy) = OffsetNormalizer.Normalize(
|
var (anchorPath, ox, oy) = ElementPathBuilder.BuildAnchored(
|
||||||
downRes.Snapshot.BoundingRectangle, down.X, down.Y);
|
downRes.Snapshot, down.X, down.Y);
|
||||||
step.Target = new ScenarioTarget
|
step.Target = new ScenarioTarget
|
||||||
{
|
{
|
||||||
UiaPath = downRes.UiaPath,
|
UiaPath = anchorPath,
|
||||||
Offset = new[] { ox, oy },
|
Offset = new[] { ox, oy },
|
||||||
};
|
};
|
||||||
if (MaskPolicy.IsMasked(downRes.Snapshot))
|
if (MaskPolicy.IsMasked(downRes.Snapshot))
|
||||||
@@ -195,11 +201,11 @@ public sealed class DragCollapser
|
|||||||
};
|
};
|
||||||
if (res is not null)
|
if (res is not null)
|
||||||
{
|
{
|
||||||
var (ox, oy) = OffsetNormalizer.Normalize(
|
var (anchorPath, ox, oy) = ElementPathBuilder.BuildAnchored(
|
||||||
res.Snapshot.BoundingRectangle, ev.X, ev.Y);
|
res.Snapshot, ev.X, ev.Y);
|
||||||
step.Target = new ScenarioTarget
|
step.Target = new ScenarioTarget
|
||||||
{
|
{
|
||||||
UiaPath = res.UiaPath,
|
UiaPath = anchorPath,
|
||||||
Offset = new[] { ox, oy },
|
Offset = new[] { ox, oy },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -265,6 +271,9 @@ public sealed class DragCollapser
|
|||||||
{
|
{
|
||||||
typeFirst = ev;
|
typeFirst = ev;
|
||||||
typeRes = res;
|
typeRes = res;
|
||||||
|
// Issue #14 Gap I-1: capture focused-element path
|
||||||
|
// snapshotted by the poller at key_down time.
|
||||||
|
typeFocusPath = ev.FocusedElementPath;
|
||||||
}
|
}
|
||||||
typeBuf.Append(tr.Text);
|
typeBuf.Append(tr.Text);
|
||||||
break;
|
break;
|
||||||
@@ -303,11 +312,11 @@ public sealed class DragCollapser
|
|||||||
};
|
};
|
||||||
if (res is not null)
|
if (res is not null)
|
||||||
{
|
{
|
||||||
var (ox, oy) = OffsetNormalizer.Normalize(
|
var (anchorPath, ox, oy) = ElementPathBuilder.BuildAnchored(
|
||||||
res.Snapshot.BoundingRectangle, ev.X, ev.Y);
|
res.Snapshot, ev.X, ev.Y);
|
||||||
step.Target = new ScenarioTarget
|
step.Target = new ScenarioTarget
|
||||||
{
|
{
|
||||||
UiaPath = res.UiaPath,
|
UiaPath = anchorPath,
|
||||||
Offset = new[] { ox, oy },
|
Offset = new[] { ox, oy },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,4 +58,72 @@ public static class ElementPathBuilder
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static string Escape(string s) => s.Replace("'", "'");
|
private static string Escape(string s) => s.Replace("'", "'");
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
16
src/Recordingtest.Recorder/FocusEventFilter.cs
Normal file
16
src/Recordingtest.Recorder/FocusEventFilter.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,14 @@ public sealed class LowLevelHook : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public IWindowFilter Filter { get; set; } = new PassThroughWindowFilter();
|
public IWindowFilter Filter { get; set; } = new PassThroughWindowFilter();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issue #14 Gap I-1 — latest UIA focused-element path observed by a
|
||||||
|
/// background poller. Stamped onto key_down RawEvents so the collapser
|
||||||
|
/// can assign a target to the resulting Type step without relying on
|
||||||
|
/// the stale post-hoc Resolve() pass. Null until the first poll.
|
||||||
|
/// </summary>
|
||||||
|
public volatile string? CurrentFocusedPath;
|
||||||
|
|
||||||
public LowLevelHook(Channel<RawEvent> channel)
|
public LowLevelHook(Channel<RawEvent> channel)
|
||||||
{
|
{
|
||||||
_channel = channel;
|
_channel = channel;
|
||||||
@@ -83,7 +91,8 @@ public sealed class LowLevelHook : IDisposable
|
|||||||
NativeMethods.WM_KEYUP or NativeMethods.WM_SYSKEYUP => "key_up",
|
NativeMethods.WM_KEYUP or NativeMethods.WM_SYSKEYUP => "key_up",
|
||||||
_ => "key",
|
_ => "key",
|
||||||
};
|
};
|
||||||
var ev = new RawEvent(NowMs(), kind, 0, 0, data.vkCode, 0);
|
var ev = new RawEvent(
|
||||||
|
NowMs(), kind, 0, 0, data.vkCode, 0, CurrentFocusedPath);
|
||||||
if (Filter.ShouldKeep(ev))
|
if (Filter.ShouldKeep(ev))
|
||||||
{
|
{
|
||||||
_channel.Writer.TryWrite(ev);
|
_channel.Writer.TryWrite(ev);
|
||||||
|
|||||||
@@ -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)
|
internal static CliArgs? ParseArgs(string[] args)
|
||||||
{
|
{
|
||||||
string? output = null;
|
string? output = null;
|
||||||
string? attach = null;
|
string? attach = null;
|
||||||
|
string? sidecarUrl = null;
|
||||||
for (int i = 0; i < args.Length; i++)
|
for (int i = 0; i < args.Length; i++)
|
||||||
{
|
{
|
||||||
switch (args[i])
|
switch (args[i])
|
||||||
@@ -50,11 +51,15 @@ public static class Program
|
|||||||
case "--attach" when i + 1 < args.Length:
|
case "--attach" when i + 1 < args.Length:
|
||||||
attach = args[++i];
|
attach = args[++i];
|
||||||
break;
|
break;
|
||||||
|
case "--sidecar-url" when i + 1 < args.Length:
|
||||||
|
sidecarUrl = args[++i];
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (string.IsNullOrEmpty(attach)) return null;
|
if (string.IsNullOrEmpty(attach)) return null;
|
||||||
if (string.IsNullOrEmpty(output)) output = "scenarios/recorded.yaml";
|
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()
|
internal static void PrintUsage()
|
||||||
@@ -72,10 +77,12 @@ public static class Program
|
|||||||
Application? app = null;
|
Application? app = null;
|
||||||
UIA3Automation? automation = null;
|
UIA3Automation? automation = null;
|
||||||
AutomationElement? mainWindow = null;
|
AutomationElement? mainWindow = null;
|
||||||
|
int sutPid = 0;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
(app, automation, mainWindow) = TryAttach(args.Attach);
|
(app, automation, mainWindow) = TryAttach(args.Attach);
|
||||||
|
if (app is not null) sutPid = app.ProcessId;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -86,7 +93,6 @@ public static class Program
|
|||||||
// any window not owned by the SUT process.
|
// any window not owned by the SUT process.
|
||||||
if (app is not null)
|
if (app is not null)
|
||||||
{
|
{
|
||||||
int sutPid = app.ProcessId;
|
|
||||||
hook.Filter = new SutProcessWindowFilter(
|
hook.Filter = new SutProcessWindowFilter(
|
||||||
sutPid,
|
sutPid,
|
||||||
processFromPoint: (x, y) =>
|
processFromPoint: (x, y) =>
|
||||||
@@ -106,10 +112,20 @@ public static class Program
|
|||||||
Console.WriteLine($"[recorder] window filter active for pid={sutPid}");
|
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
|
var scenario = new Scenario
|
||||||
{
|
{
|
||||||
Name = System.IO.Path.GetFileNameWithoutExtension(args.OutputPath),
|
Name = System.IO.Path.GetFileNameWithoutExtension(args.OutputPath),
|
||||||
Description = "Recorded session",
|
Description = "Recorded session",
|
||||||
|
CameraSnapshot = cameraSnapshot,
|
||||||
};
|
};
|
||||||
|
|
||||||
var cts = new CancellationTokenSource();
|
var cts = new CancellationTokenSource();
|
||||||
@@ -119,6 +135,26 @@ public static class Program
|
|||||||
cts.Cancel();
|
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
|
// Register UIA focus changed event. The callback only captures the
|
||||||
// element path and pushes a synthetic RawEvent into the same queue;
|
// element path and pushes a synthetic RawEvent into the same queue;
|
||||||
// it does NOT compute anything else inside the UIA callback.
|
// it does NOT compute anything else inside the UIA callback.
|
||||||
@@ -131,6 +167,11 @@ public static class Program
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (el is null) return;
|
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 snap = new FlaUiSnapshot(el);
|
||||||
var path = ElementPathBuilder.Build(snap);
|
var path = ElementPathBuilder.Build(snap);
|
||||||
channel.Writer.TryWrite(new RawEvent(
|
channel.Writer.TryWrite(new RawEvent(
|
||||||
@@ -149,6 +190,63 @@ public static class Program
|
|||||||
Console.Error.WriteLine($"[recorder] focus subscribe failed: {ex.Message}");
|
Console.Error.WriteLine($"[recorder] focus subscribe failed: {ex.Message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Issue #14 Gap I-1 — background focus poller. Periodically queries
|
||||||
|
// Automation.FocusedElement() and publishes the element path into
|
||||||
|
// LowLevelHook.CurrentFocusedPath, so key_down events captured on the
|
||||||
|
// hook thread can be stamped with the live focused-element path at
|
||||||
|
// the exact instant the user started typing. This catches custom WPF
|
||||||
|
// controls (e.g. CommandBox) that do NOT raise UIA focus_changed
|
||||||
|
// events reliably.
|
||||||
|
var pollerCts = new CancellationTokenSource();
|
||||||
|
Task? pollerTask = null;
|
||||||
|
int pollerSuccess = 0;
|
||||||
|
int pollerNullFocus = 0;
|
||||||
|
int pollerWrongPid = 0;
|
||||||
|
int pollerErrors = 0;
|
||||||
|
string? pollerLastError = null;
|
||||||
|
if (automation is not null)
|
||||||
|
{
|
||||||
|
var auto = automation;
|
||||||
|
var pid = sutPid;
|
||||||
|
pollerTask = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
while (!pollerCts.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var focused = auto.FocusedElement();
|
||||||
|
if (focused is null)
|
||||||
|
{
|
||||||
|
System.Threading.Interlocked.Increment(ref pollerNullFocus);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
int elPid = 0;
|
||||||
|
try { elPid = focused.Properties.ProcessId.ValueOrDefault; }
|
||||||
|
catch { elPid = 0; }
|
||||||
|
if (pid == 0 || elPid == pid)
|
||||||
|
{
|
||||||
|
var snap = new FlaUiSnapshot(focused);
|
||||||
|
hook.CurrentFocusedPath = ElementPathBuilder.Build(snap);
|
||||||
|
System.Threading.Interlocked.Increment(ref pollerSuccess);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
System.Threading.Interlocked.Increment(ref pollerWrongPid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
System.Threading.Interlocked.Increment(ref pollerErrors);
|
||||||
|
pollerLastError = ex.GetType().Name + ": " + ex.Message;
|
||||||
|
}
|
||||||
|
try { await Task.Delay(100, pollerCts.Token); }
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
}
|
||||||
|
}, pollerCts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
Console.WriteLine("[recorder] capturing... press Ctrl+C to stop.");
|
Console.WriteLine("[recorder] capturing... press Ctrl+C to stop.");
|
||||||
int eventCount = 0;
|
int eventCount = 0;
|
||||||
int unresolvedPaths = 0; // resolver ran but returned null
|
int unresolvedPaths = 0; // resolver ran but returned null
|
||||||
@@ -168,12 +266,20 @@ public static class Program
|
|||||||
|
|
||||||
sw.Stop();
|
sw.Stop();
|
||||||
|
|
||||||
|
// Stop the focus poller before UIA teardown.
|
||||||
|
pollerCts.Cancel();
|
||||||
|
try { pollerTask?.Wait(500); } catch { /* ignore */ }
|
||||||
|
|
||||||
// Collapse buffered raw events into scenario steps via DragCollapser.
|
// Collapse buffered raw events into scenario steps via DragCollapser.
|
||||||
var collapser = new DragCollapser();
|
var collapser = new DragCollapser();
|
||||||
UiaResolution? Resolve(RawEvent ev)
|
UiaResolution? Resolve(RawEvent ev)
|
||||||
{
|
{
|
||||||
// Key events have no meaningful coordinate — resolver cannot attempt
|
// Issue #14 Gap I-1 — key events: Resolve() runs at collapse time
|
||||||
// a point-based lookup. Count them separately from genuine misses.
|
// (after the recording ended), so querying FocusedElement() HERE
|
||||||
|
// would be stale (focus has already left the SUT). The focused
|
||||||
|
// element path is captured at key_down time by the FocusPoller
|
||||||
|
// and baked into RawEvent.FocusedElementPath. DragCollapser reads
|
||||||
|
// that directly; Resolve() simply returns null for key events.
|
||||||
if (ev.Kind == "key_down" || ev.Kind == "key_up")
|
if (ev.Kind == "key_down" || ev.Kind == "key_up")
|
||||||
{
|
{
|
||||||
noResolverAttempt++;
|
noResolverAttempt++;
|
||||||
@@ -186,7 +292,8 @@ public static class Program
|
|||||||
}
|
}
|
||||||
try
|
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)
|
if (snap is null)
|
||||||
{
|
{
|
||||||
unresolvedPaths++;
|
unresolvedPaths++;
|
||||||
@@ -218,6 +325,11 @@ public static class Program
|
|||||||
$"[recorder] done. events={eventCount} elapsed={sw.Elapsed} " +
|
$"[recorder] done. events={eventCount} elapsed={sw.Elapsed} " +
|
||||||
$"unresolved_paths={unresolvedPaths} no_resolver_attempt={noResolverAttempt} " +
|
$"unresolved_paths={unresolvedPaths} no_resolver_attempt={noResolverAttempt} " +
|
||||||
$"null_target_steps={nullTargetSteps}");
|
$"null_target_steps={nullTargetSteps}");
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[recorder] focus_poller success={pollerSuccess} null_focus={pollerNullFocus} " +
|
||||||
|
$"wrong_pid={pollerWrongPid} errors={pollerErrors} last_path={hook.CurrentFocusedPath ?? "<null>"}");
|
||||||
|
if (pollerLastError is not null)
|
||||||
|
Console.WriteLine($"[recorder] focus_poller last_error: {pollerLastError}");
|
||||||
|
|
||||||
automation?.Dispose();
|
automation?.Dispose();
|
||||||
return 0;
|
return 0;
|
||||||
@@ -258,6 +370,49 @@ public static class Program
|
|||||||
return (app, automation, main);
|
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(
|
private static async Task ConsumeAsync(
|
||||||
ChannelReader<RawEvent> reader,
|
ChannelReader<RawEvent> reader,
|
||||||
System.Collections.Generic.List<RawEvent> buffer,
|
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));
|
private readonly UIA3Automation _automation;
|
||||||
if (raw is null) return null;
|
private readonly AutomationElement? _mainWindow;
|
||||||
return new FlaUiSnapshot(raw);
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,4 +13,7 @@
|
|||||||
<PackageReference Include="FlaUI.UIA3" Version="4.0.0" />
|
<PackageReference Include="FlaUI.UIA3" Version="4.0.0" />
|
||||||
<PackageReference Include="YamlDotNet" Version="16.1.3" />
|
<PackageReference Include="YamlDotNet" Version="16.1.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="Recordingtest.Recorder.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -7,9 +7,21 @@ public sealed class Scenario
|
|||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
public ScenarioSut Sut { get; set; } = new();
|
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 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 sealed class ScenarioSut
|
||||||
{
|
{
|
||||||
public string Exe { get; set; } = "EG-BIM Modeler/EG-BIM Modeler.exe";
|
public string Exe { get; set; } = "EG-BIM Modeler/EG-BIM Modeler.exe";
|
||||||
|
|||||||
38
src/Recordingtest.Recorder/WindowPointResolver.cs
Normal file
38
src/Recordingtest.Recorder/WindowPointResolver.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/Recordingtest.Runner/IEngineStateSnapshotClient.cs
Normal file
95
src/Recordingtest.Runner/IEngineStateSnapshotClient.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ public static class Program
|
|||||||
public static int Main(string[] args)
|
public static int Main(string[] args)
|
||||||
{
|
{
|
||||||
var options = new RunnerOptions();
|
var options = new RunnerOptions();
|
||||||
|
string? sidecarUrl = null;
|
||||||
|
bool noSidecar = false;
|
||||||
for (int i = 0; i < args.Length; i++)
|
for (int i = 0; i < args.Length; i++)
|
||||||
{
|
{
|
||||||
switch (args[i])
|
switch (args[i])
|
||||||
@@ -14,9 +16,17 @@ public static class Program
|
|||||||
case "--out": options.OutDir = args[++i]; break;
|
case "--out": options.OutDir = args[++i]; break;
|
||||||
case "--profile": options.Profile = args[++i]; break;
|
case "--profile": options.Profile = args[++i]; break;
|
||||||
case "--no-launch": options.NoLaunch = true; 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 "-h":
|
||||||
case "--help":
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -29,12 +39,24 @@ public static class Program
|
|||||||
return 2;
|
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 runner = new TestRunner();
|
||||||
var report = runner.RunAll(
|
var report = runner.RunAll(
|
||||||
options,
|
options,
|
||||||
new DefaultHostFactory(),
|
new DefaultHostFactory(),
|
||||||
new DefaultNormalizer(),
|
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}");
|
Console.WriteLine($"Total: {report.Total}, Passed: {report.Passed}, Failed: {report.Failed}, Errored: {report.Errored}");
|
||||||
return TestRunner.ToExitCode(report);
|
return TestRunner.ToExitCode(report);
|
||||||
|
|||||||
@@ -18,4 +18,12 @@ public sealed class ScenarioResult
|
|||||||
public int CheckpointCount { get; set; }
|
public int CheckpointCount { get; set; }
|
||||||
public string ArtifactDir { get; set; } = string.Empty;
|
public string ArtifactDir { get; set; } = string.Empty;
|
||||||
public string? Error { get; set; }
|
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";
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user