Phase 0 (T1-3/4/7/9/11): find bar w/ native Find API + search engine + site zoom + force reload + F11, wired into MainWindow, E2E smoke (39 tests)
This commit is contained in:
parent
470c9267f2
commit
00a822eaeb
11 changed files with 584 additions and 18 deletions
126
VideoDownloader.App/Browser/FindSession.cs
Normal file
126
VideoDownloader.App/Browser/FindSession.cs
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
using Microsoft.Web.WebView2.Core;
|
||||||
|
|
||||||
|
namespace VideoDownloader.App.Browser;
|
||||||
|
|
||||||
|
/// <summary>네이티브 Find API 의 최소 추상화(단위 테스트용 fake 교체 가능).</summary>
|
||||||
|
public interface IFindApi
|
||||||
|
{
|
||||||
|
Task StartAsync(string term, bool caseSensitive, bool matchWord, bool highlightAll);
|
||||||
|
void FindNext();
|
||||||
|
void FindPrevious();
|
||||||
|
void Stop();
|
||||||
|
event EventHandler<int>? MatchCountChanged;
|
||||||
|
event EventHandler<int>? ActiveMatchIndexChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>WebView2 네이티브 Find(CoreWebView2.Find) 를 IFindApi 로 감싼 어댑터.
|
||||||
|
/// 옵션 객체는 public ctor 가 없어 CoreWebView2Environment.CreateFindOptions() 로 생성.</summary>
|
||||||
|
public sealed class WebView2FindApi : IFindApi
|
||||||
|
{
|
||||||
|
private readonly CoreWebView2 _cv;
|
||||||
|
private readonly CoreWebView2Environment _env;
|
||||||
|
|
||||||
|
public WebView2FindApi(CoreWebView2 cv, CoreWebView2Environment env)
|
||||||
|
{
|
||||||
|
_cv = cv;
|
||||||
|
_env = env;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StartAsync(string term, bool caseSensitive, bool matchWord, bool highlightAll)
|
||||||
|
{
|
||||||
|
var o = _env.CreateFindOptions();
|
||||||
|
o.FindTerm = term;
|
||||||
|
o.IsCaseSensitive = caseSensitive;
|
||||||
|
o.ShouldMatchWord = matchWord;
|
||||||
|
o.ShouldHighlightAllMatches = highlightAll;
|
||||||
|
o.SuppressDefaultFindDialog = true; // 자체 WPF 검색 바 사용
|
||||||
|
return _cv.Find.StartAsync(o);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void FindNext() => _cv.Find.FindNext();
|
||||||
|
public void FindPrevious() => _cv.Find.FindPrevious();
|
||||||
|
public void Stop() => _cv.Find.Stop();
|
||||||
|
|
||||||
|
// 이벤트 인자가 object 로 전달되므로 매치 수는 Find 프로퍼티에서 직접 읽는다.
|
||||||
|
public event EventHandler<int>? MatchCountChanged
|
||||||
|
{
|
||||||
|
add => _cv.Find.MatchCountChanged += (s, _) => value?.Invoke(s, _cv.Find.MatchCount);
|
||||||
|
remove { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler<int>? ActiveMatchIndexChanged
|
||||||
|
{
|
||||||
|
add => _cv.Find.ActiveMatchIndexChanged += (s, _) => value?.Invoke(s, _cv.Find.ActiveMatchIndex);
|
||||||
|
remove { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 페이지 내 검색 세션 상태 머신(T1-3). 용어·매치 수·활성 인덱스를 유지하고
|
||||||
|
/// UI(검색 바)가 표시할 "3 / 17" 텍스트를 제공한다.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FindSession : IDisposable
|
||||||
|
{
|
||||||
|
private readonly IFindApi _api;
|
||||||
|
|
||||||
|
public string Term { get; private set; } = string.Empty;
|
||||||
|
public int MatchCount { get; private set; }
|
||||||
|
public int ActiveMatchIndex { get; private set; }
|
||||||
|
public bool IsCaseSensitive { get; private set; }
|
||||||
|
public bool MatchWholeWord { get; private set; }
|
||||||
|
|
||||||
|
public event Action? StateChanged;
|
||||||
|
|
||||||
|
public FindSession(IFindApi api)
|
||||||
|
{
|
||||||
|
_api = api;
|
||||||
|
_api.MatchCountChanged += (_, n) => { MatchCount = n; StateChanged?.Invoke(); };
|
||||||
|
_api.ActiveMatchIndexChanged += (_, n) => { ActiveMatchIndex = n; StateChanged?.Invoke(); };
|
||||||
|
}
|
||||||
|
|
||||||
|
public string StatusText
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (MatchCount <= 0) return "결과 없음";
|
||||||
|
var oneBased = ActiveMatchIndex >= 0 ? ActiveMatchIndex + 1 : 0;
|
||||||
|
return $"{oneBased} / {MatchCount}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StartAsync(string term)
|
||||||
|
{
|
||||||
|
Term = term ?? "";
|
||||||
|
if (string.IsNullOrWhiteSpace(term))
|
||||||
|
{
|
||||||
|
Close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
MatchCount = 0;
|
||||||
|
ActiveMatchIndex = -1;
|
||||||
|
await _api.StartAsync(Term, IsCaseSensitive, MatchWholeWord, highlightAll: true);
|
||||||
|
StateChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetOptions(bool caseSensitive, bool matchWholeWord)
|
||||||
|
{
|
||||||
|
IsCaseSensitive = caseSensitive;
|
||||||
|
MatchWholeWord = matchWholeWord;
|
||||||
|
if (!string.IsNullOrWhiteSpace(Term))
|
||||||
|
_ = StartAsync(Term); // 옵션 변경은 재시작 필요(네이티브 제약)
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Next() => _api.FindNext();
|
||||||
|
public void Previous() => _api.FindPrevious();
|
||||||
|
|
||||||
|
public void Close()
|
||||||
|
{
|
||||||
|
_api.Stop();
|
||||||
|
Term = string.Empty;
|
||||||
|
MatchCount = 0;
|
||||||
|
ActiveMatchIndex = -1;
|
||||||
|
StateChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => Close();
|
||||||
|
}
|
||||||
|
|
@ -159,7 +159,7 @@
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<!-- ===== 상단 툴바 (슬림, 전체가 드래그 핸들) ===== -->
|
<!-- ===== 상단 툴바 (슬림, 전체가 드래그 핸들) ===== -->
|
||||||
<Border Grid.Row="0" Background="{StaticResource PanelBg}"
|
<Border x:Name="ToolbarRow" Grid.Row="0" Background="{StaticResource PanelBg}"
|
||||||
BorderBrush="{StaticResource AccentFadeBrush}" BorderThickness="0,0,0,2" Padding="20,9,0,9"
|
BorderBrush="{StaticResource AccentFadeBrush}" BorderThickness="0,0,0,2" Padding="20,9,0,9"
|
||||||
PreviewMouseLeftButtonDown="TitleBar_PreviewMouseLeftButtonDown">
|
PreviewMouseLeftButtonDown="TitleBar_PreviewMouseLeftButtonDown">
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
|
|
@ -217,9 +217,45 @@
|
||||||
<ColumnDefinition x:Name="RightCol" Width="420" MinWidth="56" MaxWidth="900"/>
|
<ColumnDefinition x:Name="RightCol" Width="420" MinWidth="56" MaxWidth="900"/>
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<Border Grid.Column="0" ClipToBounds="True" Background="Black">
|
<Grid Grid.Column="0">
|
||||||
<wv2:WebView2 Name="Browser"/>
|
<Grid.RowDefinitions>
|
||||||
</Border>
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<Border Grid.Row="0" ClipToBounds="True" Background="Black">
|
||||||
|
<wv2:WebView2 Name="Browser"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 페이지 내 검색 바 (T1-3): WebView2(airspace) 아래에 도킹 -->
|
||||||
|
<Border x:Name="FindBar" Grid.Row="1" Visibility="Collapsed"
|
||||||
|
Background="{StaticResource PanelBg}" BorderBrush="{StaticResource BorderSubtle}"
|
||||||
|
BorderThickness="0,1,0,0" Padding="12,6">
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
|
||||||
|
<CheckBox x:Name="FindCaseBox" Style="{StaticResource DarkCheckBox}" Content="Aa"
|
||||||
|
ToolTip="대소문자 구분" Margin="0,0,8,0" VerticalAlignment="Center"
|
||||||
|
Checked="FindOption_Changed" Unchecked="FindOption_Changed"/>
|
||||||
|
<CheckBox x:Name="FindWordBox" Style="{StaticResource DarkCheckBox}" Content="단어"
|
||||||
|
ToolTip="전체 단어 일치" Margin="0,0,10,0" VerticalAlignment="Center"
|
||||||
|
Checked="FindOption_Changed" Unchecked="FindOption_Changed"/>
|
||||||
|
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}"
|
||||||
|
BorderThickness="1" CornerRadius="7" Width="230">
|
||||||
|
<TextBox x:Name="FindBox" Style="{StaticResource DarkTextBox}" BorderThickness="0"
|
||||||
|
Background="Transparent" Padding="10,4" FontSize="12.5"
|
||||||
|
VerticalContentAlignment="Center"
|
||||||
|
TextChanged="FindBox_TextChanged" PreviewKeyDown="FindBox_PreviewKeyDown"/>
|
||||||
|
</Border>
|
||||||
|
<Button Style="{StaticResource IconButton}" Content="" ToolTip="이전 매치 (Shift+F3)"
|
||||||
|
Margin="6,0,0,0" Click="FindPrev_Click"/>
|
||||||
|
<Button Style="{StaticResource IconButton}" Content="" ToolTip="다음 매치 (F3)"
|
||||||
|
Margin="2,0,0,0" Click="FindNext_Click"/>
|
||||||
|
<TextBlock x:Name="FindStatus" Text="" Foreground="{StaticResource TextMuted}" FontSize="11.5"
|
||||||
|
VerticalAlignment="Center" Margin="10,0,4,0" MinWidth="52"/>
|
||||||
|
<Button Style="{StaticResource IconButton}" Content="" ToolTip="닫기 (Esc)"
|
||||||
|
Click="FindClose_Click"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<!-- 리사이즈 핸들 (펼침 상태에서만 노출). 드래그로 우측 패널 폭 변경. -->
|
<!-- 리사이즈 핸들 (펼침 상태에서만 노출). 드래그로 우측 패널 폭 변경. -->
|
||||||
<GridSplitter x:Name="RightSplitter" Grid.Column="1" Width="7"
|
<GridSplitter x:Name="RightSplitter" Grid.Column="1" Width="7"
|
||||||
|
|
@ -284,7 +320,7 @@
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- ===== 상태 표시줄 ===== -->
|
<!-- ===== 상태 표시줄 ===== -->
|
||||||
<Border Grid.Row="2" Background="{StaticResource PanelBg}"
|
<Border x:Name="StatusBar" Grid.Row="2" Background="{StaticResource PanelBg}"
|
||||||
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0" Padding="16,5">
|
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0" Padding="16,5">
|
||||||
<TextBlock Name="StatusText" Foreground="{StaticResource TextMuted}" FontSize="11"
|
<TextBlock Name="StatusText" Foreground="{StaticResource TextMuted}" FontSize="11"
|
||||||
TextTrimming="CharacterEllipsis" Text="준비"/>
|
TextTrimming="CharacterEllipsis" Text="준비"/>
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,13 @@ public partial class MainWindow : Window
|
||||||
private string? _pendingSocialUrl; // 타이틀 확정 시 소셜 카드 제목 갱신용
|
private string? _pendingSocialUrl; // 타이틀 확정 시 소셜 카드 제목 갱신용
|
||||||
private bool _suggesting; // 주소창 인라인 자동완성 도중 (TextChanged 재귀 방지)
|
private bool _suggesting; // 주소창 인라인 자동완성 도중 (TextChanged 재귀 방지)
|
||||||
|
|
||||||
|
// T1-4 검색엔진 / T1-7 사이트별 줌 / T1-3 페이지 내 검색 / T1-11 전체화면
|
||||||
|
private SearchEngineResolver _resolver = null!;
|
||||||
|
private SiteZoomStore _zoomStore = null!;
|
||||||
|
private FindSession? _findSession;
|
||||||
|
private bool _fullscreen;
|
||||||
|
private WindowState _stateBeforeFullscreen = WindowState.Normal;
|
||||||
|
|
||||||
// #15 위장 미디어(image/png 등) magic byte 검증용. 세션당 상한 두어 폭주 방지.
|
// #15 위장 미디어(image/png 등) magic byte 검증용. 세션당 상한 두어 폭주 방지.
|
||||||
private static readonly HttpClient ProbeClient = new(new HttpClientHandler { AllowAutoRedirect = true })
|
private static readonly HttpClient ProbeClient = new(new HttpClientHandler { AllowAutoRedirect = true })
|
||||||
{ Timeout = TimeSpan.FromSeconds(15) };
|
{ Timeout = TimeSpan.FromSeconds(15) };
|
||||||
|
|
@ -59,6 +66,9 @@ public partial class MainWindow : Window
|
||||||
BookmarksList.ItemsSource = _bookmarks;
|
BookmarksList.ItemsSource = _bookmarks;
|
||||||
HistoryList.ItemsSource = _history;
|
HistoryList.ItemsSource = _history;
|
||||||
|
|
||||||
|
_resolver = new SearchEngineResolver(_config.SearchEngine);
|
||||||
|
_zoomStore = new SiteZoomStore(Path.Combine(AppDataDir, "sitezoom.json"));
|
||||||
|
|
||||||
Loaded += async (_, _) => await InitializeBrowserAsync();
|
Loaded += async (_, _) => await InitializeBrowserAsync();
|
||||||
PreviewKeyDown += OnPreviewKeyDown;
|
PreviewKeyDown += OnPreviewKeyDown;
|
||||||
StateChanged += OnWindowStateChanged;
|
StateChanged += OnWindowStateChanged;
|
||||||
|
|
@ -127,16 +137,19 @@ public partial class MainWindow : Window
|
||||||
private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int pvAttribute, int cbAttribute);
|
private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int pvAttribute, int cbAttribute);
|
||||||
|
|
||||||
/// <summary>WebView2 영속 프로필 경로. 쿠키·세션·localStorage·캐시가 여기 보존되어 앱/빌드 재시작에도 유지.</summary>
|
/// <summary>WebView2 영속 프로필 경로. 쿠키·세션·localStorage·캐시가 여기 보존되어 앱/빌드 재시작에도 유지.</summary>
|
||||||
internal static string ProfileFolder => Path.Combine(
|
internal static string AppDataDir => Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VideoDownloader", "webview");
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VideoDownloader");
|
||||||
|
|
||||||
|
internal static string ProfileFolder => Path.Combine(AppDataDir, "webview");
|
||||||
|
|
||||||
private async Task InitializeBrowserAsync()
|
private async Task InitializeBrowserAsync()
|
||||||
{
|
{
|
||||||
|
CoreWebView2Environment? env = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 명시적 영속 프로필: 기본(exe 디렉터리) 폴더는 빌드/재배포 시 날아가 로그인이 풀림 → %AppData% 로 고정.
|
// 명시적 영속 프로필: 기본(exe 디렉터리) 폴더는 빌드/재배포 시 날아가 로그인이 풀림 → %AppData% 로 고정.
|
||||||
Directory.CreateDirectory(ProfileFolder);
|
Directory.CreateDirectory(ProfileFolder);
|
||||||
var env = await CoreWebView2Environment.CreateAsync(browserExecutableFolder: null, userDataFolder: ProfileFolder);
|
env = await CoreWebView2Environment.CreateAsync(browserExecutableFolder: null, userDataFolder: ProfileFolder);
|
||||||
var options = env.CreateCoreWebView2ControllerOptions();
|
var options = env.CreateCoreWebView2ControllerOptions();
|
||||||
if (_config.PrivateMode) options.IsInPrivateModeEnabled = true; // InPrivate: 디스크에 아무것도 저장 안 함
|
if (_config.PrivateMode) options.IsInPrivateModeEnabled = true; // InPrivate: 디스크에 아무것도 저장 안 함
|
||||||
await Browser.EnsureCoreWebView2Async(env, options);
|
await Browser.EnsureCoreWebView2Async(env, options);
|
||||||
|
|
@ -146,6 +159,20 @@ public partial class MainWindow : Window
|
||||||
var cv = Browser.CoreWebView2;
|
var cv = Browser.CoreWebView2;
|
||||||
UpdatePrivateButton();
|
UpdatePrivateButton();
|
||||||
cv.Settings.AreDevToolsEnabled = true;
|
cv.Settings.AreDevToolsEnabled = true;
|
||||||
|
|
||||||
|
// T1-3 네이티브 Find API 세션 (자체 검색 바)
|
||||||
|
_findSession = new FindSession(new WebView2FindApi(cv, env!));
|
||||||
|
_findSession.StateChanged += () => Dispatcher.BeginInvoke(new Action(() =>
|
||||||
|
{
|
||||||
|
if (_findSession != null) FindStatus.Text = _findSession.StatusText;
|
||||||
|
}));
|
||||||
|
|
||||||
|
// T1-7 사이트별 줌: 네비 완료 시 저장된 배율 적용 (WebView2 는 사이트별 영속 미보장)
|
||||||
|
cv.NavigationCompleted += (_, _) => Dispatcher.BeginInvoke(new Action(() =>
|
||||||
|
{
|
||||||
|
try { Browser.ZoomFactor = _zoomStore.Get(cv.Source); } catch { }
|
||||||
|
}));
|
||||||
|
|
||||||
cv.WebResourceResponseReceived += OnResponseReceived;
|
cv.WebResourceResponseReceived += OnResponseReceived;
|
||||||
// #4 팝업/새창을 현재 WebView2 로 강제 라우팅
|
// #4 팝업/새창을 현재 WebView2 로 강제 라우팅
|
||||||
cv.NewWindowRequested += (_, ne) =>
|
cv.NewWindowRequested += (_, ne) =>
|
||||||
|
|
@ -433,12 +460,12 @@ public partial class MainWindow : Window
|
||||||
private void NavRefresh_Click(object sender, RoutedEventArgs e) => Browser.CoreWebView2?.Reload();
|
private void NavRefresh_Click(object sender, RoutedEventArgs e) => Browser.CoreWebView2?.Reload();
|
||||||
private void NavHome_Click(object sender, RoutedEventArgs e) => NavigateTo(_config.HomeUrl);
|
private void NavHome_Click(object sender, RoutedEventArgs e) => NavigateTo(_config.HomeUrl);
|
||||||
|
|
||||||
private void NavigateTo(string? url)
|
internal void NavigateTo(string? url)
|
||||||
{
|
{
|
||||||
url = url?.Trim();
|
// T1-4: URL/검색어 판별 + 기본 엔진·키워드 전환(g, yt, ...)은 리졸버가 담당
|
||||||
if (string.IsNullOrEmpty(url)) return;
|
var intent = _resolver.Resolve(url);
|
||||||
if (!url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) url = "https://" + url;
|
if (intent == null) return;
|
||||||
Browser.CoreWebView2?.Navigate(url);
|
Browser.CoreWebView2?.Navigate(intent.Target);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 소셜 영상 페이지(YouTube/Instagram/X/...) 감지 → yt-dlp 카드 등록.
|
// 소셜 영상 페이지(YouTube/Instagram/X/...) 감지 → yt-dlp 카드 등록.
|
||||||
|
|
@ -486,24 +513,109 @@ public partial class MainWindow : Window
|
||||||
else Log("지원하는 소셜 영상 링크가 아닙니다 (YouTube·Instagram·X·TikTok·Facebook)");
|
else Log("지원하는 소셜 영상 링크가 아닙니다 (YouTube·Instagram·X·TikTok·Facebook)");
|
||||||
}
|
}
|
||||||
|
|
||||||
// #7 전역 키보드 단축키
|
// #7 전역 키보드 단축키 (+T1-3/T1-9/T1-11)
|
||||||
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
|
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
|
||||||
{
|
{
|
||||||
var cv = Browser.CoreWebView2;
|
var cv = Browser.CoreWebView2;
|
||||||
var mods = Keyboard.Modifiers;
|
var mods = Keyboard.Modifiers;
|
||||||
if (mods == ModifierKeys.Control && e.Key == Key.L) { AddressBox.Focus(); AddressBox.SelectAll(); e.Handled = true; }
|
|
||||||
|
// T1-3 검색 바가 열려 있으면 Esc 는 닫기가 우선
|
||||||
|
if (e.Key == Key.Escape && FindBar.Visibility == Visibility.Visible) { CloseFind(); e.Handled = true; }
|
||||||
|
else if (mods == ModifierKeys.Control && e.Key == Key.F) { OpenFind(); e.Handled = true; }
|
||||||
|
else if (e.Key == Key.F3)
|
||||||
|
{
|
||||||
|
if (FindBar.Visibility == Visibility.Visible)
|
||||||
|
{
|
||||||
|
if (mods == ModifierKeys.Shift) _findSession?.Previous(); else _findSession?.Next();
|
||||||
|
}
|
||||||
|
else OpenFind();
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
else if (e.Key == Key.F11) { ToggleFullscreen(); e.Handled = true; }
|
||||||
|
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.R) { ForceReload(); e.Handled = true; }
|
||||||
|
else if (mods == ModifierKeys.Control && e.Key == Key.L) { AddressBox.Focus(); AddressBox.SelectAll(); e.Handled = true; }
|
||||||
else if (e.Key == Key.F5 || (mods == ModifierKeys.Control && e.Key == Key.R)) { cv?.Reload(); e.Handled = true; }
|
else if (e.Key == Key.F5 || (mods == ModifierKeys.Control && e.Key == Key.R)) { cv?.Reload(); e.Handled = true; }
|
||||||
else if (e.Key == Key.Escape) { try { cv?.Stop(); } catch { } e.Handled = true; }
|
else if (e.Key == Key.Escape) { try { cv?.Stop(); } catch { } e.Handled = true; }
|
||||||
else if (mods == ModifierKeys.Alt && e.Key == Key.Left) { try { if (cv?.CanGoBack == true) cv.GoBack(); } catch { } e.Handled = true; }
|
else if (mods == ModifierKeys.Alt && e.Key == Key.Left) { try { if (cv?.CanGoBack == true) cv.GoBack(); } catch { } e.Handled = true; }
|
||||||
else if (mods == ModifierKeys.Alt && e.Key == Key.Right) { try { if (cv?.CanGoForward == true) cv.GoForward(); } catch { } e.Handled = true; }
|
else if (mods == ModifierKeys.Alt && e.Key == Key.Right) { try { if (cv?.CanGoForward == true) cv.GoForward(); } catch { } e.Handled = true; }
|
||||||
else if (mods == ModifierKeys.Control)
|
else if (mods == ModifierKeys.Control)
|
||||||
{
|
{
|
||||||
if (e.Key == Key.OemPlus || e.Key == Key.Add) { try { Browser.ZoomFactor = Math.Min(Browser.ZoomFactor + 0.1, 5); } catch { } e.Handled = true; }
|
if (e.Key == Key.OemPlus || e.Key == Key.Add) { try { Browser.ZoomFactor = Math.Min(Browser.ZoomFactor + 0.1, 5); PersistZoom(); } catch { } e.Handled = true; }
|
||||||
else if (e.Key == Key.OemMinus || e.Key == Key.Subtract) { try { Browser.ZoomFactor = Math.Max(Browser.ZoomFactor - 0.1, 0.1); } catch { } e.Handled = true; }
|
else if (e.Key == Key.OemMinus || e.Key == Key.Subtract) { try { Browser.ZoomFactor = Math.Max(Browser.ZoomFactor - 0.1, 0.1); PersistZoom(); } catch { } e.Handled = true; }
|
||||||
else if (e.Key == Key.D0) { try { Browser.ZoomFactor = 1.0; } catch { } e.Handled = true; }
|
else if (e.Key == Key.D0) { try { Browser.ZoomFactor = 1.0; PersistZoom(); } catch { } e.Handled = true; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- T1-3 페이지 내 검색 바 ----
|
||||||
|
internal void OpenFind()
|
||||||
|
{
|
||||||
|
FindBar.Visibility = Visibility.Visible;
|
||||||
|
FindStatus.Text = "";
|
||||||
|
FindBox.Focus();
|
||||||
|
FindBox.SelectAll();
|
||||||
|
if (FindBox.Text.Length > 0) _ = _findSession?.StartAsync(FindBox.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void CloseFind()
|
||||||
|
{
|
||||||
|
FindBar.Visibility = Visibility.Collapsed;
|
||||||
|
_findSession?.Close();
|
||||||
|
FindStatus.Text = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FindBox_TextChanged(object sender, TextChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (FindBar.Visibility == Visibility.Visible)
|
||||||
|
_ = _findSession?.StartAsync(FindBox.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FindBox_PreviewKeyDown(object sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == Key.Enter) { if (Keyboard.Modifiers == ModifierKeys.Shift) _findSession?.Previous(); else _findSession?.Next(); e.Handled = true; }
|
||||||
|
else if (e.Key == Key.Escape) { CloseFind(); e.Handled = true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FindNext_Click(object sender, RoutedEventArgs e) => _findSession?.Next();
|
||||||
|
private void FindPrev_Click(object sender, RoutedEventArgs e) => _findSession?.Previous();
|
||||||
|
private void FindClose_Click(object sender, RoutedEventArgs e) => CloseFind();
|
||||||
|
|
||||||
|
private void FindOption_Changed(object sender, RoutedEventArgs e)
|
||||||
|
=> _findSession?.SetOptions(FindCaseBox.IsChecked == true, FindWordBox.IsChecked == true);
|
||||||
|
|
||||||
|
// T1-7 줌 변경을 사이트별 저장소에 영속
|
||||||
|
private void PersistZoom()
|
||||||
|
{
|
||||||
|
var cv = Browser.CoreWebView2;
|
||||||
|
if (cv != null) _zoomStore.Set(cv.Source, Browser.ZoomFactor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// T1-9 강제 새로고침: 디스크 캐시 비우고 다시 로드
|
||||||
|
private async void ForceReload()
|
||||||
|
{
|
||||||
|
var cv = Browser.CoreWebView2;
|
||||||
|
if (cv == null) return;
|
||||||
|
try { await cv.Profile.ClearBrowsingDataAsync(CoreWebView2BrowsingDataKinds.DiskCache); }
|
||||||
|
catch { }
|
||||||
|
try { cv.Reload(); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// T1-11 전체화면(F11): 툴바/상태바 숨기고 최대화. 재누르면 복원.
|
||||||
|
internal void ToggleFullscreen()
|
||||||
|
{
|
||||||
|
_fullscreen = !_fullscreen;
|
||||||
|
if (_fullscreen)
|
||||||
|
{
|
||||||
|
_stateBeforeFullscreen = WindowState;
|
||||||
|
WindowState = WindowState.Maximized;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
WindowState = _stateBeforeFullscreen;
|
||||||
|
}
|
||||||
|
ToolbarRow.Visibility = _fullscreen ? Visibility.Collapsed : Visibility.Visible;
|
||||||
|
StatusBar.Visibility = _fullscreen ? Visibility.Collapsed : Visibility.Visible;
|
||||||
|
}
|
||||||
|
|
||||||
private static string MakeUniqueDownloadName(string folder, string file)
|
private static string MakeUniqueDownloadName(string folder, string file)
|
||||||
{
|
{
|
||||||
var p = Path.Combine(folder, file);
|
var p = Path.Combine(folder, file);
|
||||||
|
|
@ -746,6 +858,7 @@ public partial class MainWindow : Window
|
||||||
{
|
{
|
||||||
_config = w.Config;
|
_config = w.Config;
|
||||||
ConfigStore.Save(_config);
|
ConfigStore.Save(_config);
|
||||||
|
_resolver = new SearchEngineResolver(_config.SearchEngine); // T1-4 즉시 반영
|
||||||
_manager.UpdateMaxConcurrent(_config.MaxConcurrentDownloads); // #19 런타임 반영
|
_manager.UpdateMaxConcurrent(_config.MaxConcurrentDownloads); // #19 런타임 반영
|
||||||
if (w.ThemeChanged) RestartApp();
|
if (w.ThemeChanged) RestartApp();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,14 @@
|
||||||
<TextBox Name="HomeBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
|
<TextBox Name="HomeBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<StackPanel Margin="0,0,0,18">
|
||||||
|
<TextBlock Text="기본 검색 엔진 (주소창에 검색어 입력 시)" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
|
||||||
|
<ComboBox Name="SearchEngineBox" Style="{StaticResource DarkComboBox}" Margin="0,6,0,4"
|
||||||
|
SelectionChanged="SearchEngineBox_SelectionChanged"/>
|
||||||
|
<TextBlock x:Name="SearchEngineHint" FontSize="11" Foreground="{StaticResource TextMuted}" TextWrapping="Wrap"
|
||||||
|
Text="주소창에 `g 검색어`, `yt 검색어` 등 키워드를 입력하면 검색 엔진을 즉시 전환할 수 있어요"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<Grid Margin="0,0,0,18">
|
<Grid Margin="0,0,0,18">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*"/>
|
<ColumnDefinition Width="*"/>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ using System.Windows.Controls;
|
||||||
using System.Windows.Input;
|
using System.Windows.Input;
|
||||||
using System.Windows.Interop;
|
using System.Windows.Interop;
|
||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
|
using VideoDownloader.App.Browser;
|
||||||
using VideoDownloader.Core.Config;
|
using VideoDownloader.Core.Config;
|
||||||
using VideoDownloader.Core.Social;
|
using VideoDownloader.Core.Social;
|
||||||
|
|
||||||
|
|
@ -54,6 +55,19 @@ public partial class SettingsWindow : Window
|
||||||
ProfileModeText.Text = config.PrivateMode
|
ProfileModeText.Text = config.PrivateMode
|
||||||
? "개인정보 보호 모드(InPrivate) — 데이터를 저장하지 않습니다"
|
? "개인정보 보호 모드(InPrivate) — 데이터를 저장하지 않습니다"
|
||||||
: "일반 모드 — 쿠키·세션·localStorage 를 프로필에 보존합니다";
|
: "일반 모드 — 쿠키·세션·localStorage 를 프로필에 보존합니다";
|
||||||
|
|
||||||
|
SearchEngineBox.ItemsSource = SearchEngines.Presets.Select(e => e.Name).ToList();
|
||||||
|
SearchEngineBox.SelectedItem = SearchEngines.FindByName(config.SearchEngine)?.Name ?? "Google";
|
||||||
|
if (SearchEngineBox.SelectedIndex < 0) SearchEngineBox.SelectedIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SearchEngineBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (SearchEngineHint == null) return;
|
||||||
|
var engine = SearchEngines.FindByName(SearchEngineBox.SelectedItem as string);
|
||||||
|
SearchEngineHint.Text = engine == null
|
||||||
|
? "주소창에 검색어를 입력하면 검색합니다"
|
||||||
|
: $"주소창에 검색어 입력 시 {engine.Name}(으)로 검색합니다. `g 검색어`처럼 키워드로 즉시 전환도 가능해요";
|
||||||
}
|
}
|
||||||
|
|
||||||
private void BrowseFolder(object sender, RoutedEventArgs e)
|
private void BrowseFolder(object sender, RoutedEventArgs e)
|
||||||
|
|
@ -134,6 +148,7 @@ public partial class SettingsWindow : Window
|
||||||
Config.AutoInstallDeno = AutoInstallDenoBox.IsChecked == true;
|
Config.AutoInstallDeno = AutoInstallDenoBox.IsChecked == true;
|
||||||
Config.DefaultSocialQuality = (DefaultQualityBox.SelectedValue as string) ?? "best";
|
Config.DefaultSocialQuality = (DefaultQualityBox.SelectedValue as string) ?? "best";
|
||||||
Config.YoutubePlayerClients = string.IsNullOrWhiteSpace(YoutubeClientsBox.Text) ? null : YoutubeClientsBox.Text.Trim();
|
Config.YoutubePlayerClients = string.IsNullOrWhiteSpace(YoutubeClientsBox.Text) ? null : YoutubeClientsBox.Text.Trim();
|
||||||
|
Config.SearchEngine = SearchEngineBox.SelectedItem as string ?? "Google";
|
||||||
|
|
||||||
DialogResult = true;
|
DialogResult = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,4 +33,9 @@
|
||||||
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.4078.44" />
|
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.4078.44" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- E2E 테스트(InternalsVisibleTo) -->
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="VideoDownloader.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -67,4 +67,7 @@ public class AppConfig
|
||||||
|
|
||||||
/// <summary>개인정보 보호(InPrivate) 모드: 쿠키·세션·localStorage 를 디스크에 저장하지 않는다. 적용 시 앱 재시작.</summary>
|
/// <summary>개인정보 보호(InPrivate) 모드: 쿠키·세션·localStorage 를 디스크에 저장하지 않는다. 적용 시 앱 재시작.</summary>
|
||||||
public bool PrivateMode { get; set; } = false;
|
public bool PrivateMode { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>주소창 기본 검색 엔진(SearchEngines.Presets 의 Name). 검색어 입력 시 이 엔진으로 검색.</summary>
|
||||||
|
public string SearchEngine { get; set; } = "Google";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
98
VideoDownloader.Tests/Browser/FindSessionTests.cs
Normal file
98
VideoDownloader.Tests/Browser/FindSessionTests.cs
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
using VideoDownloader.App.Browser;
|
||||||
|
|
||||||
|
namespace VideoDownloader.Tests.Browser;
|
||||||
|
|
||||||
|
// T1-3 페이지 내 검색: FindSession 은 네이티브 Find API(IFindApi) 위의 상태 머신.
|
||||||
|
public class FindSessionTests
|
||||||
|
{
|
||||||
|
private sealed class FakeFindApi : IFindApi
|
||||||
|
{
|
||||||
|
public string? LastTerm;
|
||||||
|
public int StartCount, NextCount, PrevCount, StopCount;
|
||||||
|
public int MatchCount { get; set; }
|
||||||
|
public int ActiveIndex { get; set; }
|
||||||
|
|
||||||
|
public event EventHandler<int>? MatchCountChanged;
|
||||||
|
public event EventHandler<int>? 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
VideoDownloader.Tests/E2E/MainWindowSmokeE2ETests.cs
Normal file
58
VideoDownloader.Tests/E2E/MainWindowSmokeE2ETests.cs
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
using System.Windows;
|
||||||
|
using VideoDownloader.App;
|
||||||
|
using VideoDownloader.Tests.Infrastructure;
|
||||||
|
|
||||||
|
namespace VideoDownloader.Tests.E2E;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// E2E 스모크: 실제 MainWindow 를 기동해 WebView2 초기화·탐색·검색 바·전체화면 전환이
|
||||||
|
/// 크래시 없이 동작하는지 검증한다(Phase 0 통합 확인). 네트워크 의존 제거를 위해 about:blank 사용.
|
||||||
|
/// </summary>
|
||||||
|
[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, "전체화면 복원 실패");
|
||||||
|
}
|
||||||
|
}
|
||||||
71
VideoDownloader.Tests/E2E/WebView2FindE2ETests.cs
Normal file
71
VideoDownloader.Tests/E2E/WebView2FindE2ETests.cs
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using Microsoft.Web.WebView2.Core;
|
||||||
|
using VideoDownloader.Tests.Infrastructure;
|
||||||
|
|
||||||
|
namespace VideoDownloader.Tests.E2E;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// E2E: 실제 WebView2 + 네이티브 Find API(CoreWebView2.Find) 검증(T1-3).
|
||||||
|
/// WebView2 런타임 위에서 매치 카운트/활성 인덱스가 실제로 발생하는지 확인한다.
|
||||||
|
/// </summary>
|
||||||
|
[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 = "<html><body><p>hello world hello brave hello</p></body></html>";
|
||||||
|
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<CoreWebView2NavigationCompletedEventArgs>? h = null;
|
||||||
|
h = (_, _) => { cv.NavigationCompleted -= h; tcs.TrySetResult(); };
|
||||||
|
cv.NavigationCompleted += h;
|
||||||
|
return tcs.Task.WaitAsync(TimeSpan.FromSeconds(15));
|
||||||
|
}
|
||||||
|
}
|
||||||
33
VideoDownloader.Tests/Infrastructure/WpfTestApp.cs
Normal file
33
VideoDownloader.Tests/Infrastructure/WpfTestApp.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
using System.Windows;
|
||||||
|
using VideoDownloader.App;
|
||||||
|
|
||||||
|
namespace VideoDownloader.Tests.Infrastructure;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 테스트 프로세스에는 App.xaml 의 리소스가 없으므로, App 어셈블리의 테마 사전을
|
||||||
|
/// 테스트 전용 Application 에 로드한다(앱의 App.ApplyTheme 과 동일 순서: 색 → 스타일).
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue