? ActiveMatchIndexChanged;
+
+ public Task StartAsync(string term, bool caseSensitive, bool matchWord, bool highlightAll)
+ {
+ StartCount++; LastTerm = term; return Task.CompletedTask;
+ }
+ public void FindNext() => NextCount++;
+ public void FindPrevious() => PrevCount++;
+ public void Stop() => StopCount++;
+
+ public void RaiseCount(int n) { MatchCount = n; MatchCountChanged?.Invoke(this, n); }
+ public void RaiseActive(int n) { ActiveIndex = n; ActiveMatchIndexChanged?.Invoke(this, n); }
+ }
+
+ [Fact]
+ public void Start는_API로_용어를_전달하고_카운트_이벤트를_구독_반영한다()
+ {
+ var api = new FakeFindApi();
+ var session = new FindSession(api);
+
+ session.StartAsync("hello").Wait();
+ api.RaiseCount(7);
+
+ Assert.Equal(1, api.StartCount);
+ Assert.Equal("hello", api.LastTerm);
+ Assert.Equal(7, session.MatchCount);
+ }
+
+ [Fact]
+ public void Next_Previous는_API에_위임한다()
+ {
+ var api = new FakeFindApi();
+ var session = new FindSession(api);
+ session.StartAsync("x").Wait();
+
+ session.Next();
+ session.Previous();
+
+ Assert.Equal(1, api.NextCount);
+ Assert.Equal(1, api.PrevCount);
+ }
+
+ [Fact]
+ public void ActiveMatchIndex_이벤트가_상태에_반영된다()
+ {
+ var api = new FakeFindApi();
+ var session = new FindSession(api);
+ session.StartAsync("x").Wait();
+
+ api.RaiseActive(3);
+ Assert.Equal(3, session.ActiveMatchIndex);
+ }
+
+ [Fact]
+ public void 빈_용어로_Start하면_Stop만_호출된다()
+ {
+ var api = new FakeFindApi();
+ var session = new FindSession(api);
+
+ session.StartAsync(" ").Wait();
+
+ Assert.Equal(0, api.StartCount);
+ Assert.Equal(1, api.StopCount);
+ Assert.Equal(0, session.MatchCount);
+ }
+
+ [Fact]
+ public void Close는_Stop을_호출하고_상태를_초기화한다()
+ {
+ var api = new FakeFindApi();
+ var session = new FindSession(api);
+ session.StartAsync("x").Wait();
+ api.RaiseCount(5);
+ api.RaiseActive(2);
+
+ session.Close();
+
+ Assert.Equal(1, api.StopCount);
+ Assert.Equal(0, session.MatchCount);
+ Assert.Equal(-1, session.ActiveMatchIndex); // -1 = 활성 매치 없음(Start 초기값과 동일 센티넬)
+ Assert.Equal(string.Empty, session.Term);
+ }
+}
diff --git a/VideoDownloader.Tests/E2E/MainWindowSmokeE2ETests.cs b/VideoDownloader.Tests/E2E/MainWindowSmokeE2ETests.cs
new file mode 100644
index 0000000..aae07ce
--- /dev/null
+++ b/VideoDownloader.Tests/E2E/MainWindowSmokeE2ETests.cs
@@ -0,0 +1,58 @@
+using System.Windows;
+using VideoDownloader.App;
+using VideoDownloader.Tests.Infrastructure;
+
+namespace VideoDownloader.Tests.E2E;
+
+///
+/// E2E 스모크: 실제 MainWindow 를 기동해 WebView2 초기화·탐색·검색 바·전체화면 전환이
+/// 크래시 없이 동작하는지 검증한다(Phase 0 통합 확인). 네트워크 의존 제거를 위해 about:blank 사용.
+///
+[Collection("E2E")]
+public class MainWindowSmokeE2ETests
+{
+ [Fact]
+ public async Task MainWindow_기동_탐색_검색바_전체화면_스모크()
+ {
+ using var sta = StaDispatcher.Start();
+ WpfTestApp.Ensure(sta.Dispatcher);
+ var ok = await sta.InvokeAsync(async () =>
+ {
+ var window = new MainWindow();
+ window.Show();
+
+ // WebView2 초기화 대기 (Loaded → InitializeBrowserAsync)
+ var cv = await Wait.Until(
+ () => Task.FromResult(window.Browser.CoreWebView2),
+ c => c != null, 30000);
+
+ // about:blank 탐색 (네트워크 불필요)
+ window.NavigateTo("about:blank");
+ await Wait.Until(
+ () => Task.FromResult(cv.Source ?? ""),
+ s => s.Contains("about:blank"), 15000);
+
+ // T1-3 검색 바 열기/닫기
+ window.OpenFind();
+ var findVisible = window.FindBar.Visibility == Visibility.Visible;
+ window.CloseFind();
+ var findCollapsed = window.FindBar.Visibility == Visibility.Collapsed;
+
+ // T1-11 전체화면 토글
+ window.ToggleFullscreen();
+ var toolbarHidden = window.ToolbarRow.Visibility == Visibility.Collapsed;
+ window.ToggleFullscreen();
+ var toolbarBack = window.ToolbarRow.Visibility == Visibility.Visible;
+
+ window.Close();
+ return (init: cv != null, nav: true, findVisible, findCollapsed, toolbarHidden, toolbarBack);
+ });
+
+ Assert.True(ok.init, "WebView2 초기화 실패");
+ Assert.True(ok.nav);
+ Assert.True(ok.findVisible, "검색 바 미표시");
+ Assert.True(ok.findCollapsed, "검색 바 미닫힘");
+ Assert.True(ok.toolbarHidden, "전체화면에서 툴바 미숨김");
+ Assert.True(ok.toolbarBack, "전체화면 복원 실패");
+ }
+}
diff --git a/VideoDownloader.Tests/E2E/WebView2FindE2ETests.cs b/VideoDownloader.Tests/E2E/WebView2FindE2ETests.cs
new file mode 100644
index 0000000..ff2a95d
--- /dev/null
+++ b/VideoDownloader.Tests/E2E/WebView2FindE2ETests.cs
@@ -0,0 +1,71 @@
+using System.Windows;
+using System.Windows.Threading;
+using Microsoft.Web.WebView2.Core;
+using VideoDownloader.Tests.Infrastructure;
+
+namespace VideoDownloader.Tests.E2E;
+
+///
+/// E2E: 실제 WebView2 + 네이티브 Find API(CoreWebView2.Find) 검증(T1-3).
+/// WebView2 런타임 위에서 매치 카운트/활성 인덱스가 실제로 발생하는지 확인한다.
+///
+[Collection("E2E")]
+public class WebView2FindE2ETests
+{
+ [Fact]
+ public async Task 네이티브_Find로_매치_수와_활성_인덱스를_얻는다()
+ {
+ using var sta = StaDispatcher.Start();
+ var udf = Path.Combine(Path.GetTempPath(), $"vd_e2e_{Guid.NewGuid():N}");
+ var result = await sta.InvokeAsync(async () =>
+ {
+ var window = new Window();
+ var browser = new Microsoft.Web.WebView2.Wpf.WebView2();
+ window.Content = browser;
+ window.Show();
+
+ var env = await CoreWebView2Environment.CreateAsync(userDataFolder: udf);
+ await browser.EnsureCoreWebView2Async(env);
+ var cv = browser.CoreWebView2;
+ cv.Settings.AreDefaultScriptDialogsEnabled = false;
+
+ var html = "hello world hello brave hello
";
+ cv.NavigateToString(html);
+ await WaitUntilNavCompleted(cv);
+
+ // 네이티브 Find API — SuppressDefaultFindDialog 로 자체 UI 전제 검증
+ int matchCount = -1, activeIndex = -1;
+ cv.Find.MatchCountChanged += (_, _) => matchCount = cv.Find.MatchCount;
+ cv.Find.ActiveMatchIndexChanged += (_, _) => activeIndex = cv.Find.ActiveMatchIndex;
+
+ var options = env.CreateFindOptions();
+ options.FindTerm = "hello";
+ options.ShouldHighlightAllMatches = true;
+ options.SuppressDefaultFindDialog = true;
+ await cv.Find.StartAsync(options);
+
+ await Wait.Until(() => Task.FromResult(matchCount), c => c > 0, 10000);
+ var countAfterStart = matchCount;
+
+ cv.Find.FindNext();
+ await Wait.Until(() => Task.FromResult(activeIndex), i => i >= 1, 10000);
+
+ cv.Find.Stop();
+ window.Close();
+ return (countAfterStart, activeIndex);
+ });
+
+ try { Directory.Delete(udf, true); } catch { }
+ Assert.Equal(3, result.countAfterStart);
+ Assert.True(result.activeIndex >= 1, $"활성 인덱스가 진행되지 않음: {result.activeIndex}");
+ }
+
+ private static Task WaitUntilNavCompleted(CoreWebView2 cv)
+ {
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ EventHandler? h = null;
+ h = (_, _) => { cv.NavigationCompleted -= h; tcs.TrySetResult(); };
+ cv.NavigationCompleted += h;
+ return tcs.Task.WaitAsync(TimeSpan.FromSeconds(15));
+ }
+}
diff --git a/VideoDownloader.Tests/Infrastructure/WpfTestApp.cs b/VideoDownloader.Tests/Infrastructure/WpfTestApp.cs
new file mode 100644
index 0000000..736a779
--- /dev/null
+++ b/VideoDownloader.Tests/Infrastructure/WpfTestApp.cs
@@ -0,0 +1,33 @@
+using System.Windows;
+using VideoDownloader.App;
+
+namespace VideoDownloader.Tests.Infrastructure;
+
+///
+/// 테스트 프로세스에는 App.xaml 의 리소스가 없으므로, App 어셈블리의 테마 사전을
+/// 테스트 전용 Application 에 로드한다(앱의 App.ApplyTheme 과 동일 순서: 색 → 스타일).
+///
+public static class WpfTestApp
+{
+ private static bool _initialized;
+
+ public static void Ensure(System.Windows.Threading.Dispatcher dispatcher)
+ {
+ dispatcher.Invoke(() =>
+ {
+ if (_initialized) return;
+ if (Application.Current == null)
+ new Application { ShutdownMode = ShutdownMode.OnExplicitShutdown };
+ var md = Application.Current.Resources.MergedDictionaries;
+ if (md.Count == 0)
+ {
+ const string asm = "/VideoDownloader;component";
+ md.Add(new ResourceDictionary { Source = new Uri(asm + "/Themes/Dark.xaml", UriKind.Relative) });
+ md.Add(new ResourceDictionary { Source = new Uri(asm + "/Themes/Shared.xaml", UriKind.Relative) });
+ }
+ if (!Application.Current.Resources.Contains("InverseCountToVisibility"))
+ Application.Current.Resources["InverseCountToVisibility"] = new InverseCountToVisibilityConverter();
+ _initialized = true;
+ });
+ }
+}