최종 감사: 계획↔구현 매핑 문서(IMPLEMENTATION_AUDIT.md), slnx에 Mobile 추가, HlsDownloader._muxer ctor 누락 회귀 RED→GREEN 수정(103 tests), 빌드머신 아티팩트 갱신(linux-x64/osx-arm64)
This commit is contained in:
parent
432b94fcbe
commit
17f3e41cd2
4 changed files with 214 additions and 0 deletions
140
IMPLEMENTATION_AUDIT.md
Normal file
140
IMPLEMENTATION_AUDIT.md
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
# 구현 감사 보고서 — 계획 ↔ 구현 ↔ 증거 매핑
|
||||
|
||||
> **대상**: `BROWSER_FEATURES_PLAN.md` (v2) + `MOBILE_PLAN.md` (v1) 전 항목
|
||||
> **감사일**: 2026-08-15 · **방법**: TDD(RED→GREEN) + E2E 검증
|
||||
> **요약**: 전 솔루션 Release 빌드 **0 오류**, 단위/E2E 테스트 **103/103 GREEN**, Android 에뮬레이터·iOS 시뮬레이터·LAN 서버 E2E 완료
|
||||
>
|
||||
> **감사 중 발견·수정된 회귀**: `HlsDownloader` 3-인자 ctor가 `_muxer` 미할당(재구성 ② 누락) → 모든 HLS 리먹스 NUL 위험. RED 테스트(`HlsMuxerWiringTests`)로 재현 후 ctor 할당 수정. `DashDownloader`는 정상.
|
||||
|
||||
---
|
||||
|
||||
## 1. 브라우저 기능 계획 (BROWSER_FEATURES_PLAN v2)
|
||||
|
||||
### Phase 0 — 퀵윈
|
||||
|
||||
| 항목 | 구현 | 증거 |
|
||||
|---|---|---|
|
||||
| T1-3 페이지 내 검색 (Ctrl+F) | ✅ 네이티브 `CoreWebView2.Find` + 자체 하단 바, 일치 수 X/Y | `Browser/FindSession.cs` · `WebView2FindE2ETests`(네이티브 Find 3매치) |
|
||||
| T1-4 검색엔진 | ✅ URL/검색어 판별, 기본 엔진, `g /b /d /n /yt ` 키워드 | `SearchEngineResolver.cs` + 단위테스트 |
|
||||
| T1-7 사이트별 줌 영속 | ✅ 도메인→배율 LRU(300), www 정규화, 클램프 0.1~5.0 | `SiteZoomStore.cs` + 단위테스트 |
|
||||
| T1-9 강제 새로고침 | ✅ Ctrl+Shift+R | `MainWindow.xaml.cs` 단축키 |
|
||||
| T1-11 전체화면 F11 | ✅ 크롬 자동 숨김 | `MainWindow.xaml.cs` |
|
||||
|
||||
### Phase A — 탭 아키텍처
|
||||
|
||||
| 항목 | 구현 | 증거 |
|
||||
|---|---|---|
|
||||
| 탭 시스템 (T1-1) | ✅ 공유 Environment + 탭당 WebView2, 생성/닫기/전환/Ctrl+Tab·1~9/드래그 정렬/Move | `Browser/Tabs/TabManager.cs` · `MainWindowSmokeE2ETests`(탭 생명주기 E2E) |
|
||||
| T1-2 닫은 탭 복원 | ✅ Ctrl+Shift+T, 스택 25개 | `TabManager.ReopenClosedTab` + 테스트 |
|
||||
| NewWindowRequested→새 탭 | ✅ deferral 패턴 | `TabViewService` NewTabProvider |
|
||||
| T1-8 오디오 표시/뮤트 | ✅ 스피커 아이콘 + 클릭 뮤트 | 탭 템플릿 `TabAudio_Click` |
|
||||
| T1-10 세션 복원 | ✅ session.json 저장/복원(옵션) | `TabSessionStore.cs` + 테스트 (VD_DATA_DIR 격리) |
|
||||
|
||||
### Phase B — 탭 보조
|
||||
|
||||
| 항목 | 구현 | 증거 |
|
||||
|---|---|---|
|
||||
| T1-5 다운로드 매니저 | ✅ 진행률/속도/일시정지/재개/취소/완료 정리 | `PageDownloadTracker.cs`(IDownloadOperation 주입) + 테스트 |
|
||||
| T1-6 북마크바 | ✅ Ctrl+Shift+B 토글, 원클릭 이동 | `MainWindow.xaml` bookmarkbar 행 |
|
||||
| T2-2 탭 절전 | ✅ 유휴 서스펜드 "zzz" | `TabSuspendService.cs` + 테스트 |
|
||||
| T2-8 탭 검색 | ✅ Ctrl+Shift+A, 열린+닫힘 탭 | `TabSearchFilter.cs` + 테스트 |
|
||||
| T2-12 우클릭 "이 링크 영상 받기" | ✅ 커스텀 컨텍스트 메뉴 | `TabViewService.ContextMenuBuild` |
|
||||
|
||||
### Phase C — 차별화
|
||||
|
||||
| 항목 | 구현 | 증거 |
|
||||
|---|---|---|
|
||||
| T2-1 명령 팔레트 | ✅ Ctrl+K 탭/북마크/기록/액션/웹검색 | `CommandPalette.cs` + 테스트 |
|
||||
| T2-3 분할 화면 | ✅ 2분할 + 스플리터 + Exit | `MainWindow` SplitCol/분할 단축키 |
|
||||
| T2-4 리더 모드 | ✅ Readability.js(Mozilla) 내장 → 스타일 본문, 원본 복원 | `ReaderMode.cs` · `ReaderModeE2ETests`(실제 추출+복원 E2E) |
|
||||
| T2-5 세로 탭 | ✅ 토글, 좌측 사이드 스트립 | `ToggleVerticalTabs` |
|
||||
| T2-6 Glance | ✅ Alt+클릭 플로팅 미리보기 | `GlanceWindow.cs` |
|
||||
| T2-7 사이트별 Boost | ✅ 강제 다크(invert-hue-rotate, 이미지/영상 보호) 등 | `BoostStore`/`BoostCssBuilder` + 테스트 |
|
||||
| T2-9 스크린샷 | ✅ Ctrl+Shift+S | `CapturePreviewAsync` |
|
||||
| T2-11 탭 그룹 | ✅ 6색 그룹, 인접 정렬, 그룹 닫기 | `TabManagerGroupExtensions` + 테스트 |
|
||||
|
||||
### Phase D — 시그니처
|
||||
|
||||
| 항목 | 구현 | 증거 |
|
||||
|---|---|---|
|
||||
| T2-10 미디어 재생목록 | ✅ 감지 미디어 자동 적재, 플라이아웃, 플레이어 창(native+hls.js+dash.js), 자동 다음, JSON 영속 | `MediaPlaylist.cs` · `PlayerWindow.cs` + 테스트 |
|
||||
| T3-1 워크스페이스 | ✅ 저장/전환 | `TabManagerWorkspaceExtensions` + 테스트 |
|
||||
| T3-3 컨테이너/InPrivate | ✅ IsInPrivateModeEnabled 탭 | `OpenInPrivateTab` |
|
||||
| T3-4 빠른 찾기 `/` | ✅ | `MainWindow` 단축키 |
|
||||
| T3-5 컨텍스트 메뉴 전면 커스터마이징 | ✅ WPF 메뉴 + SelectedCommandId | `TabViewService` |
|
||||
| T3-7 PiP | ✅ 별도 최상위 창 | `TogglePip` |
|
||||
| T3-8 Speed Dial | ✅ 새 탭 다이얼 그리드 | `NewTabWithDial` |
|
||||
| T3-9 읽어주기(TTS) | ✅ | `SpeakText` |
|
||||
| T3-10 인쇄/소스보기 | ✅ Ctrl+P / Ctrl+U | `MainWindow` |
|
||||
|
||||
### T3 중 명시적 미구현 (계획 내 후순위/선택)
|
||||
|
||||
- T3-2 Auto-Archive 캐던스 — 닫힘 스택(25개)+복원이 커버(아카이브 자동화는 미차용)
|
||||
- T3-11 권한 UI — `PermissionRequested` 배선 미삽입 (계획상 선택)
|
||||
- T3-12 노트 패널 — 계획 자체가 "후순위" 명시
|
||||
- T3-13 컴팩트 모드 — F11 전체화면이 동등 커버
|
||||
- T3-14 추적 방지 카운터 — 미차용
|
||||
- T3-15 주소창 진행 바/파라미터 제거 복사 — 미차용
|
||||
|
||||
### 재구성 (사용자 지시 "근원부터 뜯어고쳐")
|
||||
|
||||
| 작업 | 결과 |
|
||||
|---|---|
|
||||
| Detection → `VideoDownloader.Core.Detection` 이동 | App 의존성 역전 |
|
||||
| `Core/Platform/` 신설: `IProcessRunner`/`ProcessSpec`/`DesktopProcessRunner`(stdout·stderr 동시 콜백 계약 문서화), `IMediaMuxer`/`DesktopMediaMuxer`(-progress 파싱) | 모바일/크로스플랫폼 재사용 준비 (MOBILE_PLAN §1.4 추상화 4곳 중 3곳) |
|
||||
| `VideoDownloader.Browser` 라이브러리 (탭/검색/줌/세션) | god-class 해체 |
|
||||
| `TabViewService` 추출 (탭별 WebView2 배선 전담) | MainWindow 코드비하인드 축소 |
|
||||
|
||||
## 2. 모바일 계획 (MOBILE_PLAN v1)
|
||||
|
||||
| 항목 | 구현 | 증거 |
|
||||
|---|---|---|
|
||||
| **M0** Core 추상화 | ✅ IProcessRunner/IMediaMuxer/경로(CorePaths, VD_DATA_DIR 오버라이드) | `PlatformAbstractionTests`(cmd.exe 통합, FakeProcessRunner) |
|
||||
| **M0** `VideoDownloader.Server` | ✅ Kestrel, Bearer+쿼리 토큰(상수시간), /api/pair·queue CRUD·library·stream(206 Range)·file·웹 컴패니언 | `RemoteServerE2ETests`(401/pair/queue/library/Range 206/file/웹컴패니언 전 경로) |
|
||||
| **M0** QR 페어링 다이얼로그 | ✅ QRCoder, LAN IP 자동 | `PairingWindow.cs` |
|
||||
| **M0** 검증 관문: 웹 컴패니언 | ✅ 제로 설치 페이지(#t= 프래그먼트 페어링, 2초 폴링, video 스트리밍) | `WebCompanion.cs` + E2E |
|
||||
| **M1** QR 스캔 페어링 + 수동 폴백 | ✅ ZXing(실기기) + 수동 IP:포트+토큰 | 에뮬레이터 E2E에서 수동 페어링으로 실증 (IME 제약) |
|
||||
| **M1** 큐 목록 실시간 + 제어 | ✅ 2초 폴링(계획 허용안: "v1은 2초 폴링 가능"), 일시정지/재개/취소 버튼 | 에뮬 E2E: 라이브 서버 데이터 렌더 실증 |
|
||||
| **M1** 링크 붙여넣기 추가 | ✅ URL 엔트리 + "PC에서 받기" | 페이지 UI + 서버 API E2E (POST 400 검증 포함) |
|
||||
| **M2** 라이브러리 목록 | ✅ 파일명/크기 | 에뮬 E2E: sample.mp4/415,253 bytes 렌더 |
|
||||
| **M2** MediaElement 스트리밍 재생 | ✅ Range HTTP → 모달 플레이어 | 에뮬 E2E: **실제 MP4 프레임 렌더 실증**(testsrc 패턴 스크린샷) + 서버 ESTABLISHED |
|
||||
| **M3** net10.0-ios | ✅ macOS 전용 TFM 조건 분기 | vd-mac 빌드: Mach-O arm64 .app, Info.plist 검증 |
|
||||
| **M3** iOS 시동 | ✅ | iPhone 17 시뮬레이터(iOS 26) 설치+실행, 전체 UI 렌더 스크린샷 |
|
||||
|
||||
### 모바일 E2E에서 발견·수정된 결함 (TDD 외 가치)
|
||||
|
||||
1. 탭 자식 `PushAsync` 불가(NavigationPage 부재) → 루트 모달 `PushModalAsync` + `NavigationPage` 래핑
|
||||
2. `CollectionView.SelectionChanged` Android 비발화 → 템플릿 카드 `TapGestureRecognizer`
|
||||
3. 링커가 `Xamarin.AndroidX.LocalBroadcastManager` 제거 → Release 크래시 → 명시 참조
|
||||
4. iOS `NSCameraUsageDescription` 누락 → 시작 크래시 → Info.plist 추가 (iOS+MacCatalyst)
|
||||
5. 시뮬레이터 카메라 세션이 첫 프레임 블록 → 스캐너를 실기기(Physical)로 게이트
|
||||
|
||||
### 명시적 미구현 (계획 허용/후순위)
|
||||
|
||||
- SignalR 허브 — 계획안 명시 "v1은 2초 폴링으로도 가능" 채택
|
||||
- mDNS 광고(_videodl._tcp) — QR 페어링이 주 경로(계획 §6 리스크 대응과 동일 전제)
|
||||
- U4 폰 오프라인 저장(`GET /file/{id}` API는 구현됨, 앱 측 다운로드 탭 미구현 — Tier M2 후반)
|
||||
- U5 완료 푸시 알림, 이어보기 위치 동기화(`POST /api/position` 미구현)
|
||||
- M4 Android 독립 다운로더 — 계획상 "선택"
|
||||
- iOS TestFlight — 앱 실행/페어링 프레이밍까지만 (감사일 기준)
|
||||
|
||||
## 3. 배포 (DEPLOYMENT_PLAN)
|
||||
|
||||
| 항목 | 상태 |
|
||||
|---|---|
|
||||
| Windows 로컬 빌드 | ✅ Release 0 오류 |
|
||||
| linux-x64 셸(Cli+Avalonia) self-contained | ✅ vd-linux 빌드+ELF 검증 → `out/remote/linux-x64/` |
|
||||
| osx-arm64 셸 | ✅ vd-mac 빌드+Mach-O 검증 → `out/remote/osx-arm64/` |
|
||||
| Android APK | ✅ x86_64 Release 서명 APK(에뮬 설치/실행 실증) |
|
||||
| iOS .app | ✅ vd-mac iossimulator-arm64 빌드 |
|
||||
| 빌드머신 SSH 키 프로비저닝 | ✅ `build/remote/install-buildkey.ps1` |
|
||||
|
||||
## 4. 성공 기준 체크 (계획 §7)
|
||||
|
||||
- [x] `dotnet build` 전체 0 오류 — 8개 프로젝트(App/Avalonia/Browser/Cli/Core/Mobile/Server/Tests)
|
||||
- [x] 기존 미디어 감지·다운로드 회귀 없음 — Detection 이동 후 단위/E2E GREEN
|
||||
- [x] 폰 브라우저 웹 컴패니언 큐 조회/추가 (M0) — 서버 E2E
|
||||
- [x] Android 앱 큐 실시간 갱신/제어 (M1) — 에뮬 E2E 라이브 데이터
|
||||
- [x] 라이브러리 스트리밍 재생(시크 포함) (M2) — 실측 MP4 프레임 + 206 Range
|
||||
- [x] iOS 빌드 시동 + 큐 조회 (M3) — iOS 26 시뮬레이터 UI 렌더
|
||||
- [ ] 폰 저장 오프라인 재생 (M2 후반) — 미구현(위 표)
|
||||
|
|
@ -25,6 +25,7 @@ public class HlsDownloader
|
|||
{
|
||||
_http = http;
|
||||
_config = config;
|
||||
_muxer = muxer;
|
||||
}
|
||||
|
||||
/// <param name="outputBase">확장자 없는 출력 경로. 최종 파일은 outputBase + ".mp4"</param>
|
||||
|
|
|
|||
72
VideoDownloader.Tests/Platform/HlsMuxerWiringTests.cs
Normal file
72
VideoDownloader.Tests/Platform/HlsMuxerWiringTests.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using VideoDownloader.Core.Config;
|
||||
using VideoDownloader.Core.Hls;
|
||||
using VideoDownloader.Core.Models;
|
||||
using VideoDownloader.Core.Platform;
|
||||
|
||||
namespace VideoDownloader.Tests.Platform;
|
||||
|
||||
/// <summary>HlsDownloader 가 주입된 IMediaMuxer 를 실제 리먹스 경로에서 사용하는지 검증(RED: ctor 미할당 회귀 방지).</summary>
|
||||
public class HlsMuxerWiringTests
|
||||
{
|
||||
private sealed class RecordingMuxer : IMediaMuxer
|
||||
{
|
||||
public int RemuxCalls;
|
||||
public Task RemuxAsync(string ffmpeg, string input, string output, CancellationToken ct)
|
||||
{
|
||||
RemuxCalls++;
|
||||
File.WriteAllText(output, "remuxed"); // 성공 시뮬레이션
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public Task DownloadDashAsync(string ffmpeg, Uri mpdUrl, string output, double durationSec,
|
||||
HttpClient http, IProgress<DownloadProgress>? progress, CancellationToken ct) => Task.CompletedTask;
|
||||
public string? ResolveFfmpeg(AppConfig config) => "ffmpeg";
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HlsDownloader_주입된_뮥서로_리먹스한다()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) return;
|
||||
using var server = new HttpListener();
|
||||
var port = Random.Shared.Next(20000, 60000);
|
||||
server.Prefixes.Add($"http://127.0.0.1:{port}/");
|
||||
server.Start();
|
||||
|
||||
var seg = new byte[16];
|
||||
using var listenerTask = Task.Run(async () =>
|
||||
{
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var ctx = await server.GetContextAsync();
|
||||
var path = ctx.Request.Url!.AbsolutePath;
|
||||
var body = path.EndsWith(".m3u8")
|
||||
? "#EXTM3U\n#EXT-X-TARGETDURATION:2\n#EXTINF:2.0,\nseg0.ts\n#EXTINF:2.0,\nseg1.ts\n#EXT-X-ENDLIST\n"
|
||||
: null;
|
||||
var buf = body != null ? System.Text.Encoding.UTF8.GetBytes(body) : seg;
|
||||
ctx.Response.ContentType = body != null ? "application/vnd.apple.mpegurl" : "video/mp2t";
|
||||
await ctx.Response.OutputStream.WriteAsync(buf);
|
||||
ctx.Response.Close();
|
||||
}
|
||||
});
|
||||
|
||||
var muxer = new RecordingMuxer();
|
||||
var config = new AppConfig();
|
||||
var dl = new HlsDownloader(new HttpClient(), config, muxer);
|
||||
var outBase = Path.Combine(Path.GetTempPath(), $"hls-{Guid.NewGuid():N}");
|
||||
try
|
||||
{
|
||||
var result = await dl.DownloadAsync(new Uri($"http://127.0.0.1:{port}/index.m3u8"), outBase);
|
||||
Assert.True(muxer.RemuxCalls > 0, "주입한 IMediaMuxer.RemuxAsync 가 호출되어야 한다");
|
||||
Assert.EndsWith(".mp4", result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
server.Stop();
|
||||
try { await listenerTask; } catch { }
|
||||
foreach (var f in Directory.GetFiles(Path.GetTempPath(), Path.GetFileName(outBase) + "*"))
|
||||
File.Delete(f);
|
||||
if (Directory.Exists(outBase + ".parts")) Directory.Delete(outBase + ".parts", true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
<Project Path="VideoDownloader.Browser/VideoDownloader.Browser.csproj" />
|
||||
<Project Path="VideoDownloader.Cli/VideoDownloader.Cli.csproj" />
|
||||
<Project Path="VideoDownloader.Core/VideoDownloader.Core.csproj" />
|
||||
<Project Path="VideoDownloader.Mobile/VideoDownloader.Mobile.csproj" />
|
||||
<Project Path="VideoDownloader.Server/VideoDownloader.Server.csproj" />
|
||||
<Project Path="VideoDownloader.Tests/VideoDownloader.Tests.csproj" />
|
||||
</Solution>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue