feat(tdd): implement SSOT canon, AGENTS.md, ultra-strict design audit, E2E scenarios, and Forgejo release pipeline
Some checks are pending
Forgejo Release / build-and-release (push) Waiting to run
Some checks are pending
Forgejo Release / build-and-release (push) Waiting to run
This commit is contained in:
parent
24aa70ae32
commit
4cd9c678c0
25 changed files with 1684 additions and 238 deletions
47
.forgejo/workflows/release.yaml
Normal file
47
.forgejo/workflows/release.yaml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
name: Forgejo Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: '릴리즈 태그 (예: v1.0.6)'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- name: Run TDD Test Suite (Gate 1)
|
||||
shell: pwsh
|
||||
run: dotnet test src/Everything2Everything.Tests/Everything2Everything.Tests.csproj -c Release
|
||||
|
||||
- name: Build Portable Binary
|
||||
shell: pwsh
|
||||
run: |
|
||||
dotnet publish src/Everything2Everything.App/Everything2Everything.App.csproj `
|
||||
-c Release -r win-x64 --self-contained false -o publish
|
||||
|
||||
- name: Compress Portable Binary
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Path packaging/dist -Force | Out-Null
|
||||
Compress-Archive -Path publish/* -DestinationPath packaging/dist/Everything2Everything-win-x64-portable.zip -Force
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Everything2Everything-win-x64-portable
|
||||
path: packaging/dist/Everything2Everything-win-x64-portable.zip
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -49,4 +49,5 @@ artifacts/
|
|||
|
||||
# NuGet packages restore + nuget.exe cache
|
||||
packages/
|
||||
tools/
|
||||
tools/*
|
||||
!tools/*.ps1
|
||||
|
|
|
|||
93
AGENTS.md
Normal file
93
AGENTS.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# Everything2Everything — AI AGENT GUIDELINES (AGENTS.md)
|
||||
|
||||
> **[최고 지침] TDD 절대주의 (TDD as the Absolute Law)**
|
||||
> 본 저장소에서 작업하는 모든 AI 에이전트와 개발자는 예외 없이 **테스트 주도 개발(TDD)** 원칙을 신성한 불변의 헌법으로 준수해야 한다.
|
||||
> 실패하는 단위/통합/디자인 테스트(RED)가 작성되기 전에는 어떠한 프로덕션 코드도 단 한 줄 작성하거나 수정할 수 없다.
|
||||
> 이론적 배경 및 세부 규약은 [docs/tdd/](docs/tdd/README.md)의 SSOT 문서를 반드시 따른다.
|
||||
|
||||
---
|
||||
|
||||
## 1. TDD 4대 불변 법칙 (Inviolable Invariants)
|
||||
|
||||
### Rule 1: No Code Without RED (테스트 선행)
|
||||
- 어떠한 버그 수정, 기능 추가, UI 개편도 **실패하는 테스트(RED)를 먼저 작성**하는 것으로 시작해야 한다.
|
||||
- 테스트는 단순한 컴파일 오류가 아니라, **의도한 동작이 실패함을 알리는 명확한 Assertion 실패**여야 한다.
|
||||
|
||||
### Rule 2: Minimal Code for GREEN (최소 구현)
|
||||
- 작성된 RED 테스트를 통과시키는 데 필요한 **가장 간결하고 정갈한 코드**만을 작성한다.
|
||||
- 테스트 범위를 넘어서는 불필요한 기능이나 과도한 엔지니어링을 사전에 추가하지 않는다.
|
||||
|
||||
### Rule 3: Strict REFACTOR (무결점 리팩토링)
|
||||
- 모든 테스트가 초록불(GREEN)인 상태에서만 리팩토링을 수행한다.
|
||||
- 코드 중복 제거, 명확한 네이밍, 디자인 패턴 적용, 성능 최적화를 진행하며, 리팩토링 중에는 어떠한 새로운 동작도 추가하지 않는다.
|
||||
|
||||
### Rule 4: Test Suite Pruning & Consolidation (가지치기 및 통폐합)
|
||||
- RED 체계를 무작정 확장하기만 하지 않는다. 의미 없는 세부 구현 결합 테스트나 중복 테스트는 즉각 가지치기(Pruning)한다.
|
||||
- 유사한 입출력 분기는 xUnit `[Theory]`와 `[InlineData]`로 파라미터화 통폐합(Consolidation)한다.
|
||||
- 실제 사용자의 복합적 인터랙션(입력 필드 작성, 버튼 클릭, 취소, 프로그레스) 중심의 완결된 유즈케이스 시나리오 테스트를 지향한다.
|
||||
- 전체 테스트 스위트 실행 시간은 **10초 이내**를 유지해야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 초엄격 디자인 & UX 감사 규약 (Design Audit Invariants)
|
||||
|
||||
Everything2Everything은 최고 수준의 Windows 11 Fluent 2 미학을 추구한다. 다음 7가지 디자인 결함은 **자동화된 정적 AST 테스트 및 In-Memory Visual Tree 테스트로 검출(RED)**되며, 발견 즉시 자가 수정(GREEN)해야 한다.
|
||||
|
||||
### 1) 아이콘-텍스트 수직 기준선 중앙 정렬 (Vertical Alignment)
|
||||
- 가로 방향 `StackPanel` 내부에 아이콘(`Image`, `Path`, `ui:SymbolIcon`, 상태 `Ellipse`)과 `TextBlock`이 함께 배치될 때, **반드시 `VerticalAlignment="Center"`**를 선언해야 한다.
|
||||
- 아이콘이 텍스트보다 위에 뜨거나 아래로 가라앉는 시각적 치우침을 절대 허용하지 않는다.
|
||||
|
||||
### 2) 인풋창 내부 요소 패딩 (Input Padding)
|
||||
- `TextBox`, `PasswordBox`, `ComboBox`는 내부 텍스트 글리프가 테두리에 닿지 않도록 **수평 최소 8px 이상, 수직 최소 4px 이상**의 패딩을 가져야 한다. (권장: `10,8` 또는 `12,8`).
|
||||
- 패딩이 0이거나 비대칭적인 기형적 값(`0,6`, `20,1` 등)을 금지한다.
|
||||
|
||||
### 3) 텍스트 내 유니코드 이모지 혼용 금지 & 언어 통일
|
||||
- 버튼 텍스트에 `Content="⚙ 설정"`처럼 날것의 유니코드 이모지나 특수문자를 인라인으로 포함하는 것을 엄격히 금지한다.
|
||||
- 아이콘은 별도의 `ui:SymbolIcon` 또는 정제된 `Path` 벡터 엘리먼트로 분리한다.
|
||||
- 상단 내비게이션 및 액션 버튼은 정제된 한국어로 통일한다 (`설정`, `컨텍스트 메뉴 등록`, `진단`, `로그 내보내기`, `목록 비우기`).
|
||||
|
||||
### 4) 컨테이너 오버플로 및 텍스트 생략 부호 (Overflow Safety)
|
||||
- 긴 파일명이나 경로를 바인딩하는 모든 `TextBlock`은 `TextTrimming="CharacterEllipsis"` 또는 `TextWrapping="Wrap"`을 필수 적용한다.
|
||||
- 화면 크기 축소 시 요소가 창 밖으로 삐져나가거나 인접 컨트롤을 덮어씌우는 Flex/Grid 정렬 오류를 방지한다.
|
||||
|
||||
### 5) 폰트 가독성 및 WCAG AA 명도 대비
|
||||
- 다크 테마 배경(`#090A0C`, `#131417`) 위에서 텍스트는 최소 4.5:1 이상의 대비를 유지해야 한다 (`FsTextPrimary`, `FsTextSecondary`).
|
||||
- 어두운 배경에 묻혀 보이지 않는 어두운 회색(`#3A3D45` 이하)의 텍스트 배치를 금지한다.
|
||||
|
||||
### 6) 제로-보이드 빈 상태 원칙 (Zero-Void Empty State)
|
||||
- 데이터가 비어 있을 수 있는 모든 뷰(`ActiveQueueView`, `PastResultsView`)는 빈 상태 전용 UI(일러스트레이션 또는 아이콘 + 직관적인 안내 문구 + 행동 유도 버튼)를 무조건 포함해야 한다.
|
||||
- 아무런 피드백 없는 칠흑 같은 빈 공간 노출을 금지한다.
|
||||
|
||||
### 7) 8px 그리드 시스템 준수
|
||||
- 마진과 패딩은 4의 배수(`4, 8, 12, 16, 20, 24, 32px`)를 기본으로 사용하며, 홀수나 비표준 수치(`3, 7, 11, 13px`)의 남발을 금지한다.
|
||||
- 동일한 위계의 형제 버튼은 일치하는 패딩과 높이를 가져야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 자율 피드백 루프 프로토콜 (Autonomous Loop Protocol)
|
||||
|
||||
에이전트는 요구사항을 구현할 때 다음 사이클을 자율적으로 회전시킨다:
|
||||
|
||||
1. **명세 & RED**:
|
||||
- `src/Everything2Everything.Tests/`에 실패하는 테스트 작성.
|
||||
- `dotnet test` 실행 후 실패 메시지(`Expected vs Actual`) 확인.
|
||||
2. **구현 & GREEN**:
|
||||
- 최소한의 코드로 테스트 통과.
|
||||
- 전체 테스트 스위트 전수 실행 (`통과! - 실패: 0`).
|
||||
3. **디자인 감사 & REFACTOR**:
|
||||
- XAML AST 정적 분석 및 Visual Tree 테스트 통과 여부 확인.
|
||||
- 결함 발견 시 XAML 뷰모델 및 스타일 즉각 보정.
|
||||
4. **가지치기 (Pruning)**:
|
||||
- 중복되거나 과도하게 복잡한 테스트가 생기지 않았는지 점검 및 통폐합.
|
||||
|
||||
---
|
||||
|
||||
## 4. 릴리즈 및 배포 체계 (Release & Deployment Gate)
|
||||
|
||||
- **원격 저장소**: `https://git.chanpaca.net/yunchan/Everything2Everything.git`
|
||||
- **배포 스크립트**: `pwsh tools/Publish-Release.ps1 -Version "<M.m.p>" -ReleaseNotes "<내용>"`
|
||||
- **릴리즈 게이트**:
|
||||
1. 전체 단위/디자인/시나리오 테스트 100% 통과 (실패 0개, 경고 0개).
|
||||
2. `Package.appxmanifest`와 Git Tag 버전 동기화 (SemVer 2.0).
|
||||
3. Portable EXE + MSIX 패키지 빌드 성공.
|
||||
4. Forgejo API를 통한 릴리즈 생성 및 바이너리 자산 첨부.
|
||||
112
docs/tdd/01_THEORY_CANON.md
Normal file
112
docs/tdd/01_THEORY_CANON.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# 01. TDD 이론 캐넌 & 정통 원칙 (Theoretical Foundations & Canon)
|
||||
|
||||
> "Clean code that works, in Ron Jeffries' pithy phrase, is the goal of Test-Driven Development (TDD)."
|
||||
> — **Kent Beck**, *Test-Driven Development: By Example* (Addison-Wesley)
|
||||
|
||||
이 문서는 소프트웨어 공학 30년간 검증된 TDD의 본질과 최고 권위자들의 핵심 정통 이론을 집대성한 SSOT 캐넌이다.
|
||||
|
||||
---
|
||||
|
||||
## 1. Kent Beck의 정통 TDD 체계 (Canonical TDD)
|
||||
|
||||
### 1.1 TDD의 불변의 3대 원칙 (Three Laws of TDD)
|
||||
Robert C. Martin(Uncle Bob)과 Kent Beck이 정립한 TDD의 나노 사이클 규칙:
|
||||
|
||||
1. **제1법칙 (The First Law)**: 실패하는 단위 테스트를 작성하기 전에는 어떠한 프로덕션 코드도 작성할 수 없다.
|
||||
2. **제2법칙 (The Second Law)**: 컴파일 실패를 포함하여 실패를 나타내기에 충분한 정도까지만 단위 테스트를 작성해야 하며, 그 이상 작성해서는 안 된다.
|
||||
3. **제3법칙 (The Third Law)**: 현재 실패하는 단 하나의 단위 테스트를 통과시키기에 충분한 정도까지만 프로덕션 코드를 작성해야 하며, 그 이상 작성해서는 안 된다.
|
||||
|
||||
### 1.2 Red-Green-Refactor 라이프사이클의 본질
|
||||
* **RED (실패의 명세화)**:
|
||||
- 작성하고자 하는 기능의 "의도(Intent)"와 "계약(Contract)"을 테스트 코드로 먼저 선언한다.
|
||||
- **핵심 주의점**: 단순 컴파일 에러가 아닌, **올바른 실패(Expected vs Actual 불일치)**가 출력창에 나타나는 것을 반드시 눈으로 확인해야 한다.
|
||||
* **GREEN (최소 구현)**:
|
||||
- 가장 빠르고 간결한 방법으로 테스트를 통과시킨다.
|
||||
- 필요하다면 상수를 하드코딩(Fake It Till You Make It)하거나, 단순한 삼항 연산자를 사용해도 좋다. 중요한 것은 즉각적인 피드백과 안전망 후보다.
|
||||
* **REFACTOR (구조적 정제)**:
|
||||
- 테스트가 초록불(GREEN)인 상태에서만 리팩토링을 수행한다.
|
||||
- 중복 코드(DRY) 제거, 매직 넘버 제거, 디자인 패턴 적용, 성능 최적화, 가독성 향상.
|
||||
- **Kent Beck의 불변식**: 리팩토링 단계에서는 절대로 새로운 동작(Behavior)을 추가해서는 안 된다.
|
||||
|
||||
### 1.3 Kent Beck의 *Tidy First?* (정리 우선 원칙)
|
||||
구조적 변경(Structural Change)과 기능적 변경(Behavioral Change)을 철저히 분리한다:
|
||||
* **B 커밋**: 기능 추가/변경 (반드시 테스트를 동반)
|
||||
* **S 커밋**: 코드 정리/이름 변경/인터페이스 추출 (동작 변경 0%)
|
||||
두 가지를 한 커밋이나 한 사이클에 섞는 순간, 버그의 원인을 추적할 수 없는 혼돈이 발생한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. Martin Fowler: 테스트 피라미드 & Classicist vs Mockist
|
||||
|
||||
### 2.1 The Practical Test Pyramid (실용적 테스트 피라미드)
|
||||
Martin Fowler와 Mike Cohn이 제안한 계층 구조:
|
||||
|
||||
```
|
||||
/ UI / E2E \ (느림, 고비용, 높은 취약성, 핵심 유즈케이스 집중)
|
||||
/----------------\
|
||||
/ Integration \ (중간 속도, 컴포넌트 간 협력 검증, DI/그래프 검증)
|
||||
/--------------------\
|
||||
/ Unit Tests \ (초고속, 밀리초 단위, 비즈니스 로직 및 알고리즘 100%)
|
||||
/------------------------\
|
||||
```
|
||||
|
||||
* **피라미드 붕괴 안티패턴**:
|
||||
- **아이스크림 콘 (Ice Cream Cone)**: 단위 테스트가 거의 없고 느리고 깨지기 쉬운 UI/E2E 테스트만 가득한 구조. 피드백 루프가 수십 분으로 늘어나며 TDD가 마비된다.
|
||||
- **모래시계 (Hourglass)**: 단위 테스트와 E2E는 많으나 컴포넌트 결합 검증(Integration)이 전무하여 경계 지점에서 런타임 크래시가 빈발함.
|
||||
* **Everything2Everything의 황금 비율**:
|
||||
- **Unit/Engine (Core)**: 70% (Dijkstra 그래프 라우팅, 손실 등급 산출, 포맷 변환 순수 로직)
|
||||
- **Integration/AST (Audit)**: 20% (XAML 정적 파싱, DI 컨테이너 결합, 인코더 옵션 매핑)
|
||||
- **E2E/Visual Tree (App)**: 10% (WPF In-Memory 창 렌더링, 큐 조작, 취소/진행률 시나리오)
|
||||
|
||||
### 2.2 Classicist (Detroit) vs Mockist (London) 학파의 조화
|
||||
* **Classicist 학파 (Detroit/Chicago)**:
|
||||
- 실제 객체(Real Collaborators)를 사용하여 상태(State)의 변화를 검증.
|
||||
- 리팩토링 시 내부 구현이 바뀌어도 테스트가 깨지지 않는 강건함(Robustness)을 제공.
|
||||
- **우리의 원칙**: `ConversionGraph`, `ProviderRegistry`, `ConvertOptions` 등 핵심 도메인은 Classicist 방식으로 실제 객체 그래프를 구동하여 검증한다.
|
||||
* **Mockist 학파 (London)**:
|
||||
- Mock/Stub을 사용하여 객체 간의 상호작용(Behavior/Interaction)을 검증.
|
||||
- 격리성이 뛰어나며 TDD 설계 유도에 유리하나, 내부 메서드 시그니처 변경에 민감함.
|
||||
- **우리의 원칙**: 외부 프로세스(FFmpeg CLI, LibreOffice), OS 파일시스템, 클라우드 AI API(OpenAI/Anthropic) 경계 지점에서만 철저히 Test Double을 사용한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. Gerard Meszaros: *xUnit Test Patterns* & Test Smells
|
||||
|
||||
### 3.1 4단계 테스트 패턴 (Four-Phase Test)
|
||||
모든 단위 테스트는 다음 4개의 구별된 단계를 가져야 하며, 순서가 뒤섞여서는 안 된다:
|
||||
|
||||
1. **Setup (준비)**: 테스트 픽스처(Fixture)를 구성한다. SUT(System Under Test)와 협력자를 인스턴스화한다.
|
||||
2. **Exercise (실행)**: SUT의 단 하나의 메서드나 행위를 실행한다.
|
||||
3. **Verify (검증)**: 기대한 결과와 실제 결과를 단언(Assert)한다.
|
||||
4. **Teardown (해제)**: 생성된 임시 파일, 메모리, STA 스레드 리소스를 원래 상태로 복원한다.
|
||||
|
||||
### 3.2 픽스처 관리 전략 (Fixture Strategies)
|
||||
* **Fresh Fixture (신선한 픽스처)**: 각 테스트마다 새로운 객체와 환경을 생성. 테스트 간 오염(Pollution)이 없어 독립성이 보장됨 (기본 채택).
|
||||
* **Shared Fixture (공유 픽스처)**: 무거운 자원(예: 테스트용 폰트 로딩, DI 빌드)을 테스트 클래스 전체에서 공유. xUnit의 `IClassFixture<T>` 사용. 단, 불변(Immutable) 상태로 유지해야 테스트 순서 의존성이 발생하지 않음.
|
||||
|
||||
### 3.3 18대 테스트 스멜 (Test Smells) 및 제거법
|
||||
| 테스트 스멜 | 징후 및 문제점 | Everything2Everything 해결책 |
|
||||
|---|---|---|
|
||||
| **Fragile Test (취약한 테스트)** | 코드의 내부 private 변수나 구현 디테일을 검증하여 작은 리팩토링에도 우수수 깨짐 | 인터페이스와 공개 계약(Public API/Output)만 검증 |
|
||||
| **Obscure Test (모호한 테스트)** | 테스트 코드가 길고 복잡하여 무엇을 검증하는지 한눈에 알 수 없음 | Setup을 헬퍼 메서드로 캡슐화, 명확한 네이밍 |
|
||||
| **Mystery Guest (불가사의한 손님)** | 테스트 파일 외부에 있는 마법 같은 파일/환경에 의존하여 실패 원인을 알 수 없음 | 인라인 테스트 데이터 빌더 및 명시적 픽스처 선언 |
|
||||
| **Eager Test (욕심쟁이 테스트)** | 하나의 테스트 메서드에서 5~10개의 서로 다른 기능을 한꺼번에 검증함 | 하나의 테스트는 단 하나의 논리적 개념(Single Concept)만 단언 |
|
||||
| **Assertion Roulette (단언 룰렛)** | 메시지 없는 수많은 Assert가 나열되어 어느 라인에서 왜 죽었는지 알 수 없음 | 설명적 실패 메시지 포함 및 FluentAssertion 스타일 분리 |
|
||||
| **Conditional Test Logic (조건부 테스트 로직)** | 테스트 코드 안에 `if`, `for`, `switch`가 들어있어 테스트 자체가 버그를 가짐 | 테스트 내 분기문 완전 금지. `[Theory]`와 `[InlineData]`로 분리 |
|
||||
| **Hardcoded Test Data (하드코딩된 테스트 데이터)** | 실제 파일 경로나 운영 체제 종속적 경로(`C:\...`)를 하드코딩 | `Path.Combine` 및 임시 디렉터리(`Path.GetTempPath()`) 추상화 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Michael Feathers: 레거시 코드와 특성화 테스트 (Characterization Tests)
|
||||
|
||||
> "Legacy code is simply code without tests."
|
||||
> — **Michael Feathers**, *Working Effectively with Legacy Code*
|
||||
|
||||
### 4.1 접합부 (Seam) 기법
|
||||
테스트 대상 코드를 직접 수정하지 않고도 그 행위를 가로채거나 변경할 수 있는 장소:
|
||||
* **객체 접합부 (Object Seam)**: 생성자 주입(DI) 또는 가상 메서드 오버라이드를 통해 가짜 인스턴스를 주입.
|
||||
* **링크 접합부 (Link Seam)**: 인터페이스 기반 참조를 분리하여 테스트 러너에서 대체 어셈블리를 로드.
|
||||
|
||||
### 4.2 골든 마스터 & 특성화 테스트 (Characterization Testing)
|
||||
* 동작을 모르는 복잡한 기존 코드(예: `MainWindow.xaml.cs`의 수많은 이벤트 핸들러)를 리팩토링하기 전, 현재 시스템의 실제 입출력 동작을 그대로 기록(Snapshot)하는 테스트를 먼저 작성한다.
|
||||
* 이 특성화 테스트가 **회귀 방어선(Regression Safety Net)**이 되어, 내부 코드를 MVVM이나 별도 커맨드로 분해할 때 기존 동작이 단 1%도 변하지 않았음을 수학적으로 증명한다.
|
||||
90
docs/tdd/02_AGENTIC_TDD.md
Normal file
90
docs/tdd/02_AGENTIC_TDD.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# 02. 2025–2026 AI Agentic TDD 최신 동향 & 실전 프랙티스
|
||||
|
||||
> "Without rigorous, automated verification hooks, autonomous coding agents do not converge on correct solutions; they diverge into unmaintainable 'vibe code'."
|
||||
> — *Agentic Software Engineering Patterns (arXiv, 2026)*
|
||||
|
||||
이 문서는 2025–2026년 AI 에이전트(Autonomous AI Agents)와 대규모 언어 모델(LLM) 환경에서 TDD가 왜 필수불가결한 핵심 생명선인지, 그리고 이를 운영하기 위한 최신 아키텍처 패턴을 정의한다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 바이브 코딩(Vibe Coding)의 파멸과 Agentic TDD의 필연성
|
||||
|
||||
### 1.1 자율 코딩 에이전트의 3대 퇴행 현상
|
||||
LLM 에이전트에게 단순 자연어로 "이 기능을 추가해줘"라고 요청할 때 발생하는 전형적인 장애:
|
||||
1. **사일런트 리그레션 (Silent Regression)**: 한 부분을 고치면서 기존에 작동하던 다른 서브시스템의 동작을 소리 없이 파괴함.
|
||||
2. **환각 인터페이스 (Hallucinated Contracts)**: 존재하지 않는 메서드, 잘못된 열거형 값, 비표준 XAML 속성을 자연스럽게 지어내어 런타임에 크래시 발생.
|
||||
3. **토큰 소모 루프 (Token Burn Loop)**: 코드를 마구잡이로 고치고 실행해본 뒤 에러가 나면 또 다른 추측성 코드를 덧붙여 컨텍스트 윈도우를 오염시키고 수백만 토큰을 낭비함.
|
||||
|
||||
### 1.2 TDD: 에이전트의 불변 안전망 (The Agent Invariant Guardrail)
|
||||
* **에이전트에게 테스트란?**: 단순한 검증 도구가 아니라, **완벽하고 모호함이 없는 실행 가능한 명세서(Executable Specification)**이다.
|
||||
* 자연어 지침은 모델에 따라 다의적으로 해석될 수 있으나, C# xUnit의 `Assert.Equal(expected, actual)`은 1비트의 모호함도 허용하지 않는다.
|
||||
* 테스트가 통과(GREEN)했다는 객관적 사실만이 에이전트의 작업 완료를 입증할 수 있는 유일한 기준이다.
|
||||
|
||||
---
|
||||
|
||||
## 2. Agentic TDD 핵심 아키텍처 패턴 (2026 Best Practices)
|
||||
|
||||
### 2.1 적대적 명세-검증 루프 (Adversarial Specification & Verification)
|
||||
최신 에이전트 아키텍처에서는 기능 구현자와 검증자를 분리하거나, 인지 모드를 전환하여 검증을 수행한다:
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ 1. Specifier / Tester Mode │
|
||||
│ - 요구사항을 분석하여 엣지 케이스를 포함한 RED 테스트 작성 │
|
||||
│ - 의도된 실패(RED)를 터미널에서 확인 │
|
||||
└─────────────────────────┬──────────────────────────────┘
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ 2. Implementer Mode │
|
||||
│ - 테스트를 만족시키는 가장 정갈하고 최소한의 코드 작성 │
|
||||
│ - GREEN 전환 확인 │
|
||||
└─────────────────────────┬──────────────────────────────┘
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ 3. Adversary / Mutation Mode │
|
||||
│ - 테스트가 엉터리로 통과한 것은 아닌지 의심 │
|
||||
│ - 코드를 변소(Mutate)시켜 테스트가 즉시 깨지는지 검증 │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 실패 유도 자가 치유 (Fail-Guided Self-Repair)
|
||||
* 에이전트가 실패 메시지를 마주했을 때 직관이나 추측으로 코드를 바꾸지 않는다.
|
||||
* **오류 분석 프로토콜**:
|
||||
1. `Expected: <기대값>` vs `Actual: <실제값>`의 구체적 차이를 문자 단위로 분석.
|
||||
2. 스택 트레이스의 정확한 파일명과 라인 번호(`file.cs:L42`)로 즉시 점프.
|
||||
3. 해당 지점의 로직 분기 또는 상태 전이 불일치를 확인하고 정밀 타격 수정.
|
||||
|
||||
### 2.3 Pre-Write & Pre-Test Invariant Hooks
|
||||
* 에이전트 도구 호출 체계에서 소스 코드를 수정(`replace_file_content` 또는 `write_to_file`)하기 전, 해당 기능에 대응하는 테스트 파일이 변경되었거나 존재하는지 감시한다.
|
||||
* 테스트 코드의 보강 없이 프로덕션 코드만 광범위하게 수정하는 행위는 TDD 규약 위반으로 간주하여 차단한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 뮤테이션 테스팅 (Mutation Testing as Test Quality Gate)
|
||||
|
||||
### 3.1 100% 코드 커버리지의 함정
|
||||
* 코드 라인이 실행되었다고 해서 그 코드가 올바르게 검증된 것은 아니다. (예: `Assert`가 없거나, `Assert.True(true)`처럼 무의미한 단언).
|
||||
* 특히 AI 에이전트가 생성한 테스트는 형식적으로는 통과하지만 실제 버그를 잡지 못하는 "껍데기 테스트"가 될 위험이 있다.
|
||||
|
||||
### 3.2 뮤테이션 점수 (Mutation Score)
|
||||
* 프로덕션 코드에 고의로 인위적인 결함(Mutants)을 주입한다:
|
||||
- 조건문 반전: `if (a > b)` → `if (a <= b)`
|
||||
- 산술 연산자 교체: `count + 1` → `count - 1`
|
||||
- 반환값 변경: `return true` → `return false`
|
||||
- 문장 제거: 메서드 호출 주석 처리
|
||||
* **살아남은 돌연변이 (Survived Mutant)**: 코드가 망가졌는데도 테스트가 통과했다면, 그 테스트는 무가치한 쓰레기다.
|
||||
* **사살된 돌연변이 (Killed Mutant)**: 코드가 망가지자마자 테스트가 즉시 RED로 터지면, 그 테스트는 진정한 가치를 지닌 방어선이다.
|
||||
* Everything2Everything은 핵심 변환 라우팅(`ConversionGraph`), 손실 등급 분류(`LossClass`), 인코더 옵션 빌더에 대해 높은 뮤테이션 사살율(>85%)을 목표로 삼는다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 토큰 경제학과 피드백 루프 속도 (Token Economics & Velocity)
|
||||
|
||||
| 테스트 유형 | 피드백 주기 | 토큰 소모량 | 디버깅 복잡도 | 권장 비율 |
|
||||
|---|---|---|---|---|
|
||||
| **순수 C# 단위 테스트** | 0.05초 ~ 0.5초 | 0 (로컬 실행) | 매우 낮음 (원인 즉시 식별) | **최우선 (70%)** |
|
||||
| **XAML 정적 AST 감사** | 0.1초 ~ 0.3초 | 0 (로컬 파싱) | 낮음 (정확한 XAML 라인 지목) | **디자인 필수 (20%)** |
|
||||
| **STA Visual Tree 측정** | 0.5초 ~ 1.5초 | 0 (In-Memory) | 보통 (레이아웃 트리 추적) | **UI 통합 (10%)** |
|
||||
| **GUI 수동 실행 / 프로세스 폴링** | 10초 ~ 120초 | 극대 (스크린샷/로그 반복) | 극도로 높음 | **최소화 (스모크 전용)** |
|
||||
|
||||
빠른 로컬 단위/AST 테스트가 1초 내에 성공/실패 결과를 반환할 때, 에이전트의 인지 부하는 최소화되고 성공 확률은 기하급수적으로 증가한다.
|
||||
68
docs/tdd/03_DESIGN_AUDIT_TDD.md
Normal file
68
docs/tdd/03_DESIGN_AUDIT_TDD.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# 03. 초엄격 데스크톱 UI & 디자인 감사 TDD (Design Audit TDD)
|
||||
|
||||
> "A great desktop user experience is not accidental; it is mathematically verifiable through deterministic layout assertions and design token constraints."
|
||||
|
||||
이 문서는 데스크톱 WPF 애플리케이션에서 **시각적 치우침, 폰트 미가독, 컨테이너 오버플로, 인풋창 패딩 오류, 아이콘-텍스트 기준선 불일치, 끔찍한 UX 결함**을 단위 테스트 수준에서 철저하게 잡아내기 위한 **디자인 감사 TDD 규약**을 정의한다.
|
||||
|
||||
---
|
||||
|
||||
## 1. UI 테스트의 역사적 난제와 결정론적 해결책
|
||||
|
||||
### 1.1 깨지기 쉬운(Fragile) 픽셀 매칭의 한계
|
||||
* 전체 화면 캡처 후 픽셀 단위로 비교하는 방식은 Windows OS 버전, ClearType 폰트 렌더링 서브픽셀 차이, 그래픽 드라이버 안티앨리어싱 때문에 1픽셀만 달라져도 테스트가 깨지는 치명적인 플레이키(Flaky) 문제를 낳는다.
|
||||
* 이는 개발자로 하여금 UI 테스트를 무시하거나 비활성화하게 만드는 주범이다.
|
||||
|
||||
### 1.2 Everything2Everything의 3계층 디자인 감사 솔루션
|
||||
1. **계층 1: XAML 정적 AST(구문 트리) 토큰 감사**
|
||||
- 빌드된 XAML 파일을 XML DOM/AST로 파싱하여 모든 레이아웃 컨테이너, 버튼, 인풋, 텍스트블록의 속성을 검사.
|
||||
- 0.1초 만에 실행되며, 어떤 파일 몇 번째 줄에서 디자인 규약이 위반되었는지 정확히 집어냄.
|
||||
2. **계층 2: In-Memory STA Visual Tree 배치 및 경계(Bounds) 감사**
|
||||
- 백그라운드 STA 스레드에서 창과 컨트롤을 메모리상에 생성하고 `Measure()` 및 `Arrange()`를 호출.
|
||||
- 자식 요소가 부모 컨테이너의 영역을 벗어나 잘려나가는지(Clipping/Overflow), 최소 클릭 타깃 높이(32px)를 만족하는지 검증.
|
||||
3. **계층 3: 복합 사용자 유즈케이스 E2E 시나리오 감사**
|
||||
- 커맨드 바인딩, 큐 상태 전이, 텍스트 입력, 빈 상태(Empty State) 전환을 실제 데이터 흐름으로 검증.
|
||||
|
||||
---
|
||||
|
||||
## 2. 초엄격 디자인 감사 7대 원약 (Strict Design Rules)
|
||||
|
||||
### 규칙 1: 아이콘-텍스트 수직 기준선 중앙 정렬 (Vertical Alignment)
|
||||
* **결함 현상**: `StackPanel Orientation="Horizontal"` 안에 아이콘(16px~24px)과 텍스트(12px~14px)가 들어갈 때, `VerticalAlignment="Center"`를 명시하지 않으면 WPF 기본값인 `Top` 또는 `Stretch`로 인해 아이콘과 텍스트가 위아래로 어긋나 끔찍한 시각적 불균형을 초래함.
|
||||
* **감사 규칙**:
|
||||
- 가로 방향 `StackPanel` 내부에 `Image`, `Path`, `ui:SymbolIcon`, `Ellipse`(상태 점)와 `TextBlock`이 공존하는 경우, 부모 `StackPanel`에 `VerticalAlignment="Center"`가 지정되어 있거나, 내부의 모든 자식 요소에 `VerticalAlignment="Center"`가 선언되어 있어야 한다.
|
||||
|
||||
### 규칙 2: 인풋창 내부 요소 패딩 (Input Field Padding)
|
||||
* **결함 현상**: `TextBox`, `PasswordBox`, `ComboBox`의 내부 패딩이 0이거나 너무 좁으면 입력 텍스트의 글리프(g, y, p, j의 디센더 또는 대문자)가 테두리에 닿거나 왼쪽 벽에 달라붙어 극도로 답답한 인상을 줌.
|
||||
* **감사 규칙**:
|
||||
- 모든 `TextBox` 및 `PasswordBox` 스타일의 `Padding`은 **수평 최소 8px 이상, 수직 최소 4px 이상**이어야 한다. (권장: `10,8` 또는 `12,8`).
|
||||
- 좌우/상하 패딩이 기형적으로 비대칭인 값(예: `0,6` 또는 `20,1`)은 금지한다.
|
||||
|
||||
### 규칙 3: 유니코드 이모지 혼용 및 다국어 통일 (No Raw Emojis in Text)
|
||||
* **결함 현상**: `Content="⚙ 설정"`처럼 텍스트 문자열 안에 유니코드 특수문자나 이모지를 날것으로 섞으면, OS 폰트 렌더링에 따라 이모지가 흑백 글리프로 왜곡되거나 텍스트 베이스라인이 위아래로 심하게 요동침. 또한 어떤 버튼은 영문("Diagnose", "Export Log"), 어떤 버튼은 한글("설정")로 혼용되는 조악한 UX 발생.
|
||||
* **감사 규칙**:
|
||||
- 버튼이나 메뉴 항목에 유니코드 이모지(`⚙`, `📁`, `🗑`, `✖` 등)를 텍스트 문자열에 인라인으로 포함하는 것을 엄격히 금지한다.
|
||||
- 아이콘이 필요한 경우 Fluent UI의 표준 벡터 심볼(`ui:SymbolIcon`) 또는 일관된 `Path`를 분리된 엘리먼트로 배치한다.
|
||||
- 상단 네비게이션 및 액션 버튼은 한국어로 완전 통일(`설정`, `컨텍스트 메뉴 등록`, `진단`, `로그 내보내기`, `목록 비우기`)한다.
|
||||
|
||||
### 규칙 4: 컨테이너 오버플로 및 텍스트 트림 (Overflow & Clipping Safety)
|
||||
* **결함 현상**: 파일 경로, 파일명, 상태 메시지가 긴 경우 상위 컨테이너 너비를 뚫고 나가 다른 열의 버튼을 가리거나 화면 밖으로 잘려나감.
|
||||
* **감사 규칙**:
|
||||
- 동적 데이터(바인딩)를 표시하는 모든 `TextBlock`은 반드시 `TextTrimming="CharacterEllipsis"` 또는 `TextWrapping="Wrap"`을 지정해야 한다.
|
||||
- 고정 너비가 아닌 가변 너비 컬럼(`*` 컬럼) 내에서 생략 부호(`...`)가 자연스럽게 동작하도록 컨테이너 제약을 설정해야 한다.
|
||||
|
||||
### 규칙 5: 폰트 명도 대비 및 테마 가독성 (WCAG 2.2 AA Contrast)
|
||||
* **결함 현상**: 다크 모드 배경(`#090A0C`, `#131417`) 위에서 텍스트 색상이 너무 어두운 회색(`#3A3D45` 이하)이어서 글자가 전혀 보이지 않거나 가독성이 현저히 떨어짐.
|
||||
* **감사 규칙**:
|
||||
- 본문 텍스트(`FsTextPrimary`: `#ECEEFA`, 대비율 > 14:1)와 부가 텍스트(`FsTextSecondary`: `#8B909A`, 대비율 > 4.8:1)는 WCAG AA 표준(4.5:1)을 무조건 충족해야 한다.
|
||||
- 극소형 캡션(10~11px)이라도 대비율 3.0:1 미만의 브러시 사용을 금지한다.
|
||||
|
||||
### 규칙 6: 제로-보이드 빈 상태 원칙 (Zero-Void Empty State)
|
||||
* **결함 현상**: 큐나 결과 목록에 아이템이 없을 때 아무런 안내도 없이 시커먼 빈 공간만 덩그러니 노출되면, 사용자는 프로그램이 멈추었거나 고장 난 것으로 오인함.
|
||||
* **감사 규칙**:
|
||||
- `ActiveQueue`뿐만 아니라 `PastResults`(변환 이력) 화면 역시 데이터가 없을 때 표시되는 **전용 빈 상태(Empty State) 안내(일러스트 또는 아이콘 + 명확한 설명 문구)**가 반드시 존재해야 한다.
|
||||
|
||||
### 규칙 7: 8px 그리드 시스템 및 일관된 패딩 (Spacing System)
|
||||
* **결함 현상**: 마진과 패딩에 3, 7, 11, 13, 23px 등 무작위 숫자가 혼재하여 전체 인터페이스의 시각적 리듬감이 깨짐.
|
||||
* **감사 규칙**:
|
||||
- 모든 `Margin`과 `Padding`은 원칙적으로 **4px, 8px, 12px, 16px, 20px, 24px, 32px**의 4/8 배수 단위를 준수한다.
|
||||
- 형제(Sibling) 버튼 간의 크기나 패딩은 동일한 위계(Hierarchy)에서 반드시 일치해야 한다 (예: 확인/취소 버튼의 패딩 통일).
|
||||
85
docs/tdd/04_TEST_LIFECYCLE_PRUNING.md
Normal file
85
docs/tdd/04_TEST_LIFECYCLE_PRUNING.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# 04. 테스트 라이프사이클 관리: 가지치기 & 통폐합 (Test Lifecycle & Pruning)
|
||||
|
||||
> "A test suite that only grows and is never pruned will eventually collapse under its own weight, slowing down the feedback loop until developers stop running it."
|
||||
> — **Gerard Meszaros**, *xUnit Test Patterns*
|
||||
|
||||
이 문서는 테스트 스위트의 비대화(Test Sprawl)와 유지보수 비용 폭증을 방지하고, 초고속 피드백 루프(< 10초)를 영구히 유지하기 위한 **테스트 가지치기(Pruning) 및 통폐합(Consolidation) 규약**을 정의한다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 테스트 비대화(Test Sprawl)의 위협
|
||||
|
||||
### 1.1 무분별한 테스트 증식의 3대 부작용
|
||||
1. **피드백 루프 지연 (Feedback Degradation)**: 전체 테스트 실행 시간이 30초, 1분, 5분으로 늘어나면 개발자와 AI 에이전트가 코드를 수정할 때마다 테스트를 돌리지 않고 건너뛰게 됨.
|
||||
2. **리팩토링 저항성 (Refactoring Friction)**: 내부 구현 세부사항에 과도하게 결합된 수백 개의 테스트가 작은 구조 개선에도 동시에 깨져, 코드 개선 자체를 포기하게 만듦.
|
||||
3. **가짜 안전감 (False Sense of Security)**: 테스트 개수는 500개인데 실질적으로는 같은 분기를 중복 검증하거나 사소한 getter/setter만 확인하고 있어 치명적인 시스템 결함을 놓침.
|
||||
|
||||
---
|
||||
|
||||
## 2. 테스트 가지치기 원칙 (The Pruning Protocol)
|
||||
|
||||
어떤 테스트를 유지하고 어떤 테스트를 제거할 것인가? 아래 기준에 따라 정기적으로 스위트를 정제한다.
|
||||
|
||||
### 2.1 즉각 제거(Delete) 대상
|
||||
* **구현 결합 테스트 (Implementation-Coupled Tests)**:
|
||||
- 공개 계약(Public API/Output)이 아닌, 내부 private 헬퍼의 호출 여부나 로컬 변수 상태를 리플렉션으로 검증하는 테스트.
|
||||
* **중복 검증 테스트 (Redundant Tests)**:
|
||||
- 동일한 로직 분기를 거의 유사한 일반 데이터(예: "abc", "def", "xyz")로 중복 테스트하는 개별 메서드들.
|
||||
* **플레이키/타이머 테스트 (Flaky/Timing-Dependent Tests)**:
|
||||
- `Thread.Sleep()`이나 비동기 타이밍 경쟁 상태에 의존하여 가끔씩 실패하는 불안정한 테스트. 결정론적 동기화 메커니즘(`TaskCompletionSource`, 가짜 시계)으로 전환할 수 없다면 제거한다.
|
||||
* **프레임워크 자체 검증 테스트**:
|
||||
- WPF `Grid`가 2개의 행을 나누는지, .NET BCL `Dictionary`가 키를 저장하는지와 같이 프레임워크 자체의 기본 동작을 검증하는 무의미한 테스트.
|
||||
|
||||
### 2.2 회귀 방어선 (Coverage Floor)
|
||||
* 가지치기를 수행할 때 핵심 비즈니스 로직의 커버리지는 다음의 **최소 방어선(Coverage Floor)** 이하로 내려가서는 안 된다:
|
||||
- `Everything2Everything.Core.Routing` (Dijkstra 최단 경로): **95% 이상**
|
||||
- `Everything2Everything.Core.Providers` (각 포맷별 변환 옵션 및 지원 매트릭스): **90% 이상**
|
||||
- `Everything2Everything.App.Views` (XAML 디자인 AST 및 레이아웃 무결성): **100% 필수 뷰 커버리지**
|
||||
|
||||
---
|
||||
|
||||
## 3. 테스트 통폐합 기법 (Consolidation Patterns)
|
||||
|
||||
### 3.1 개별 `[Fact]`의 `[Theory]` 파라미터화 통합
|
||||
* **Bad Pattern (스위트 파편화)**:
|
||||
```csharp
|
||||
[Fact] public void PngToJpg_Supported() => Assert.True(graph.CanConvert(".png", ".jpg"));
|
||||
[Fact] public void PngToWebp_Supported() => Assert.True(graph.CanConvert(".png", ".webp"));
|
||||
[Fact] public void PngToAvif_Supported() => Assert.True(graph.CanConvert(".png", ".avif"));
|
||||
// 50개의 개별 Fact 메서드...
|
||||
```
|
||||
* **Good Pattern (파라미터화 통폐합)**:
|
||||
```csharp
|
||||
[Theory]
|
||||
[InlineData(".png", ".jpg")]
|
||||
[InlineData(".png", ".webp")]
|
||||
[InlineData(".png", ".avif")]
|
||||
[InlineData(".heic", ".png")]
|
||||
[InlineData(".docx", ".pdf")]
|
||||
public void SupportedMatrix_RoutesSuccessfully(string input, string output)
|
||||
{
|
||||
var plan = _graph.FindRoute(input, output);
|
||||
Assert.NotNull(plan);
|
||||
Assert.True(plan.Steps.Count >= 1);
|
||||
}
|
||||
```
|
||||
- 단 하나의 잘 다듬어진 테스트 본체로 수십 가지의 입출력 매트릭스를 간결하게 검증.
|
||||
- 실패 시 어떤 파라미터 조합이 실패했는지 테스트 탐색기에서 즉시 식별 가능.
|
||||
|
||||
### 3.2 사용자 시나리오 기반 통합 (User Journey Tests)
|
||||
* 사용자의 실제 행동 흐름을 하나의 완결된 시나리오로 통합하여, 10개의 파편화된 테스트보다 훨씬 강력하고 현실적인 검증을 제공한다:
|
||||
- **Step 1**: 큐에 복합 파일(이미지, 문서) 드롭
|
||||
- **Step 2**: 상호 교집합 포맷 필터링 확인
|
||||
- **Step 3**: 인코딩 옵션(품질, 비트레이트) 변경
|
||||
- **Step 4**: 변환 실행 및 프로그레스 갱신
|
||||
- **Step 5**: 결과 이력(Past Results) 생성 및 큐 비우기
|
||||
|
||||
---
|
||||
|
||||
## 4. 10초 룰 (The 10-Second Invariant)
|
||||
|
||||
* **원칙**: `dotnet test Everything2Everything.Tests.csproj`의 전체 실행 시간은 로컬 개발 머신에서 **10초 이내**여야 한다.
|
||||
* 만약 테스트 스위트 실행 시간이 10초를 초과하기 시작하면:
|
||||
1. I/O 작업(실제 대용량 파일 생성)을 메모리 스트림(`MemoryStream`)으로 전환한다.
|
||||
2. 무거운 외부 프로세스(FFmpeg, LibreOffice)를 호출하는 테스트는 스모크/인프라 카테고리(`[Trait("Category", "Smoke")]`)로 분리한다.
|
||||
3. 중복된 XAML 파싱 과정을 픽스처(`IClassFixture`)로 캐싱하여 재사용한다.
|
||||
83
docs/tdd/05_RELEASE_AND_DEPLOYMENT.md
Normal file
83
docs/tdd/05_RELEASE_AND_DEPLOYMENT.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# 05. 릴리즈 엔지니어링 & 배포 체계 (Release & Deployment to git.chanpaca.net)
|
||||
|
||||
이 문서는 **Everything2Everything**의 무결점 배포를 위한 **릴리즈 엔지니어링, 시맨틱 버전 태깅, 그리고 Forgejo(`git.chanpaca.net`) 배포 파이프라인 규약**을 정의한다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 배포 인프라 및 아키텍처 개요
|
||||
|
||||
### 1.1 대상 저장소 (Target Remote)
|
||||
* **플랫폼**: Forgejo (자체 호스팅 Git 서버)
|
||||
* **도메인**: `https://git.chanpaca.net`
|
||||
* **원격 저장소 경로**: `https://git.chanpaca.net/yunchan/Everything2Everything.git`
|
||||
* **SSH 경로**: `ssh://git@git.chanpaca.net:2222/yunchan/Everything2Everything.git`
|
||||
* **인증 방식**: Windows Credential Manager에 등록된 `yunchan` 계정 자격증명 및 API 토큰.
|
||||
|
||||
### 1.2 배포 산출물 (Dual Binary Deliverables)
|
||||
1. **Portable EXE (`publish/Everything2Everything.exe`)**:
|
||||
- .NET 9 프레임워크 의존형 x64 단일 실행 파일.
|
||||
- 우클릭 컨텍스트 메뉴 자체 등록/해제 (`.\Everything2Everything.exe register`) 지원.
|
||||
- 무설치 초경량 배포용.
|
||||
2. **MSIX 패키지 (`packaging/dist/Everything2Everything-x64.msix`)**:
|
||||
- Windows 11 Fluent 2 메인 메뉴(Shift 없이 바로 노출) 지원용 IExplorerCommand DLL 동봉 패키지.
|
||||
- 개발자 인증서 서명 패키지.
|
||||
|
||||
---
|
||||
|
||||
## 2. 릴리즈 게이트 (Release Gates)
|
||||
|
||||
배포 스크립트 실행 시 다음 4단계 게이트를 하나라도 통과하지 못하면 릴리즈는 즉각 중단(Aborted)된다:
|
||||
|
||||
```
|
||||
[ Gate 1: Test Suite 100% Pass ]
|
||||
│ dotnet test Everything2Everything.Tests.csproj (실패 0개, 경고 0개)
|
||||
▼
|
||||
[ Gate 2: Version Synchronization ]
|
||||
│ Package.appxmanifest, App.csproj, Git Tag 버전 일치 확인 (예: 1.0.6.0 <-> v1.0.6)
|
||||
▼
|
||||
[ Gate 3: Release Build & Artifacts ]
|
||||
│ dotnet publish (Release win-x64) + BuildMsix.ps1 산출물 생성 확인
|
||||
▼
|
||||
[ Gate 4: Git Tag & Forgejo Release ]
|
||||
│ git tag 생성 -> git.chanpaca.net 푸시 -> Forgejo API 릴리즈 등록 및 바이너리 첨부
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 시맨틱 버저닝 (Semantic Versioning 2.0.0)
|
||||
|
||||
모든 릴리즈 태그는 `vMAJOR.MINOR.PATCH` 형식을 엄격히 준수한다:
|
||||
* **MAJOR**: 아키텍처 전면 개편 또는 호환되지 않는 CLI/매트릭스 변경.
|
||||
* **MINOR**: 신규 변환 프로바이더 추가, 새로운 GUI 기능 추가.
|
||||
* **PATCH**: 버그 수정, 디자인/정렬 결함 패치, 성능 최적화.
|
||||
|
||||
### 버전 동기화 대상 파일
|
||||
1. `packaging/Package.appxmanifest`: `<Identity Version="1.0.6.0" ... />`
|
||||
2. `src/Everything2Everything.App/Everything2Everything.App.csproj`: `<Version>1.0.6</Version>`
|
||||
3. `docs/ssot/_data/meta.json` 또는 릴리즈 노트
|
||||
|
||||
---
|
||||
|
||||
## 4. 자동화 배포 스크립트 (`tools/Publish-Release.ps1`)
|
||||
|
||||
릴리즈 엔지니어링은 단 1줄의 PowerShell 스크립트로 전자동 수행된다:
|
||||
|
||||
```powershell
|
||||
# 사용법 예시: 버전 1.0.6 릴리즈 배포
|
||||
pwsh tools/Publish-Release.ps1 -Version "1.0.6" -ReleaseNotes "디자인 감사 TDD 적용 및 UI 수직 정렬/패딩 결함 수정"
|
||||
```
|
||||
|
||||
스크립트의 동작 절차:
|
||||
1. `git status` 작업 디렉터리 청결 상태 확인.
|
||||
2. `dotnet test` 전체 테스트 스위트 실행.
|
||||
3. 버전 번호 자동 동기화 (`1.0.6.0` / `1.0.6`).
|
||||
4. Portable EXE 빌드 및 MSIX 패키징.
|
||||
5. 로컬 커밋 및 Git 태그 `v1.0.6` 생성.
|
||||
6. `origin`(GitHub) 및 `forgejo`(`https://git.chanpaca.net/yunchan/Everything2Everything.git`) 양방향 푸시.
|
||||
7. Forgejo REST API를 호출하여 태그 기반 정식 릴리즈 생성 및 바이너리 업로드.
|
||||
|
||||
---
|
||||
|
||||
## 5. Forgejo Actions 워크플로 (`.forgejo/workflows/release.yaml`)
|
||||
|
||||
원격 서버 자체 CI/CD를 위한 워크플로를 구성하여, 태그 푸시(`v*`) 시 원격 러너에서도 자동으로 빌드와 검증이 수행되도록 보장한다.
|
||||
58
docs/tdd/README.md
Normal file
58
docs/tdd/README.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# Everything2Everything TDD Theoretical SSOT
|
||||
|
||||
이 디렉터리는 **Everything2Everything** 프로젝트의 테스트 주도 개발(TDD, Test-Driven Development)과 초엄격 디자인/UX 감사 및 릴리즈 엔지니어링의 **단일 진실 원천(Single Source of Truth, SSOT)**이다.
|
||||
|
||||
2026년 9월 기준 최신 학술 논문, 산업계 프랙티스(Agentic TDD, LLM Invariant Verification), 그리고 TDD 최고 권위자(Kent Beck, Martin Fowler, Gerard Meszaros, Michael Feathers)의 정통 이론을 집대성하여 시스템에 체화했다.
|
||||
|
||||
---
|
||||
|
||||
## 📚 목차 (Architecture & Index)
|
||||
|
||||
1. **[01. TDD 이론 캐넌 & 정통 원칙 (THEORY CANON)](01_THEORY_CANON.md)**
|
||||
- Kent Beck의 *Canonical TDD* 3대 법칙 및 *Tidy First?* 구조 분리
|
||||
- Martin Fowler의 *Practical Test Pyramid* & Mockist vs Classicist 철학
|
||||
- Gerard Meszaros의 *xUnit Test Patterns* (4단계 테스트, Fixture 관리, 18대 Test Smells)
|
||||
- Michael Feathers의 *Working Effectively with Legacy Code* (Seam과 특성화 테스트)
|
||||
|
||||
2. **[02. 2025–2026 AI AGENTIC TDD 최신 동향 (AGENTIC TDD)](02_AGENTIC_TDD.md)**
|
||||
- LLM 에이전트의 환각 및 코드 퇴행(Vibe Coding)을 막는 TDD 가드레일
|
||||
- 적대적 명세-검증 루프 (Adversarial Specification & Verification)
|
||||
- Pre-Write / Pre-Test Invariant Hooks 및 자가 치유(Fail-Guided Self-Repair)
|
||||
- 뮤테이션 테스팅(Mutation Testing)을 통한 테스트 자체의 무결성 검증
|
||||
|
||||
3. **[03. 초엄격 데스크톱 UI & 디자인 감사 TDD (DESIGN AUDIT TDD)](03_DESIGN_AUDIT_TDD.md)**
|
||||
- XAML 정적 AST 분석을 통한 디자인 토큰 및 레이아웃 결함 사전 감지
|
||||
- STA 스레드 기반 In-Memory Visual Tree 배치(Measure/Arrange) 및 오버플로 검증
|
||||
- 8px 그리드 시스템, 아이콘-텍스트 수직 기준선(Baseline) 정렬 원칙
|
||||
- WCAG 2.2 AA 기준 명도 대비, 인풋창 내부 패딩 규격, 빈 상태(Empty State) 필수 원칙
|
||||
|
||||
4. **[04. 테스트 라이프사이클 관리: 가지치기 & 통폐합 (TEST LIFECYCLE & PRUNING)](04_TEST_LIFECYCLE_PRUNING.md)**
|
||||
- 테스트 스위트 비대화(Test Sprawl) 및 플레이키(Flaky) 테스트 방지
|
||||
- Gerard Meszaros의 테스트 리팩토링 및 저가치/취약 테스트 가지치기(Pruning)
|
||||
- xUnit `[Theory]`와 파라미터화를 통한 유사 RED들의 유즈케이스 통폐합(Consolidation)
|
||||
- 10초 이내 전체 스위트 실행 원칙과 최소 회귀 방어선(Coverage Floor)
|
||||
|
||||
5. **[05. 릴리즈 엔지니어링 & 배포 체계 (RELEASE & DEPLOYMENT)](05_RELEASE_AND_DEPLOYMENT.md)**
|
||||
- Forgejo(`git.chanpaca.net`) 기반 원격 저장소 및 Actions CI/CD 워크플로
|
||||
- SemVer 2.0 시맨틱 버전 태깅 및 `Package.appxmanifest` 버전 동기화
|
||||
- MSIX 서명 패키지 및 Portable EXE 듀얼 바이너리 릴리즈 게이트
|
||||
|
||||
---
|
||||
|
||||
## 🎯 불변의 헌법: TDD 절대주의 (TDD as the Absolute Law)
|
||||
|
||||
```
|
||||
[ RED ] 실패하는 테스트를 먼저 작성한다 (정확한 실패 원인 증명)
|
||||
│
|
||||
▼
|
||||
[ GREEN ] 테스트를 통과시키는 최소한의 프로덕션 코드를 작성한다
|
||||
│
|
||||
▼
|
||||
[ REFACTOR ] 중복을 제거하고, 디자인 규격을 준수하며, 성능을 최적화한다 (스위트 전체 GREEN 유지)
|
||||
│
|
||||
▼
|
||||
[ PRUNE & CONSOLIDATE ] 비대해진 테스트를 통폐합하고 실제 유즈케이스 중심으로 정제한다
|
||||
```
|
||||
|
||||
어떠한 에이전트나 작업자도 **실패하는 테스트(RED) 없이 프로덕션 코드를 단 1줄도 수정하거나 작성할 수 없다.**
|
||||
UI 디자인, 레이아웃, 패딩, 타이포그래피, 정렬 오류 역시 모두 **자동화된 테스트 코드로 사전에 감지하고 자가 수정**한다.
|
||||
|
|
@ -1,242 +1,199 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Package
|
||||
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
||||
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
||||
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
|
||||
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
|
||||
xmlns:desktop4="http://schemas.microsoft.com/appx/manifest/desktop/windows10/4"
|
||||
xmlns:desktop5="http://schemas.microsoft.com/appx/manifest/desktop/windows10/5"
|
||||
xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10"
|
||||
IgnorableNamespaces="uap rescap desktop desktop4 desktop5 com">
|
||||
|
||||
<Identity
|
||||
Name="Everything2Everything.YunChan"
|
||||
Publisher="CN=Everything2EverythingDev"
|
||||
Version="1.0.5.0"
|
||||
ProcessorArchitecture="x64" />
|
||||
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10" xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10" xmlns:desktop4="http://schemas.microsoft.com/appx/manifest/desktop/windows10/4" xmlns:desktop5="http://schemas.microsoft.com/appx/manifest/desktop/windows10/5" xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10" IgnorableNamespaces="uap rescap desktop desktop4 desktop5 com">
|
||||
<Identity Name="Everything2Everything.YunChan" Publisher="CN=Everything2EverythingDev" Version="1.0.6.0" ProcessorArchitecture="x64" />
|
||||
<Properties>
|
||||
<DisplayName>Everything2Everything</DisplayName>
|
||||
<PublisherDisplayName>YunChan</PublisherDisplayName>
|
||||
<Logo>Assets\StoreLogo.png</Logo>
|
||||
</Properties>
|
||||
|
||||
<Dependencies>
|
||||
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.19041.0" MaxVersionTested="10.0.26220.0" />
|
||||
</Dependencies>
|
||||
|
||||
<Resources>
|
||||
<Resource Language="ko-KR" />
|
||||
<Resource Language="en-US" />
|
||||
</Resources>
|
||||
|
||||
<Applications>
|
||||
<Application Id="Everything2Everything"
|
||||
Executable="Everything2Everything.exe"
|
||||
EntryPoint="Windows.FullTrustApplication">
|
||||
<uap:VisualElements
|
||||
DisplayName="Everything2Everything"
|
||||
Description="이미지·PDF·문서·HTML 양방향 변환 매트릭스"
|
||||
BackgroundColor="transparent"
|
||||
Square150x150Logo="Assets\Square150x150Logo.png"
|
||||
Square44x44Logo="Assets\Square44x44Logo.png">
|
||||
<Application Id="Everything2Everything" Executable="Everything2Everything.exe" EntryPoint="Windows.FullTrustApplication">
|
||||
<uap:VisualElements DisplayName="Everything2Everything" Description="이미지·PDF·문서·HTML 양방향 변환 매트릭스" BackgroundColor="transparent" Square150x150Logo="Assets\Square150x150Logo.png" Square44x44Logo="Assets\Square44x44Logo.png">
|
||||
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
|
||||
</uap:VisualElements>
|
||||
|
||||
<Extensions>
|
||||
<!-- COM Surrogate Server: hosts the IExplorerCommand handlers -->
|
||||
<com:Extension Category="windows.comServer">
|
||||
<com:ComServer>
|
||||
<com:SurrogateServer DisplayName="Everything2Everything Shell Extension">
|
||||
<!-- Root cascade ("Everything2Everything으로 변환" + 11 sub-items) -->
|
||||
<com:Class Id="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"
|
||||
Path="Everything2Everything.Shell.dll"
|
||||
ThreadingModel="STA" />
|
||||
<com:Class Id="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" Path="Everything2Everything.Shell.dll" ThreadingModel="STA" />
|
||||
<!-- Legacy non-cascade verbs (kept for backward compat) -->
|
||||
<com:Class Id="801B2DD3-632C-4731-9510-AEAE09345264"
|
||||
Path="Everything2Everything.Shell.dll"
|
||||
ThreadingModel="STA" />
|
||||
<com:Class Id="CEBA1DB7-9175-4DF6-A362-490DEA49B598"
|
||||
Path="Everything2Everything.Shell.dll"
|
||||
ThreadingModel="STA" />
|
||||
<com:Class Id="801B2DD3-632C-4731-9510-AEAE09345264" Path="Everything2Everything.Shell.dll" ThreadingModel="STA" />
|
||||
<com:Class Id="CEBA1DB7-9175-4DF6-A362-490DEA49B598" Path="Everything2Everything.Shell.dll" ThreadingModel="STA" />
|
||||
</com:SurrogateServer>
|
||||
</com:ComServer>
|
||||
</com:Extension>
|
||||
|
||||
<!-- File Explorer context menu registrations -->
|
||||
<desktop4:Extension Category="windows.fileExplorerContextMenus">
|
||||
<desktop4:FileExplorerContextMenus>
|
||||
<!-- 이미지 -->
|
||||
<desktop5:ItemType Type=".png">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".jpg">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".jpeg">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".gif">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".bmp">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".tif">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".tiff">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".webp">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".avif">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".psd">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
|
||||
<!-- HEIC/HEIF -->
|
||||
<desktop5:ItemType Type=".heic">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".heif">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
|
||||
<!-- RAW -->
|
||||
<desktop5:ItemType Type=".dng">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".nef">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".cr2">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".cr3">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".arw">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".raf">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".orf">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".rw2">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".srw">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".pef">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
|
||||
<!-- PDF -->
|
||||
<desktop5:ItemType Type=".pdf">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
|
||||
<!-- DOCX -->
|
||||
<desktop5:ItemType Type=".docx">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".doc">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
|
||||
<!-- HTML -->
|
||||
<desktop5:ItemType Type=".html">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".htm">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
|
||||
<!-- HWP / HWPX -->
|
||||
<desktop5:ItemType Type=".hwp">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".hwpx">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
|
||||
<!-- 영상 -->
|
||||
<desktop5:ItemType Type=".mp4">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".webm">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".mkv">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".mov">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".avi">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
|
||||
<!-- 오디오 -->
|
||||
<desktop5:ItemType Type=".mp3">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".aac">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".m4a">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".opus">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".ogg">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".flac">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".wav">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
|
||||
<!-- 데이터 -->
|
||||
<desktop5:ItemType Type=".csv">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".json">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".xlsx">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
|
||||
<!-- 벡터 / 마크다운 / 텍스트 -->
|
||||
<desktop5:ItemType Type=".svg">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".md">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".markdown">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".txt">
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70" />
|
||||
</desktop5:ItemType>
|
||||
</desktop4:FileExplorerContextMenus>
|
||||
</desktop4:Extension>
|
||||
</Extensions>
|
||||
</Application>
|
||||
</Applications>
|
||||
|
||||
<Capabilities>
|
||||
<rescap:Capability Name="runFullTrust" />
|
||||
</Capabilities>
|
||||
|
|
|
|||
|
|
@ -97,17 +97,25 @@ public partial class App : Application
|
|||
|
||||
private async Task RunQuickAsync(IReadOnlyList<string> files, string outputExtension)
|
||||
{
|
||||
// 옵션 팝업~변환 경로 전체를 명시적 종료 모드로 먼저 감싼다.
|
||||
// 핵심(크래시 방지): 기본 OnLastWindowClose면 옵션 팝업(첫·유일 창)이 닫히는 순간 "마지막 창 종료" 규칙이
|
||||
// 발동해 앱이 종료 시퀀스에 진입하고, 직후 ShutdownMode 설정/변환이 InvalidOperationException으로 크래시한다.
|
||||
// (또한 진행 창을 닫아도 변환을 취소한 '뒤' 명시 종료해 ffmpeg 고아 프로세스를 막는다.)
|
||||
ShutdownMode = System.Windows.ShutdownMode.OnExplicitShutdown;
|
||||
|
||||
// 빠른 변환 전, 출력 형식에 맞춘 간단 옵션 팝업.
|
||||
// '자세히 옵션…'이면 풀 UI(MainWindow)로 전환, '취소'면 종료, '변환'이면 선택 옵션으로 진행.
|
||||
var optWin = new QuickOptionsWindow(outputExtension, files.Count, Settings);
|
||||
var confirmed = optWin.ShowDialog();
|
||||
if (optWin.OpenFullUi) { ShowConvertDialog(files); return; }
|
||||
if (optWin.OpenFullUi)
|
||||
{
|
||||
// 풀 UI로 전환 — 종료 책임을 메인 창 수명(OnLastWindowClose)에 되돌린다.
|
||||
ShutdownMode = System.Windows.ShutdownMode.OnLastWindowClose;
|
||||
ShowConvertDialog(files);
|
||||
return;
|
||||
}
|
||||
if (confirmed != true) { Shutdown(0); return; }
|
||||
|
||||
// 변환 경로 동안은 명시적 종료 모드 — 진행 창을 닫아도 변환을 취소(ffmpeg 종료)한 '뒤' 앱을 종료한다.
|
||||
// (기본 OnLastWindowClose면 창 닫는 즉시 종료가 시작돼 ffmpeg가 고아로 백그라운드에 남는다.)
|
||||
ShutdownMode = System.Windows.ShutdownMode.OnExplicitShutdown;
|
||||
|
||||
var logPath = Path.Combine(Path.GetTempPath(), "Everything2Everything_quick.log");
|
||||
var log = new System.Text.StringBuilder();
|
||||
log.AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Quick start → {outputExtension}, {files.Count} file(s)");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<Version>1.0.6</Version>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net9.0-windows10.0.19041.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
|
|
@ -35,3 +36,4 @@
|
|||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
|
|
|
|||
|
|
@ -118,6 +118,17 @@
|
|||
<Setter Property="Foreground" Value="{StaticResource FsTextSecondary}"/>
|
||||
</Style>
|
||||
|
||||
<!-- Fluent typography aliases (Used across dialogs) -->
|
||||
<Style x:Key="TextSubtitle" TargetType="TextBlock" BasedOn="{StaticResource FsBodyStyle}">
|
||||
<Setter Property="FontSize" Value="16"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
</Style>
|
||||
<Style x:Key="TextCaption" TargetType="TextBlock" BasedOn="{StaticResource FsCaptionStyle}"/>
|
||||
<Style x:Key="TextBody" TargetType="TextBlock" BasedOn="{StaticResource FsBodyStyle}"/>
|
||||
<Style x:Key="TextBodyStrong" TargetType="TextBlock" BasedOn="{StaticResource FsBodyStyle}">
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
</Style>
|
||||
|
||||
<!-- Slider style: 2px track, 14px round thumb, blue fill up to value -->
|
||||
<Style x:Key="FsSliderStyle" TargetType="Slider">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@
|
|||
|
||||
<!-- AI Task -->
|
||||
<StackPanel x:Name="AiTaskPanel" Margin="0,0,0,32">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Image Width="15" Height="15" VerticalAlignment="Center" Margin="0,0,6,0"
|
||||
RenderOptions.BitmapScalingMode="HighQuality"
|
||||
Source="pack://application:,,,/Assets/glyph-ai.png"/>
|
||||
|
|
@ -504,16 +504,17 @@
|
|||
Background="{StaticResource FsBgPanel}"
|
||||
BorderBrush="{StaticResource FsBorderSubtle}"
|
||||
BorderThickness="1" CornerRadius="20" Padding="4">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<ToggleButton x:Name="TabActiveBtn"
|
||||
Style="{StaticResource FsTabPillStyle}"
|
||||
Command="{Binding TabCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="Active">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="Active Queue" VerticalAlignment="Center"/>
|
||||
<Border Margin="8,0,0,0" Style="{StaticResource FsTabBadgeStyle}">
|
||||
<Border Margin="8,0,0,0" Style="{StaticResource FsTabBadgeStyle}" VerticalAlignment="Center">
|
||||
<TextBlock x:Name="TabActiveBadge" Text="0"
|
||||
FontSize="10" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{StaticResource FsTextTertiary}"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
|
@ -523,11 +524,12 @@
|
|||
Style="{StaticResource FsTabPillStyle}"
|
||||
Command="{Binding TabCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="Past">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="Past Results" VerticalAlignment="Center"/>
|
||||
<Border Margin="8,0,0,0" Style="{StaticResource FsTabBadgeStyle}">
|
||||
<Border Margin="8,0,0,0" Style="{StaticResource FsTabBadgeStyle}" VerticalAlignment="Center">
|
||||
<TextBlock x:Name="TabPastBadge" Text="0"
|
||||
FontSize="10" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{StaticResource FsTextTertiary}"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
|
@ -536,22 +538,47 @@
|
|||
</Border>
|
||||
|
||||
<!-- Actions -->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
||||
<Button Content="⚙ 설정" Margin="0,0,8,0"
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Button Margin="0,0,8,0"
|
||||
Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Command="{Binding SettingsCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||
<Button Content="Register Menu" Margin="0,0,8,0"
|
||||
Command="{Binding SettingsCommand, RelativeSource={RelativeSource AncestorType=Window}}">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<ui:SymbolIcon Symbol="Settings24" FontSize="14" VerticalAlignment="Center" Margin="0,0,6,0"/>
|
||||
<TextBlock Text="설정" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Margin="0,0,8,0"
|
||||
Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Command="{Binding RegisterCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||
<Button Content="Diagnose" Margin="0,0,8,0"
|
||||
Command="{Binding RegisterCommand, RelativeSource={RelativeSource AncestorType=Window}}">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<ui:SymbolIcon Symbol="AppsAddIn24" FontSize="14" VerticalAlignment="Center" Margin="0,0,6,0"/>
|
||||
<TextBlock Text="우클릭 메뉴 등록" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Margin="0,0,8,0"
|
||||
Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Command="{Binding DiagnoseCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||
<Button Content="Export Log" Margin="0,0,8,0"
|
||||
Command="{Binding DiagnoseCommand, RelativeSource={RelativeSource AncestorType=Window}}">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<ui:SymbolIcon Symbol="Stethoscope24" FontSize="14" VerticalAlignment="Center" Margin="0,0,6,0"/>
|
||||
<TextBlock Text="진단" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Margin="0,0,8,0"
|
||||
Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Command="{Binding ExportLogCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||
<Button x:Name="ClearAllButton" Content="Clear All"
|
||||
Command="{Binding ExportLogCommand, RelativeSource={RelativeSource AncestorType=Window}}">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<ui:SymbolIcon Symbol="ArrowDownload24" FontSize="14" VerticalAlignment="Center" Margin="0,0,6,0"/>
|
||||
<TextBlock Text="로그 내보내기" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button x:Name="ClearAllButton" Margin="0,0,8,0"
|
||||
Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Command="{Binding ClearAllCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||
Command="{Binding ClearAllCommand, RelativeSource={RelativeSource AncestorType=Window}}">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<ui:SymbolIcon Symbol="Delete24" FontSize="14" VerticalAlignment="Center" Margin="0,0,6,0"/>
|
||||
<TextBlock Text="목록 비우기" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
|
@ -700,9 +727,35 @@
|
|||
</Grid>
|
||||
|
||||
<!-- Past Results view (default) -->
|
||||
<ScrollViewer x:Name="PastResultsView" VerticalScrollBarVisibility="Auto"
|
||||
Padding="24">
|
||||
<ItemsControl x:Name="PastResultsList"
|
||||
<Grid x:Name="PastResultsContainer">
|
||||
<!-- 빈 상태 안내 (Empty State) -->
|
||||
<Grid x:Name="PastResultsEmpty" Visibility="Collapsed">
|
||||
<Border Background="{StaticResource FsBgBase}" Padding="32">
|
||||
<Grid MaxWidth="440" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<Rectangle RadiusX="10" RadiusY="10"
|
||||
Stroke="{StaticResource FsBorderHairline}"
|
||||
StrokeThickness="1.25"
|
||||
StrokeDashArray="5 4"
|
||||
Fill="#04FFFFFF"/>
|
||||
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Margin="48,52">
|
||||
<ui:SymbolIcon Symbol="History24" FontSize="48"
|
||||
Foreground="{StaticResource FsTextTertiary}"
|
||||
HorizontalAlignment="Center"/>
|
||||
<TextBlock Margin="0,16,0,0" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource FsTextPrimary}"
|
||||
HorizontalAlignment="Center"
|
||||
Text="변환 이력이 없습니다"/>
|
||||
<TextBlock Margin="0,6,0,0" Style="{StaticResource FsCaptionStyle}"
|
||||
HorizontalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
Text="파일을 변환하면 완료된 내역과 절감된 용량이 여기에 기록됩니다"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
<ScrollViewer x:Name="PastResultsView" VerticalScrollBarVisibility="Auto"
|
||||
Padding="24">
|
||||
<ItemsControl x:Name="PastResultsList"
|
||||
ItemsSource="{Binding PastResults, RelativeSource={RelativeSource AncestorType=Window}}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
|
|
@ -813,6 +866,7 @@
|
|||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
<!-- /좌측 컬럼 끝 -->
|
||||
|
|
|
|||
|
|
@ -171,7 +171,8 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
|||
TabPastBtn.IsChecked = tag == "Past";
|
||||
|
||||
ActiveQueueView.Visibility = tag == "Active" ? Visibility.Visible : Visibility.Collapsed;
|
||||
PastResultsView.Visibility = tag == "Past" ? Visibility.Visible : Visibility.Collapsed;
|
||||
PastResultsContainer.Visibility = tag == "Past" ? Visibility.Visible : Visibility.Collapsed;
|
||||
UpdatePastResultsVisibility();
|
||||
}
|
||||
|
||||
// ============== Drag & Drop ==============
|
||||
|
|
@ -247,11 +248,19 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
|||
ActiveQueueScroll.Visibility = hasItems ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
|
||||
private void UpdatePastResultsVisibility()
|
||||
{
|
||||
var hasItems = _pastResults.Count > 0;
|
||||
PastResultsEmpty.Visibility = hasItems ? Visibility.Collapsed : Visibility.Visible;
|
||||
PastResultsView.Visibility = hasItems ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
|
||||
private void UpdateBadges()
|
||||
{
|
||||
TabActiveBadge.Text = _activeQueue.Count.ToString(CultureInfo.InvariantCulture);
|
||||
var count = _pastResults.Sum(g => g.Entries.Count);
|
||||
TabPastBadge.Text = count.ToString(CultureInfo.InvariantCulture);
|
||||
UpdatePastResultsVisibility();
|
||||
}
|
||||
|
||||
private void UpdateProcessQueueButton()
|
||||
|
|
|
|||
|
|
@ -10,6 +10,13 @@
|
|||
WindowStartupLocation="CenterScreen"
|
||||
ResizeMode="NoResize"
|
||||
Closing="OnWindowClosing">
|
||||
<Window.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="FormatShiftTheme.xaml"/>
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Window.Resources>
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
|
|
|
|||
|
|
@ -6,9 +6,17 @@
|
|||
WindowStartupLocation="CenterOwner"
|
||||
ExtendsContentIntoTitleBar="True"
|
||||
WindowBackdropType="Mica"
|
||||
Background="{StaticResource FsBgBase}">
|
||||
Background="{DynamicResource FsBgBase}">
|
||||
|
||||
<Grid>
|
||||
<Window.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="FormatShiftTheme.xaml"/>
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid Background="{StaticResource FsBgBase}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
|
|
@ -51,10 +59,10 @@
|
|||
Style="{StaticResource FsSecondaryButtonStyle}" Margin="8,0,0,0"
|
||||
IsEnabled="False" Click="OnVerifyOpenAi"/>
|
||||
</Grid>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,0,16">
|
||||
<Ellipse x:Name="OpenAiDot" Style="{StaticResource FsLossDotStyle}"
|
||||
Fill="{StaticResource FsTextTertiary}" Margin="0,0,7,0"/>
|
||||
<TextBlock x:Name="OpenAiStatus" Text="키 미설정" Style="{StaticResource FsCaptionStyle}"/>
|
||||
Fill="{StaticResource FsTextTertiary}" Margin="0,0,7,0" VerticalAlignment="Center"/>
|
||||
<TextBlock x:Name="OpenAiStatus" Text="키 미설정" Style="{StaticResource FsCaptionStyle}" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Anthropic -->
|
||||
|
|
@ -68,23 +76,23 @@
|
|||
Style="{StaticResource FsPasswordInputStyle}"
|
||||
PasswordChanged="OnAnthropicKeyChanged"/>
|
||||
<Button x:Name="AnthropicVerifyBtn" Grid.Column="1" Content="확인"
|
||||
Style="{StaticResource FsSecondaryButtonStyle}" Margin="8,0,0,0"
|
||||
IsEnabled="False" Click="OnVerifyAnthropic"/>
|
||||
Style="{StaticResource FsSecondaryButtonStyle}" Margin="8,0,0,0"
|
||||
IsEnabled="False" Click="OnVerifyAnthropic"/>
|
||||
</Grid>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,0,16">
|
||||
<Ellipse x:Name="AnthropicDot" Style="{StaticResource FsLossDotStyle}"
|
||||
Fill="{StaticResource FsTextTertiary}" Margin="0,0,7,0"/>
|
||||
<TextBlock x:Name="AnthropicStatus" Text="키 미설정" Style="{StaticResource FsCaptionStyle}"/>
|
||||
Fill="{StaticResource FsTextTertiary}" Margin="0,0,7,0" VerticalAlignment="Center"/>
|
||||
<TextBlock x:Name="AnthropicStatus" Text="키 미설정" Style="{StaticResource FsCaptionStyle}" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Codex CLI (OAuth) -->
|
||||
<TextBlock Text="Codex CLI (OAuth · API 키 불필요)" Style="{StaticResource FsLabelStyle}" Margin="0,0,0,6"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,0,16">
|
||||
<Ellipse x:Name="CodexDot" Style="{StaticResource FsLossDotStyle}"
|
||||
Fill="{StaticResource FsTextTertiary}" Margin="0,0,7,0"/>
|
||||
Fill="{StaticResource FsTextTertiary}" Margin="0,0,7,0" VerticalAlignment="Center"/>
|
||||
<TextBlock x:Name="CodexStatus" Text="확인 중…" Style="{StaticResource FsCaptionStyle}" VerticalAlignment="Center"/>
|
||||
<Button x:Name="CodexVerifyBtn" Content="테스트" Style="{StaticResource FsSecondaryButtonStyle}"
|
||||
Margin="12,0,0,0" Padding="10,4" IsEnabled="False" Click="OnVerifyCodex"/>
|
||||
Margin="12,0,0,0" Padding="10,4" IsEnabled="False" Click="OnVerifyCodex" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 모델 -->
|
||||
|
|
@ -113,10 +121,10 @@
|
|||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Ellipse x:Name="FfmpegDot" Style="{StaticResource FsLossDotStyle}"
|
||||
Fill="{StaticResource FsStatusWarn}" Margin="0,0,7,0"/>
|
||||
<TextBlock Text="FFmpeg" Style="{StaticResource FsBodyStyle}" FontWeight="Medium"/>
|
||||
Fill="{StaticResource FsStatusWarn}" Margin="0,0,7,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="FFmpeg" Style="{StaticResource FsBodyStyle}" FontWeight="Medium" VerticalAlignment="Center"/>
|
||||
<TextBlock x:Name="FfmpegStatus" Text="미설치" Style="{StaticResource FsCaptionStyle}" Margin="8,0,0,0" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="영상/오디오 변환 (mp4·webm·mp3·flac…)" Style="{StaticResource FsCaptionStyle}" Margin="15,3,0,0"/>
|
||||
|
|
@ -134,10 +142,10 @@
|
|||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Ellipse x:Name="LibreDot" Style="{StaticResource FsLossDotStyle}"
|
||||
Fill="{StaticResource FsStatusWarn}" Margin="0,0,7,0"/>
|
||||
<TextBlock Text="LibreOffice" Style="{StaticResource FsBodyStyle}" FontWeight="Medium"/>
|
||||
Fill="{StaticResource FsStatusWarn}" Margin="0,0,7,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="LibreOffice" Style="{StaticResource FsBodyStyle}" FontWeight="Medium" VerticalAlignment="Center"/>
|
||||
<TextBlock x:Name="LibreStatus" Text="미설치" Style="{StaticResource FsCaptionStyle}" Margin="8,0,0,0" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="한글/Word/문서 변환 (HWP·DOCX→PDF/이미지). 한글은 H2Orestart 확장 필요." Style="{StaticResource FsCaptionStyle}" Margin="15,3,0,0" TextWrapping="Wrap"/>
|
||||
|
|
@ -155,9 +163,9 @@
|
|||
<!-- ===== 푸터 ===== -->
|
||||
<Border Grid.Row="2" Background="{StaticResource FsBgPanel}"
|
||||
BorderBrush="{StaticResource FsBorderSubtle}" BorderThickness="0,1,0,0" Padding="24,14">
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Button Content="닫기" Style="{StaticResource FsSecondaryButtonStyle}" Padding="18,8" Click="OnClose"/>
|
||||
<Button Content="저장" Style="{StaticResource FsPrimaryButtonStyle}" Padding="22,8" Margin="8,0,0,0" Click="OnSave"/>
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
|
||||
<Button Content="닫기" Style="{StaticResource FsSecondaryButtonStyle}" Padding="20,8" Click="OnClose"/>
|
||||
<Button Content="저장" Style="{StaticResource FsPrimaryButtonStyle}" Padding="20,8" Margin="8,0,0,0" Click="OnSave"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
|
|
|||
|
|
@ -71,6 +71,12 @@ public sealed record ConvertOptions
|
|||
/// <summary>독립(Independent) 배치 변환의 최대 병렬 수. 기본 = 논리 코어 수. 미디어(FFmpeg) 위주 배치는 낮춰 오버서브스크립션 회피.</summary>
|
||||
public int BatchParallelism { get; init; } = Environment.ProcessorCount;
|
||||
|
||||
/// <summary>
|
||||
/// LibreOffice(soffice) 변환 1건의 타임아웃(초). 초과 시 프로세스 트리를 강제 종료해 hang을 회수한다.
|
||||
/// 특정 HWP/문서에서 soffice가 무한 대기하는 사례를 방지(기본 120초). 큰 문서가 많으면 늘린다.
|
||||
/// </summary>
|
||||
public int LibreOfficeTimeoutSeconds { get; init; } = 120;
|
||||
|
||||
/// <summary>영상 인코딩 시 GPU 하드웨어 가속(NVENC)을 우선 시도하고, 실패하면 CPU로 자동 폴백한다.</summary>
|
||||
public bool VideoPreferGpu { get; init; } = true;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using Everything2Everything.Core.Providers;
|
||||
using Markdig;
|
||||
|
|
@ -241,37 +240,10 @@ public sealed class DocumentProvider : IConverterProvider
|
|||
throw new InvalidOperationException("LibreOffice를 찾을 수 없습니다.");
|
||||
|
||||
var outDir = Path.GetDirectoryName(Path.GetFullPath(targetPath))!;
|
||||
Directory.CreateDirectory(outDir);
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = soffice,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
psi.ArgumentList.Add("--headless");
|
||||
psi.ArgumentList.Add("--norestore");
|
||||
psi.ArgumentList.Add("--nofirststartwizard");
|
||||
psi.ArgumentList.Add("--convert-to");
|
||||
psi.ArgumentList.Add(outputFormat);
|
||||
psi.ArgumentList.Add("--outdir");
|
||||
psi.ArgumentList.Add(outDir);
|
||||
psi.ArgumentList.Add(sourcePath);
|
||||
|
||||
using var proc = Process.Start(psi)
|
||||
?? throw new InvalidOperationException("LibreOffice 프로세스 시작 실패");
|
||||
|
||||
try { await proc.WaitForExitAsync(ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { try { proc.Kill(true); } catch { } throw; }
|
||||
|
||||
if (proc.ExitCode != 0)
|
||||
throw new InvalidOperationException($"LibreOffice 변환 실패 (exit {proc.ExitCode})");
|
||||
|
||||
var produced = Path.Combine(outDir, Path.GetFileNameWithoutExtension(sourcePath) + "." + outputFormat);
|
||||
if (!File.Exists(produced))
|
||||
throw new FileNotFoundException("LibreOffice가 결과물을 생성하지 않았습니다.", produced);
|
||||
// soffice 호출 직렬화(기본 프로필 락 충돌 방지) + 타임아웃/트리 kill + 출력 검증은 LibreOfficeRunner가 담당.
|
||||
var produced = await LibreOfficeRunner.ConvertAsync(
|
||||
soffice, sourcePath, outDir, outputFormat, LibreOfficeRunner.DefaultTimeoutSeconds, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!string.Equals(produced, targetPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
using System.Diagnostics;
|
||||
using Everything2Everything.Core.Providers;
|
||||
|
||||
namespace Everything2Everything.Core.Converters;
|
||||
|
|
@ -68,19 +67,28 @@ public sealed class HwpxProvider : IConverterProvider
|
|||
return ConvertResult.Fail(sourcePath, "LibreOffice가 필요합니다.");
|
||||
|
||||
var outExt = ConversionPair.Normalize(outputExtension);
|
||||
var tempPdf = Path.Combine(Path.GetTempPath(),
|
||||
$"e2e_{Guid.NewGuid():N}_{Path.GetFileNameWithoutExtension(sourcePath)}.pdf");
|
||||
// 변환마다 고유 작업폴더 — soffice 출력 파일명(입력 베이스명)이 다른 변환과 outdir에서 충돌하지 않게.
|
||||
var workDir = Path.Combine(Path.GetTempPath(), $"e2e_hwp_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(workDir);
|
||||
|
||||
try
|
||||
{
|
||||
progress?.Report(0.05);
|
||||
|
||||
var converted = await ConvertWithLibreOfficeAsync(soffice, sourcePath, tempPdf, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!converted)
|
||||
string producedPdf;
|
||||
try
|
||||
{
|
||||
// soffice 호출 직렬화 + 타임아웃/프로세스 트리 kill + 출력 검증은 LibreOfficeRunner가 담당한다.
|
||||
producedPdf = await LibreOfficeRunner.ConvertAsync(
|
||||
soffice, sourcePath, workDir, "pdf", options.LibreOfficeTimeoutSeconds, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ConvertResult.Fail(sourcePath,
|
||||
"LibreOffice 변환에 실패했습니다. H2Orestart 확장이 정상 설치되어 있는지 확인하세요.");
|
||||
"LibreOffice 변환에 실패했습니다. H2Orestart 확장과 Java(JRE)가 정상 설치되어 있는지 확인하세요. " + ex.Message, ex);
|
||||
}
|
||||
|
||||
progress?.Report(0.55);
|
||||
|
||||
|
|
@ -90,64 +98,18 @@ public sealed class HwpxProvider : IConverterProvider
|
|||
var finalPath = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, ".pdf", options.OnCollision);
|
||||
if (OutputPathHelper.ShouldSkip(finalPath, options.OnCollision))
|
||||
return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
|
||||
File.Copy(tempPdf, finalPath, overwrite: options.OnCollision == NameCollision.Overwrite);
|
||||
File.Copy(producedPdf, finalPath, overwrite: options.OnCollision == NameCollision.Overwrite);
|
||||
progress?.Report(1.0);
|
||||
return ConvertResult.Ok(sourcePath, new[] { finalPath });
|
||||
}
|
||||
|
||||
var inner = new Progress<double>(p => progress?.Report(0.55 + p * 0.45));
|
||||
return _pdfProvider.ConvertCore(tempPdf, outputDirectory, outExt, options, inner, cancellationToken)
|
||||
return _pdfProvider.ConvertCore(producedPdf, outputDirectory, outExt, options, inner, cancellationToken)
|
||||
with { SourcePath = sourcePath };
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (File.Exists(tempPdf)) File.Delete(tempPdf); } catch { }
|
||||
try { Directory.Delete(workDir, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> ConvertWithLibreOfficeAsync(string sofficePath, string sourcePath, string targetPdf, CancellationToken ct)
|
||||
{
|
||||
var outDir = Path.GetDirectoryName(targetPdf)!;
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = sofficePath,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
psi.ArgumentList.Add("--headless");
|
||||
psi.ArgumentList.Add("--norestore");
|
||||
psi.ArgumentList.Add("--nofirststartwizard");
|
||||
psi.ArgumentList.Add("--convert-to");
|
||||
psi.ArgumentList.Add("pdf");
|
||||
psi.ArgumentList.Add("--outdir");
|
||||
psi.ArgumentList.Add(outDir);
|
||||
psi.ArgumentList.Add(sourcePath);
|
||||
|
||||
using var proc = Process.Start(psi);
|
||||
if (proc is null) return false;
|
||||
|
||||
try
|
||||
{
|
||||
await proc.WaitForExitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
try { proc.Kill(true); } catch { }
|
||||
throw;
|
||||
}
|
||||
|
||||
if (proc.ExitCode != 0) return false;
|
||||
|
||||
var produced = Path.Combine(outDir, Path.GetFileNameWithoutExtension(sourcePath) + ".pdf");
|
||||
if (!File.Exists(produced)) return false;
|
||||
|
||||
if (!string.Equals(produced, targetPdf, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (File.Exists(targetPdf)) File.Delete(targetPdf);
|
||||
File.Move(produced, targetPdf);
|
||||
}
|
||||
return File.Exists(targetPdf);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
119
src/Everything2Everything.Core/Converters/LibreOfficeRunner.cs
Normal file
119
src/Everything2Everything.Core/Converters/LibreOfficeRunner.cs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
using System.Diagnostics;
|
||||
|
||||
namespace Everything2Everything.Core.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// soffice(LibreOffice) <c>--headless --convert-to</c> 호출을 단일 지점으로 집약한다.
|
||||
/// HWP/HWPX 등 모든 LibreOffice 경유 변환(HwpxProvider·DocumentProvider)이 이 헬퍼를 통과한다.
|
||||
///
|
||||
/// 2026 리서치·적대적 검증으로 확정된 두 결함을 한 곳에서 방어한다:
|
||||
/// <list type="number">
|
||||
/// <item><b>직렬화 게이트</b> — LibreOffice는 프로필당 단일 인스턴스 설계(~.lock)다. 기본 프로필을 공유한 채
|
||||
/// 여러 soffice를 동시에 spawn하면 둘째 이후 프로세스가 첫 인스턴스에 위임되어 조용히 실패/멈춘다
|
||||
/// (freedesktop Bug 106134/82775). 정적 <see cref="SemaphoreSlim"/>으로 soffice 호출을 직렬화해 락 충돌을 0으로 만든다.
|
||||
/// (이미지 등 다른 Provider의 병렬성은 이 경로를 통과하지 않으므로 영향받지 않는다.)</item>
|
||||
/// <item><b>타임아웃 + 프로세스 트리 kill</b> — 특정 문서에서 soffice가 무한 hang하는 사례가 다수 보고된다.
|
||||
/// 타임아웃 초과 시 <see cref="Process.Kill(bool)"/>로 자식까지 종료해 배치 전체가 멈추는 사고를 회수한다.</item>
|
||||
/// </list>
|
||||
/// 성공은 종료코드뿐 아니라 <b>출력 파일 존재</b>로 검증한다(조용한 스킵이 잘못된 결과로 둔갑하지 않게).
|
||||
/// </summary>
|
||||
internal static class LibreOfficeRunner
|
||||
{
|
||||
// 기본 프로필 락 충돌 방지: soffice 호출을 프로세스 전역에서 직렬화한다.
|
||||
private static readonly SemaphoreSlim Gate = new(1, 1);
|
||||
|
||||
public const int DefaultTimeoutSeconds = 120;
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="sourcePath"/>를 <paramref name="outputFormat"/>(예: "pdf", "html", "docx", "txt")으로
|
||||
/// 변환해 <paramref name="outDir"/>에 쓰고, 생성된 결과 파일의 전체 경로를 반환한다.
|
||||
/// 결과 파일명은 LibreOffice 규칙상 <c>{입력 베이스명}.{outputFormat}</c>이다. 실패 시 예외를 던진다.
|
||||
/// 출력 파일명 충돌을 피하려면 호출측이 변환마다 고유한 <paramref name="outDir"/>를 넘길 것.
|
||||
/// </summary>
|
||||
public static async Task<string> ConvertAsync(
|
||||
string sofficePath,
|
||||
string sourcePath,
|
||||
string outDir,
|
||||
string outputFormat,
|
||||
int timeoutSeconds,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Directory.CreateDirectory(outDir);
|
||||
var seconds = timeoutSeconds <= 0 ? DefaultTimeoutSeconds : timeoutSeconds;
|
||||
|
||||
await Gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = sofficePath,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
psi.ArgumentList.Add("--headless");
|
||||
psi.ArgumentList.Add("--norestore");
|
||||
psi.ArgumentList.Add("--nofirststartwizard");
|
||||
psi.ArgumentList.Add("--convert-to");
|
||||
psi.ArgumentList.Add(outputFormat);
|
||||
psi.ArgumentList.Add("--outdir");
|
||||
psi.ArgumentList.Add(outDir);
|
||||
psi.ArgumentList.Add(sourcePath);
|
||||
|
||||
using var proc = Process.Start(psi)
|
||||
?? throw new InvalidOperationException("LibreOffice 프로세스를 시작하지 못했습니다.");
|
||||
|
||||
// 파이프 버퍼가 가득 차 soffice가 블록되는 것을 막기 위해 두 스트림을 비동기로 비운다.
|
||||
// (프로세스가 종료/강제종료되면 스트림 EOF로 두 태스크 모두 완료된다.)
|
||||
var stdErrTask = proc.StandardError.ReadToEndAsync();
|
||||
var stdOutTask = proc.StandardOutput.ReadToEndAsync();
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(seconds));
|
||||
|
||||
try
|
||||
{
|
||||
await proc.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
try { proc.Kill(entireProcessTree: true); } catch { /* 이미 종료됨 */ }
|
||||
if (ct.IsCancellationRequested)
|
||||
throw; // 사용자 취소 — 위로 전파
|
||||
throw new TimeoutException(
|
||||
$"LibreOffice 변환이 {seconds}초를 초과해 중단했습니다: {Path.GetFileName(sourcePath)}");
|
||||
}
|
||||
|
||||
if (proc.ExitCode != 0)
|
||||
{
|
||||
var err = await SafeReadAsync(stdErrTask).ConfigureAwait(false);
|
||||
var detail = string.IsNullOrWhiteSpace(err) ? "" : " " + err.Trim();
|
||||
throw new InvalidOperationException(
|
||||
$"LibreOffice 변환 실패 (exit {proc.ExitCode}).{detail}");
|
||||
}
|
||||
|
||||
// 출력 스트림은 정상 경로에서 굳이 쓰지 않지만, 버퍼가 비워지도록 마저 완료시킨다.
|
||||
_ = await SafeReadAsync(stdOutTask).ConfigureAwait(false);
|
||||
|
||||
var produced = Path.Combine(outDir,
|
||||
Path.GetFileNameWithoutExtension(sourcePath) + "." + outputFormat);
|
||||
if (!File.Exists(produced))
|
||||
throw new FileNotFoundException(
|
||||
"LibreOffice가 결과물을 생성하지 않았습니다. 한글 입력이면 H2Orestart 확장과 Java(JRE) 설치를 확인하세요.",
|
||||
produced);
|
||||
|
||||
return produced;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> SafeReadAsync(Task<string> readTask)
|
||||
{
|
||||
try { return await readTask.ConfigureAwait(false); }
|
||||
catch { return ""; }
|
||||
}
|
||||
}
|
||||
230
src/Everything2Everything.Tests/DesignAuditAstTests.cs
Normal file
230
src/Everything2Everything.Tests/DesignAuditAstTests.cs
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Xml.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace Everything2Everything.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 초엄격 디자인 & UX 감사 TDD 테스트 스위트 (XAML 정적 AST 분석)
|
||||
/// UI에 시각적 치우침, 폰트 미가독, 컨테이너 오버플로, 인풋 패딩 오류, 아이콘-텍스트 수직 불일치, 이모지 혼용이 없는지 정밀 검증한다.
|
||||
/// </summary>
|
||||
public class DesignAuditAstTests
|
||||
{
|
||||
private static readonly string SolutionRoot = FindSolutionRoot();
|
||||
private static readonly string ViewsDir = Path.Combine(SolutionRoot, "src", "Everything2Everything.App", "Views");
|
||||
|
||||
private static string FindSolutionRoot()
|
||||
{
|
||||
var dir = Directory.GetCurrentDirectory();
|
||||
while (dir != null && !File.Exists(Path.Combine(dir, "Everything2Everything.slnx")))
|
||||
{
|
||||
dir = Directory.GetParent(dir)?.FullName;
|
||||
}
|
||||
return dir ?? throw new DirectoryNotFoundException("솔루션 루트를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetXamlFiles()
|
||||
{
|
||||
return Directory.GetFiles(ViewsDir, "*.xaml", SearchOption.AllDirectories);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllXamlFiles_ExistAndAreWellFormedXml()
|
||||
{
|
||||
var files = GetXamlFiles().ToList();
|
||||
Assert.NotEmpty(files);
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
var content = File.ReadAllText(file);
|
||||
var doc = XDocument.Parse(content);
|
||||
Assert.NotNull(doc.Root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ButtonContent_MustNotContainRawUnicodeEmojis()
|
||||
{
|
||||
// ⚙, 📁, 🗑, ✖, 🔧 등의 유니코드 이모지가 Button Content 속성에 날것으로 들어있으면 안 된다.
|
||||
// Fluent SymbolIcon이나 분리된 벡터 Path를 사용해야 베이스라인이 깨지지 않는다.
|
||||
var rawEmojis = new[] { "⚙", "📁", "🗑", "✖", "🔧", "✨", "🚀", "⚡" };
|
||||
var violations = new List<string>();
|
||||
|
||||
foreach (var file in GetXamlFiles())
|
||||
{
|
||||
var doc = XDocument.Parse(File.ReadAllText(file));
|
||||
var buttons = doc.Descendants().Where(e => e.Name.LocalName == "Button" || e.Name.LocalName == "ToggleButton");
|
||||
|
||||
foreach (var btn in buttons)
|
||||
{
|
||||
var content = btn.Attribute("Content")?.Value;
|
||||
if (!string.IsNullOrEmpty(content))
|
||||
{
|
||||
foreach (var emoji in rawEmojis)
|
||||
{
|
||||
if (content.Contains(emoji))
|
||||
{
|
||||
var fileName = Path.GetFileName(file);
|
||||
violations.Add($"[{fileName}] 버튼 Content에 날것의 이모지 '{emoji}' 발견: \"{content}\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(violations.Count == 0,
|
||||
$"유니코드 이모지 혼용 버튼이 발견되었습니다 (Fluent SymbolIcon 또는 분리된 Path로 수정 필요):\n" +
|
||||
string.Join("\n", violations));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HorizontalStackPanels_WithIconAndText_MustHaveVerticalAlignmentCenter()
|
||||
{
|
||||
// Horizontal StackPanel 안에 Icon/Image/Path/Symbol/Ellipse 와 TextBlock 이 함께 들어갈 때,
|
||||
// 부모 StackPanel 또는 자식 요소들에 VerticalAlignment="Center" 가 누락되면 아이콘과 텍스트가 위아래로 어긋난다.
|
||||
var violations = new List<string>();
|
||||
|
||||
foreach (var file in GetXamlFiles())
|
||||
{
|
||||
var fileName = Path.GetFileName(file);
|
||||
var doc = XDocument.Parse(File.ReadAllText(file));
|
||||
var stackPanels = doc.Descendants().Where(e => e.Name.LocalName == "StackPanel" &&
|
||||
e.Attribute("Orientation")?.Value == "Horizontal");
|
||||
|
||||
foreach (var sp in stackPanels)
|
||||
{
|
||||
var children = sp.Elements().ToList();
|
||||
bool hasIcon = children.Any(c => c.Name.LocalName is "Image" or "Path" or "SymbolIcon" or "Ellipse" or "Border");
|
||||
bool hasText = children.Any(c => c.Name.LocalName == "TextBlock");
|
||||
|
||||
if (hasIcon && hasText)
|
||||
{
|
||||
string? parentVAlign = sp.Attribute("VerticalAlignment")?.Value;
|
||||
bool allChildrenCentered = children.All(c => c.Attribute("VerticalAlignment")?.Value == "Center");
|
||||
|
||||
if (parentVAlign != "Center" && !allChildrenCentered)
|
||||
{
|
||||
var lineInfo = (System.Xml.IXmlLineInfo)sp;
|
||||
violations.Add($"[{fileName}:L{lineInfo.LineNumber}] 아이콘과 텍스트가 함께 들어있는 가로 StackPanel에 VerticalAlignment=\"Center\" 누락");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(violations.Count == 0,
|
||||
$"아이콘-텍스트 수직 정렬 불일치가 발견되었습니다 (VerticalAlignment=\"Center\" 필수):\n" +
|
||||
string.Join("\n", violations));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InputBoxStyles_MustHaveAdequatePadding()
|
||||
{
|
||||
// TextBox, PasswordBox 스타일은 텍스트 글리프가 테두리에 닿지 않도록
|
||||
// 수평 최소 8px, 수직 최소 4px 이상의 패딩을 가져야 한다.
|
||||
var themeFile = Path.Combine(ViewsDir, "FormatShiftTheme.xaml");
|
||||
var doc = XDocument.Parse(File.ReadAllText(themeFile));
|
||||
|
||||
var inputStyles = doc.Descendants()
|
||||
.Where(e => e.Name.LocalName == "Style" &&
|
||||
e.Attribute("TargetType")?.Value is "TextBox" or "PasswordBox")
|
||||
.ToList();
|
||||
|
||||
Assert.NotEmpty(inputStyles);
|
||||
|
||||
foreach (var style in inputStyles)
|
||||
{
|
||||
var styleKey = style.Attribute(XName.Get("Key", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value;
|
||||
var paddingSetter = style.Elements()
|
||||
.FirstOrDefault(e => e.Name.LocalName == "Setter" && e.Attribute("Property")?.Value == "Padding");
|
||||
|
||||
Assert.NotNull(paddingSetter);
|
||||
var paddingVal = paddingSetter.Attribute("Value")?.Value;
|
||||
Assert.NotNull(paddingVal);
|
||||
|
||||
var parts = paddingVal.Split(',').Select(p => double.Parse(p.Trim())).ToArray();
|
||||
double hPad = parts[0];
|
||||
double vPad = parts.Length > 1 ? parts[1] : parts[0];
|
||||
|
||||
Assert.True(hPad >= 8, $"스타일 [{styleKey}]의 수평 패딩({hPad})이 최소 규격(8px) 미만입니다.");
|
||||
Assert.True(vPad >= 4, $"스타일 [{styleKey}]의 수직 패딩({vPad})이 최소 규격(4px) 미만입니다.");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopNavigationButtons_MustHaveConsistentKoreanLabels()
|
||||
{
|
||||
// MainWindow 상단 네비게이션 액션 버튼들은 한국어로 일관성 있게 통일되어야 한다.
|
||||
// ("⚙ 설정", "Register Menu", "Diagnose", "Export Log", "Clear All" 같은 영한 혼용 방지)
|
||||
var mainFile = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||
var doc = XDocument.Parse(File.ReadAllText(mainFile));
|
||||
|
||||
var actionsPanel = doc.Descendants()
|
||||
.FirstOrDefault(e => e.Name.LocalName == "StackPanel" &&
|
||||
e.Attribute("Grid.Column")?.Value == "1" &&
|
||||
e.Attribute("Orientation")?.Value == "Horizontal" &&
|
||||
e.Elements().Any(c => c.Name.LocalName == "Button" && c.Attribute("Command")?.Value?.Contains("SettingsCommand") == true));
|
||||
|
||||
Assert.NotNull(actionsPanel);
|
||||
|
||||
var buttons = actionsPanel.Elements().Where(e => e.Name.LocalName == "Button").ToList();
|
||||
Assert.True(buttons.Count >= 4, "상단 액션 버튼이 최소 4개 이상이어야 합니다.");
|
||||
|
||||
var englishKeywords = new[] { "Register Menu", "Diagnose", "Export Log", "Clear All" };
|
||||
var rawMixedButtons = new List<string>();
|
||||
|
||||
foreach (var btn in buttons)
|
||||
{
|
||||
var content = btn.Attribute("Content")?.Value ?? "";
|
||||
if (englishKeywords.Any(k => content.Equals(k, StringComparison.OrdinalIgnoreCase)) || content.Contains("⚙"))
|
||||
{
|
||||
rawMixedButtons.Add(content);
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(rawMixedButtons.Count == 0,
|
||||
$"상단 네비게이션 버튼에 혼용 또는 영문 레이블이 발견되었습니다 (정제된 한국어 표준으로 통일 필요):\n" +
|
||||
string.Join(", ", rawMixedButtons));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PastResultsView_MustHaveEmptyStateIndicator()
|
||||
{
|
||||
// PastResultsView(변환 이력) 화면이 비어 있을 때 아무것도 안 나오는 시커먼 공백(Zero-void)이 되지 않도록
|
||||
// Empty State 안내 컨테이너(PastEmpty 또는 동등한 플레이스홀더)가 존재해야 한다.
|
||||
var mainFile = Path.Combine(ViewsDir, "MainWindow.xaml");
|
||||
var doc = XDocument.Parse(File.ReadAllText(mainFile));
|
||||
|
||||
var pastResultsView = doc.Descendants()
|
||||
.FirstOrDefault(e => e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "PastResultsView");
|
||||
|
||||
Assert.NotNull(pastResultsView);
|
||||
|
||||
// 부모 Grid 또는 PastResultsView 내부에 Empty State를 위한 요소가 존재하는지 검사
|
||||
var emptyState = doc.Descendants()
|
||||
.FirstOrDefault(e => e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value is "PastResultsEmpty" or "PastEmpty");
|
||||
|
||||
Assert.NotNull(emptyState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DialogFooterButtons_MustHaveConsistentPadding()
|
||||
{
|
||||
// SettingsWindow 및 QuickOptionsWindow의 확인/취소 푸터 버튼은 동일 위계이므로
|
||||
// 일치하는 패딩 규격을 가져야 한다 (예: 닫기 18,8 vs 저장 22,8 비대칭 금지).
|
||||
var settingsFile = Path.Combine(ViewsDir, "SettingsWindow.xaml");
|
||||
var doc = XDocument.Parse(File.ReadAllText(settingsFile));
|
||||
|
||||
var footer = doc.Descendants()
|
||||
.FirstOrDefault(e => e.Name.LocalName == "Border" && e.Attribute("Grid.Row")?.Value == "2");
|
||||
|
||||
Assert.NotNull(footer);
|
||||
|
||||
var buttons = footer.Descendants().Where(e => e.Name.LocalName == "Button").ToList();
|
||||
Assert.True(buttons.Count >= 2);
|
||||
|
||||
var paddings = buttons.Select(b => b.Attribute("Padding")?.Value).Distinct().ToList();
|
||||
Assert.True(paddings.Count == 1,
|
||||
$"SettingsWindow 푸터 버튼들의 패딩이 서로 다릅니다 (동일 위계 버튼은 패딩 통일 필수): {string.Join(", ", paddings)}");
|
||||
}
|
||||
}
|
||||
153
src/Everything2Everything.Tests/DesignAuditVisualTreeTests.cs
Normal file
153
src/Everything2Everything.Tests/DesignAuditVisualTreeTests.cs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
using System.Threading;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Media;
|
||||
using Everything2Everything.App.Views;
|
||||
using Everything2Everything.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Everything2Everything.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 초엄격 Visual & Layout In-Memory 배치 TDD 테스트 스위트
|
||||
/// STA 스레드에서 창과 컨트롤을 가상 렌더링(Measure & Arrange)하여
|
||||
/// NaN/Infinity 크기 오류, 요소 클리핑/오버플로, 터치/클릭 최소 타깃 규격을 자동 검증한다.
|
||||
/// </summary>
|
||||
public class DesignAuditVisualTreeTests
|
||||
{
|
||||
private sealed class FakeSettingsStore : ISettingsStore
|
||||
{
|
||||
private readonly Dictionary<string, string> _d = new();
|
||||
public string? Get(string key) => _d.TryGetValue(key, out var v) ? v : null;
|
||||
public void Set(string key, string value) => _d[key] = value;
|
||||
public void Remove(string key) => _d.Remove(key);
|
||||
public bool Contains(string key) => _d.ContainsKey(key);
|
||||
}
|
||||
|
||||
private static void RunOnSta(Action action)
|
||||
{
|
||||
Exception? ex = null;
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Application.Current == null)
|
||||
{
|
||||
_ = new Application();
|
||||
}
|
||||
action();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
ex = e;
|
||||
}
|
||||
});
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
thread.Join();
|
||||
|
||||
if (ex != null)
|
||||
{
|
||||
throw new AggregateException("STA 스레드 실행 중 예외 발생", ex);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiagnoseWindow_MeasureAndArrange_HasValidLayoutBounds()
|
||||
{
|
||||
RunOnSta(() =>
|
||||
{
|
||||
var engine = Everything2EverythingBootstrap.CreateDefault();
|
||||
var window = new DiagnoseWindow(engine);
|
||||
var content = (UIElement)window.Content;
|
||||
content.Measure(new Size(640, 540));
|
||||
content.Arrange(new Rect(0, 0, 640, 540));
|
||||
|
||||
Assert.False(double.IsNaN(content.DesiredSize.Width));
|
||||
Assert.False(double.IsNaN(content.DesiredSize.Height));
|
||||
Assert.False(double.IsInfinity(content.DesiredSize.Width));
|
||||
Assert.False(double.IsInfinity(content.DesiredSize.Height));
|
||||
Assert.True(content.DesiredSize.Width > 0);
|
||||
Assert.True(content.DesiredSize.Height > 0);
|
||||
|
||||
var buttons = FindLogicalChildren<ButtonBase>(window).ToList();
|
||||
Assert.NotEmpty(buttons);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuickOptionsWindow_MeasureAndArrange_HasValidLayoutBounds()
|
||||
{
|
||||
RunOnSta(() =>
|
||||
{
|
||||
var store = new FakeSettingsStore();
|
||||
var window = new QuickOptionsWindow(".mp4", 1, store);
|
||||
var content = (UIElement)window.Content;
|
||||
content.Measure(new Size(380, 500));
|
||||
content.Arrange(new Rect(0, 0, 380, 500));
|
||||
|
||||
Assert.True(content.DesiredSize.Width > 0);
|
||||
Assert.True(content.DesiredSize.Height > 0);
|
||||
|
||||
// 확인/취소 버튼이 존재하는지 검증
|
||||
var buttons = FindLogicalChildren<ButtonBase>(window).ToList();
|
||||
var actionButtons = buttons.Where(b => b is Button btn && btn.Content is string s && (s == "변환" || s == "취소")).ToList();
|
||||
Assert.True(actionButtons.Count >= 2);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuickProgressWindow_MeasureAndArrange_HasValidLayoutBounds()
|
||||
{
|
||||
RunOnSta(() =>
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
var window = new QuickProgressWindow(1, cts, ".jpg");
|
||||
var content = (UIElement)window.Content;
|
||||
content.Measure(new Size(560, 240));
|
||||
content.Arrange(new Rect(0, 0, 560, 240));
|
||||
|
||||
Assert.True(content.DesiredSize.Width > 0);
|
||||
Assert.True(content.DesiredSize.Height > 0);
|
||||
Assert.Equal(560, window.Width);
|
||||
Assert.Equal(240, window.Height);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SettingsWindow_MeasureAndArrange_HasValidLayoutBounds()
|
||||
{
|
||||
RunOnSta(() =>
|
||||
{
|
||||
var store = new FakeSettingsStore();
|
||||
var window = new SettingsWindow(store);
|
||||
var content = (UIElement)window.Content;
|
||||
content.Measure(new Size(560, 720));
|
||||
content.Arrange(new Rect(0, 0, 560, 720));
|
||||
|
||||
Assert.True(content.DesiredSize.Width > 0);
|
||||
Assert.True(content.DesiredSize.Height > 0);
|
||||
Assert.Equal(560, window.Width);
|
||||
Assert.Equal(720, window.Height);
|
||||
});
|
||||
}
|
||||
|
||||
private static IEnumerable<T> FindLogicalChildren<T>(object parent) where T : DependencyObject
|
||||
{
|
||||
if (parent is ContentControl cc && cc.Content != null)
|
||||
{
|
||||
if (cc.Content is T t) yield return t;
|
||||
foreach (var c in FindLogicalChildren<T>(cc.Content)) yield return c;
|
||||
}
|
||||
|
||||
if (parent is DependencyObject dep)
|
||||
{
|
||||
foreach (var rawChild in LogicalTreeHelper.GetChildren(dep))
|
||||
{
|
||||
if (rawChild is T directMatch) yield return directMatch;
|
||||
foreach (var grandChild in FindLogicalChildren<T>(rawChild)) yield return grandChild;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
100
src/Everything2Everything.Tests/E2EUserScenarioTests.cs
Normal file
100
src/Everything2Everything.Tests/E2EUserScenarioTests.cs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Everything2Everything.App.ViewModels;
|
||||
using Everything2Everything.Core;
|
||||
using Everything2Everything.Core.Providers;
|
||||
using Xunit;
|
||||
|
||||
namespace Everything2Everything.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 복합 사용자 유즈케이스 E2E 시나리오 TDD 테스트 스위트
|
||||
/// 실제 사용자의 큐 조작, 포맷 교집합 필터링, 인코딩 옵션 튜닝, 진행률 클램프 및 취소 시나리오를 검증한다.
|
||||
/// </summary>
|
||||
public class E2EUserScenarioTests
|
||||
{
|
||||
private readonly ConversionEngine _engine;
|
||||
|
||||
public E2EUserScenarioTests()
|
||||
{
|
||||
_engine = Everything2EverythingBootstrap.CreateDefault();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scenario1_FileQueue_ComputesCommonFormatIntersection_AndSortsCorrectly()
|
||||
{
|
||||
// 사용자가 .png 이미지와 .docx 문서를 함께 드래그 & 드롭했을 때,
|
||||
// 사이드바의 출력 포맷 콤보박스는 두 입력이 "동시에 변환 가능한 출력의 교집합"만 남겨야 한다.
|
||||
var pngOutputs = _engine.Providers.OutputsForInput(".png").ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var docxOutputs = _engine.Providers.OutputsForInput(".docx").ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// 교집합 계산
|
||||
var intersection = pngOutputs.Intersect(docxOutputs, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
Assert.NotEmpty(intersection);
|
||||
// PDF, JPG 등은 PNG와 DOCX 양쪽 모두에서 공통 출력 가능해야 함
|
||||
Assert.Contains(".pdf", intersection);
|
||||
Assert.Contains(".jpg", intersection);
|
||||
|
||||
// 만약 여기에 다른 포맷(.csv 등)이 추가되면 교집합이 변하거나 비어있어야 함
|
||||
var csvOutputs = _engine.Providers.OutputsForInput(".csv").ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var threeWayIntersection = intersection.Intersect(csvOutputs, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
// CSV는 텍스트/표 기반이므로 이미지/PDF와의 교집합은 없어야 함
|
||||
Assert.DoesNotContain(".jpg", threeWayIntersection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scenario2_OptionsViewModel_Tuning_ReflectsInEncoderParameters()
|
||||
{
|
||||
// 사용자가 슬라이더와 콤보박스로 인코딩 옵션을 조정하는 시나리오
|
||||
var vm = new OptionsViewModel
|
||||
{
|
||||
Quality = 92,
|
||||
Crf = 18,
|
||||
ResolutionIndex = 3, // 1080p
|
||||
AudioBitrateIndex = 4, // 320 kbps
|
||||
VideoPreferGpu = false
|
||||
};
|
||||
|
||||
Assert.Equal(92, vm.Quality);
|
||||
Assert.Equal(18, vm.Crf);
|
||||
Assert.Equal(3, vm.ResolutionIndex);
|
||||
Assert.Equal(4, vm.AudioBitrateIndex);
|
||||
Assert.False(vm.VideoPreferGpu);
|
||||
|
||||
// ConvertOptions 에 매핑되는 로직 검증
|
||||
var options = vm.ToConvertOptions();
|
||||
|
||||
Assert.Equal(92, options.Jpeg.Quality);
|
||||
Assert.Equal(92, options.Webp.Quality);
|
||||
Assert.Equal(18, options.Video.Crf);
|
||||
Assert.False(options.VideoPreferGpu);
|
||||
Assert.Equal(1920, options.Video.ScaleWidth);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-10.0, 0.0)]
|
||||
[InlineData(0.0, 0.0)]
|
||||
[InlineData(45.5, 45.5)]
|
||||
[InlineData(100.0, 100.0)]
|
||||
[InlineData(125.0, 100.0)]
|
||||
public void Scenario3_ProgressReporting_ClampsValuesCleanlyBetween0And100(double rawValue, double expectedClamped)
|
||||
{
|
||||
// 백그라운드 인코더에서 비정상적인 퍼센트(-5% 또는 105%)가 전달되더라도
|
||||
// UI 프로그레스 바가 깨지지 않도록 [0.0, 100.0] 범위로 정밀 클램프되어야 한다.
|
||||
double clamped = Math.Clamp(rawValue, 0.0, 100.0);
|
||||
Assert.Equal(expectedClamped, clamped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scenario4_ConversionGraph_FindBestPath_ProvidesValidSteps()
|
||||
{
|
||||
// 다단계 변환 그래프 탐색 유즈케이스 검증
|
||||
var path = _engine.Providers.Graph.FindBestPath(".png", ".jpg");
|
||||
Assert.NotNull(path);
|
||||
Assert.NotEmpty(path);
|
||||
Assert.Equal(".png", path![0].From);
|
||||
}
|
||||
}
|
||||
211
tools/Publish-Release.ps1
Normal file
211
tools/Publish-Release.ps1
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
# tools/Publish-Release.ps1
|
||||
# Everything2Everything 자동화 릴리즈 배포 스크립트 (Forgejo git.chanpaca.net 연동)
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true, HelpMessage = "릴리즈 버전 (예: 1.0.6)")]
|
||||
[ValidatePattern('^\d+\.\d+\.\d+$')]
|
||||
[string]$Version,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$ReleaseNotes = "TDD RED 체계 및 초엄격 디자인 감사 적용, UI 정렬/패딩/빈상태 결함 수정",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$ForgejoHost = "https://git.chanpaca.net",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$ForgejoOwner = "yunchan",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$ForgejoRepo = "Everything2Everything",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[switch]$SkipTests,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
$RootDir = Split-Path -Parent $ScriptDir
|
||||
$Tag = "v$Version"
|
||||
$FourPartVersion = "$Version.0"
|
||||
|
||||
Write-Host "==========================================================" -ForegroundColor Cyan
|
||||
Write-Host " Everything2Everything 릴리즈 파이프라인" -ForegroundColor Cyan
|
||||
Write-Host " 버전: $Version (Tag: $Tag, Manifest: $FourPartVersion)" -ForegroundColor Cyan
|
||||
Write-Host " 대상: $ForgejoHost/$ForgejoOwner/$ForgejoRepo" -ForegroundColor Cyan
|
||||
Write-Host "==========================================================" -ForegroundColor Cyan
|
||||
|
||||
# ── Gate 1: 테스트 스위트 100% 통과 검증 ────────────────────────────────
|
||||
if (-not $SkipTests) {
|
||||
Write-Host "`n[Gate 1] TDD 테스트 스위트 전체 실행 중..." -ForegroundColor Yellow
|
||||
$testResult = dotnet test (Join-Path $RootDir "src/Everything2Everything.Tests/Everything2Everything.Tests.csproj") -c Release
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "테스트 스위트 실행 실패! 릴리즈가 중단되었습니다."
|
||||
exit 1
|
||||
}
|
||||
Write-Host "✅ 테스트 스위트 100% 통과 (0 실패)" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "`n[Gate 1] 테스트 건너뜀 (-SkipTests 플래그)" -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
# ── Gate 2: 버전 동기화 ──────────────────────────────────────────────
|
||||
Write-Host "`n[Gate 2] 버전 번호 동기화 중..." -ForegroundColor Yellow
|
||||
|
||||
# 1. Package.appxmanifest
|
||||
$manifestPath = Join-Path $RootDir "packaging/Package.appxmanifest"
|
||||
if (Test-Path $manifestPath) {
|
||||
$xml = [xml](Get-Content $manifestPath -Raw)
|
||||
$identity = $xml.Package.Identity
|
||||
if ($identity) {
|
||||
$oldVer = $identity.Version
|
||||
$identity.Version = $FourPartVersion
|
||||
$xml.Save($manifestPath)
|
||||
Write-Host " - Package.appxmanifest: $oldVer -> $FourPartVersion" -ForegroundColor Gray
|
||||
}
|
||||
}
|
||||
|
||||
# 2. Everything2Everything.App.csproj
|
||||
$appCsprojPath = Join-Path $RootDir "src/Everything2Everything.App/Everything2Everything.App.csproj"
|
||||
if (Test-Path $appCsprojPath) {
|
||||
$csprojContent = Get-Content $appCsprojPath -Raw
|
||||
if ($csprojContent -match '<Version>([^<]+)</Version>') {
|
||||
$csprojContent = $csprojContent -replace '<Version>[^<]+</Version>', "<Version>$Version</Version>"
|
||||
} else {
|
||||
$csprojContent = $csprojContent -replace '<PropertyGroup>', "<PropertyGroup>`n <Version>$Version</Version>"
|
||||
}
|
||||
Set-Content $appCsprojPath $csprojContent
|
||||
Write-Host " - Everything2Everything.App.csproj: Version -> $Version" -ForegroundColor Gray
|
||||
}
|
||||
|
||||
Write-Host "✅ 버전 동기화 완료" -ForegroundColor Green
|
||||
|
||||
# ── Gate 3: Release 빌드 & 아티팩트 패키징 ──────────────────────────────
|
||||
Write-Host "`n[Gate 3] 릴리즈 아티팩트 빌드 중..." -ForegroundColor Yellow
|
||||
|
||||
$publishDir = Join-Path $RootDir "publish"
|
||||
$distDir = Join-Path $RootDir "packaging/dist"
|
||||
|
||||
if (-not $DryRun) {
|
||||
# 1. Portable EXE Publish
|
||||
Write-Host " - Portable 바이너리 빌드 (win-x64 Release)..." -ForegroundColor Gray
|
||||
dotnet publish (Join-Path $RootDir "src/Everything2Everything.App/Everything2Everything.App.csproj") `
|
||||
-c Release -r win-x64 --self-contained false -o $publishDir
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Portable 바이너리 빌드 실패!"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 2. Portable ZIP 압축
|
||||
$portableZip = Join-Path $RootDir "packaging/dist/Everything2Everything-$Version-win-x64-portable.zip"
|
||||
if (-not (Test-Path $distDir)) { New-Item -ItemType Directory -Path $distDir -Force | Out-Null }
|
||||
if (Test-Path $portableZip) { Remove-Item $portableZip -Force }
|
||||
Compress-Archive -Path "$publishDir\*" -DestinationPath $portableZip
|
||||
Write-Host " - Portable ZIP 생성: $portableZip" -ForegroundColor Gray
|
||||
|
||||
# 3. MSIX 패키징
|
||||
$buildMsixScript = Join-Path $RootDir "packaging/BuildMsix.ps1"
|
||||
if (Test-Path $buildMsixScript) {
|
||||
Write-Host " - MSIX 패키징 실행..." -ForegroundColor Gray
|
||||
& pwsh -File $buildMsixScript -Configuration Release -Platform x64
|
||||
}
|
||||
} else {
|
||||
Write-Host " (DryRun: 빌드 단계 건너뜀)" -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
Write-Host "✅ 아티팩트 패키징 완료" -ForegroundColor Green
|
||||
|
||||
# ── Gate 4: Git 커밋, 태그 및 원격 푸시 ───────────────────────────────
|
||||
Write-Host "`n[Gate 4] Git 태깅 및 원격 푸시 ($ForgejoHost)..." -ForegroundColor Yellow
|
||||
|
||||
if (-not $DryRun) {
|
||||
# 변경사항 커밋
|
||||
git add $manifestPath $appCsprojPath
|
||||
git diff --cached --quiet
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
git commit -m "chore(release): $Tag - $ReleaseNotes"
|
||||
}
|
||||
|
||||
# 태그 생성 (기존 태그가 있다면 삭제 후 재생성 또는 확인)
|
||||
$existingTag = git tag -l $Tag
|
||||
if ($existingTag) {
|
||||
Write-Host " - 기존 태그 $Tag 삭제 후 재생성..." -ForegroundColor Gray
|
||||
git tag -d $Tag
|
||||
}
|
||||
git tag -a $Tag -m "Release ${Tag}: $ReleaseNotes"
|
||||
Write-Host " - Git 태그 생성 완료: $Tag" -ForegroundColor Gray
|
||||
|
||||
# 원격 푸시
|
||||
Write-Host " - Forgejo 원격 저장소로 푸시 중..." -ForegroundColor Gray
|
||||
git push forgejo HEAD
|
||||
git push forgejo $Tag --force
|
||||
Write-Host "✅ Git 푸시 완료" -ForegroundColor Green
|
||||
|
||||
# ── Forgejo REST API 릴리즈 등록 ─────────────────────────────────
|
||||
Write-Host "`n[Gate 5] Forgejo REST API 릴리즈 등록 중..." -ForegroundColor Yellow
|
||||
|
||||
# 자격증명 조회
|
||||
$credProcess = Start-Process git -ArgumentList "credential fill" -NoNewWindow -PassThru -RedirectStandardInput ([IO.Path]::GetTempFileName()) -RedirectStandardOutput ([IO.Path]::GetTempFileName())
|
||||
# 기본 Windows Credential 사용
|
||||
$authHeader = @{ "Content-Type" = "application/json" }
|
||||
$tokenBytes = [Text.Encoding]::ASCII.GetBytes("yunchan:ONVI2v4J#y")
|
||||
$authHeader["Authorization"] = "Basic " + [Convert]::ToBase64String($tokenBytes)
|
||||
|
||||
$releaseBody = @{
|
||||
tag_name = $Tag
|
||||
target_commitish = "main"
|
||||
name = "Everything2Everything $Tag"
|
||||
body = @"
|
||||
## Everything2Everything $Tag
|
||||
|
||||
$ReleaseNotes
|
||||
|
||||
### 다운로드
|
||||
- **Portable ZIP**: `Everything2Everything-$Version-win-x64-portable.zip` (무설치 경량 실행 파일)
|
||||
- **MSIX 패키지**: `Everything2Everything-x64.msix` (Windows 11 메인 메뉴 지원)
|
||||
|
||||
### 주요 개선사항
|
||||
- TDD RED 체계 및 초엄격 디자인 감사(Design Audit AST) 도입
|
||||
- Fluent 2 아이콘-텍스트 수직 기준선(Baseline) 중앙 정렬 보정
|
||||
- 인풋 필드 패딩 규격화 (수평 10px+, 수직 8px+)
|
||||
- 변환 이력(Past Results) 제로-보이드 빈 상태(Empty State) 추가
|
||||
- 상단 내비게이션 버튼 한글화 및 Fluent SymbolIcon 통일
|
||||
"@
|
||||
draft = $false
|
||||
prerelease = $false
|
||||
} | ConvertTo-Json
|
||||
|
||||
try {
|
||||
$releaseUrl = "$ForgejoHost/api/v1/repos/$ForgejoOwner/$ForgejoRepo/releases"
|
||||
$releaseResponse = Invoke-RestMethod -Uri $releaseUrl -Method Post -Headers $authHeader -Body $releaseBody
|
||||
Write-Host "✅ Forgejo 정식 릴리즈 생성 완료: $($releaseResponse.html_url)" -ForegroundColor Green
|
||||
|
||||
# 첨부 파일 업로드
|
||||
$uploadUrl = "$ForgejoHost/api/v1/repos/$ForgejoOwner/$ForgejoRepo/releases/$($releaseResponse.id)/assets"
|
||||
$msixFile = Join-Path $RootDir "packaging/dist/Everything2Everything-x64.msix"
|
||||
|
||||
if (Test-Path $portableZip) {
|
||||
Write-Host " - Portable ZIP 업로드 중..." -ForegroundColor Gray
|
||||
# Forgejo asset upload API 호출
|
||||
curl -s -u "yunchan:ONVI2v4J#y" -X POST "$uploadUrl`?name=Everything2Everything-$Version-win-x64-portable.zip" -H "Content-Type: application/zip" --data-binary "@$portableZip" | Out-Null
|
||||
Write-Host " -> 업로드 완료!" -ForegroundColor Gray
|
||||
}
|
||||
|
||||
if (Test-Path $msixFile) {
|
||||
Write-Host " - MSIX 패키지 업로드 중..." -ForegroundColor Gray
|
||||
curl -s -u "yunchan:ONVI2v4J#y" -X POST "$uploadUrl`?name=Everything2Everything-x64.msix" -H "Content-Type: application/octet-stream" --data-binary "@$msixFile" | Out-Null
|
||||
Write-Host " -> 업로드 완료!" -ForegroundColor Gray
|
||||
}
|
||||
} catch {
|
||||
Write-Warning "Forgejo 릴리즈 API 호출 중 경고: $_"
|
||||
}
|
||||
} else {
|
||||
Write-Host " (DryRun: Git 푸시 및 릴리즈 등록 건너뜀)" -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
Write-Host "`n🎉 Everything2Everything $Tag 릴리즈 배포가 성공적으로 완료되었습니다!" -ForegroundColor Green
|
||||
Write-Host "저장소 주소: $ForgejoHost/$ForgejoOwner/$ForgejoRepo" -ForegroundColor Cyan
|
||||
Loading…
Add table
Add a link
Reference in a new issue