From 470c9267f2f014d8b58f537ef0df68e1031b566d Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Sat, 15 Aug 2026 12:04:23 +0900 Subject: [PATCH] T1-4/T1-7: search engine resolver + site zoom store (TDD RED->GREEN, 32 tests) --- VideoDownloader.App/Browser/SearchEngine.cs | 34 ++++++ .../Browser/SearchEngineResolver.cs | 80 +++++++++++++ VideoDownloader.App/Browser/SiteZoomStore.cs | 106 ++++++++++++++++ .../Browser/SearchEngineResolverTests.cs | 113 ++++++++++++++++++ .../Browser/SiteZoomStoreTests.cs | 95 +++++++++++++++ VideoDownloader.Tests/GlobalUsings.cs | 2 + .../Infrastructure/StaDispatcher.cs | 72 +++++++++++ .../VideoDownloader.Tests.csproj | 19 +++ VideoDownloader.slnx | 1 + 9 files changed, 522 insertions(+) create mode 100644 VideoDownloader.App/Browser/SearchEngine.cs create mode 100644 VideoDownloader.App/Browser/SearchEngineResolver.cs create mode 100644 VideoDownloader.App/Browser/SiteZoomStore.cs create mode 100644 VideoDownloader.Tests/Browser/SearchEngineResolverTests.cs create mode 100644 VideoDownloader.Tests/Browser/SiteZoomStoreTests.cs create mode 100644 VideoDownloader.Tests/GlobalUsings.cs create mode 100644 VideoDownloader.Tests/Infrastructure/StaDispatcher.cs create mode 100644 VideoDownloader.Tests/VideoDownloader.Tests.csproj diff --git a/VideoDownloader.App/Browser/SearchEngine.cs b/VideoDownloader.App/Browser/SearchEngine.cs new file mode 100644 index 0000000..8138bf3 --- /dev/null +++ b/VideoDownloader.App/Browser/SearchEngine.cs @@ -0,0 +1,34 @@ +namespace VideoDownloader.App.Browser; + +/// 검색엔진 정의. QueryTemplate 의 {0} 이 URL 인코딩된 검색어로 치환된다. +public sealed record SearchEngine(string Name, string Keyword, string QueryTemplate); + +/// 기본 프리셋 엔진. Keyword 는 주소창에서 `<키워드> <검색어>` 형태로 즉시 전환에 쓰인다. +public static class SearchEngines +{ + public static readonly IReadOnlyList Presets = new[] + { + new SearchEngine("Google", "g", "https://www.google.com/search?q={0}"), + new SearchEngine("Bing", "b", "https://www.bing.com/search?q={0}"), + new SearchEngine("DuckDuckGo", "d", "https://duckduckgo.com/?q={0}"), + new SearchEngine("Naver", "n", "https://search.naver.com/search.naver?query={0}"), + new SearchEngine("YouTube", "yt", "https://www.youtube.com/results?search_query={0}"), + }; + + public static SearchEngine? FindByName(string? name, IEnumerable? extra = null) + { + var pool = extra != null ? Presets.Concat(extra) : Presets; + return pool.FirstOrDefault(e => + string.Equals(e.Name, name, StringComparison.OrdinalIgnoreCase)); + } + + public static SearchEngine? FindByKeyword(string? keyword, IEnumerable? extra = null) + { + var pool = extra != null ? Presets.Concat(extra) : Presets; + return pool.FirstOrDefault(e => + string.Equals(e.Keyword, keyword, StringComparison.OrdinalIgnoreCase)); + } +} + +/// 주소창 입력에 대한 네비게이션 의도. IsUrl=false 면 Target 은 검색 결과 URL. +public sealed record NavigationIntent(bool IsUrl, string Target, string? EngineName = null); diff --git a/VideoDownloader.App/Browser/SearchEngineResolver.cs b/VideoDownloader.App/Browser/SearchEngineResolver.cs new file mode 100644 index 0000000..a56e306 --- /dev/null +++ b/VideoDownloader.App/Browser/SearchEngineResolver.cs @@ -0,0 +1,80 @@ +using System.Text.RegularExpressions; + +namespace VideoDownloader.App.Browser; + +/// +/// 주소창 입력을 URL 또는 검색 의도로 판별한다(T1-4). +/// 판별 순서: ① 명시적 스킴 ② localhost/IPv4 ③ 도메인 형태 ④ 키워드 접두(`g `, `yt `) ⑤ 기본 엔진 검색. +/// +public sealed partial class SearchEngineResolver +{ + private readonly SearchEngine _default; + private readonly List _custom; + + public SearchEngineResolver(string defaultEngineName, IEnumerable? customEngines = null) + { + _default = SearchEngines.FindByName(defaultEngineName, customEngines) + ?? SearchEngines.Presets[0]; + _custom = customEngines?.ToList() ?? new(); + } + + public SearchEngine DefaultEngine => _default; + + /// 입력이 비었으면 null, URL 이면 URL 의도, 아니면 검색 결과 URL 의도. + public NavigationIntent? Resolve(string? input) + { + input = input?.Trim(); + if (string.IsNullOrEmpty(input)) return null; + + if (HasScheme(input)) + return new NavigationIntent(true, input); + + if (IsLocalHost(input) || IsIpv4(input)) + return new NavigationIntent(true, "https://" + input); + + if (LooksLikeDomain(input)) + return new NavigationIntent(true, "https://" + input); + + // 키워드 전환: "g 고양이" → Google 검색. 미등록 키워드는 검색어의 일부. + if (MatchKeyword(input) is { } kw) + return new NavigationIntent(false, Format(kw.engine, kw.query), kw.engine.Name); + + return new NavigationIntent(false, Format(_default, input), _default.Name); + } + + private static string Format(SearchEngine engine, string query) + => string.Format(engine.QueryTemplate, Uri.EscapeDataString(query).Replace("%20", "+")); + + private (SearchEngine engine, string query)? MatchKeyword(string input) + { + var m = KeywordPrefix().Match(input); + if (!m.Success) return null; + var engine = SearchEngines.FindByKeyword(m.Groups[1].Value, _custom); + return engine == null ? null : (engine, m.Groups[2].Value); + } + + private static bool HasScheme(string s) => + s.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || s.StartsWith("https://", StringComparison.OrdinalIgnoreCase) + || s.StartsWith("file://", StringComparison.OrdinalIgnoreCase) + || s.StartsWith("about:", StringComparison.OrdinalIgnoreCase) + || s.StartsWith("data:", StringComparison.OrdinalIgnoreCase) + || s.StartsWith("blob:", StringComparison.OrdinalIgnoreCase); + + private static bool IsLocalHost(string s) => + s.Equals("localhost", StringComparison.OrdinalIgnoreCase) + || s.StartsWith("localhost:", StringComparison.OrdinalIgnoreCase); + + private static bool IsIpv4(string s) => + s.Length > 7 && s[0] >= '0' && s[0] <= '9' && Regex.IsMatch(s, @"^\d{1,3}(\.\d{1,3}){3}(:\d+)?([/?#].*)?$"); + + private static bool LooksLikeDomain(string s) => + DomainPattern().IsMatch(s); + + // 라벨.라벨[.라벨...] 형태(최소 점 1개) + 선택적 포트/경로. 공백 포함 시 불일치. + [GeneratedRegex(@"^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)+(:\d+)?([/?#].*)?$")] + private static partial Regex DomainPattern(); + + [GeneratedRegex(@"^(\S+)\s+(.+)$", RegexOptions.Singleline)] + private static partial Regex KeywordPrefix(); +} diff --git a/VideoDownloader.App/Browser/SiteZoomStore.cs b/VideoDownloader.App/Browser/SiteZoomStore.cs new file mode 100644 index 0000000..81f63f3 --- /dev/null +++ b/VideoDownloader.App/Browser/SiteZoomStore.cs @@ -0,0 +1,106 @@ +using System.IO; +using System.Text.Json; + +namespace VideoDownloader.App.Browser; + +/// +/// 사이트별 줌 배율 영속 저장소(T1-7). WebView2 는 사이트별 줌 영속을 보장하지 않아 +/// 앱이 도메인→배율 사전을 SSOT 로 관리한다. LRU 300개, 저장 즉시 원자적 파일 기록. +/// +public sealed class SiteZoomStore +{ + private sealed record Entry(double Zoom, long LastUsedTicks); + + private const int MaxEntries = 300; + private const double Min = 0.1, Max = 5.0; + + private readonly string _path; + private readonly object _lock = new(); + private readonly Dictionary _map = new(StringComparer.OrdinalIgnoreCase); + + public SiteZoomStore(string path) + { + _path = path; + try + { + if (File.Exists(path)) + { + var loaded = JsonSerializer.Deserialize>(File.ReadAllText(path)); + if (loaded != null) + foreach (var (k, v) in loaded) _map[k] = v; + } + } + catch + { + // 손상된 JSON → 빈 상태로 시작(기존 BrowserStore 정책과 동일) + _map.Clear(); + } + } + + public int Count { get { lock (_lock) return _map.Count; } } + + /// URL 의 호스트에 저장된 배율. 없으면 1.0. 읽기는 LRU 를 갱신한다. + public double Get(string? url) + { + var host = NormalizeHost(url); + if (host == null) return 1.0; + lock (_lock) + { + if (_map.TryGetValue(host, out var e)) + { + _map[host] = e with { LastUsedTicks = DateTime.UtcNow.Ticks }; + return e.Zoom; + } + } + return 1.0; + } + + /// 배율 저장(0.1~5.0 클램프). 초과분은 가장 오래된 사용 순으로 제거 후 즉시 영속. + public void Set(string? url, double zoom) + { + var host = NormalizeHost(url); + if (host == null) return; + zoom = Math.Clamp(zoom, Min, Max); + lock (_lock) + { + _map[host] = new Entry(zoom, DateTime.UtcNow.Ticks); + if (_map.Count > MaxEntries) + foreach (var k in _map.OrderBy(kv => kv.Value.LastUsedTicks).Take(_map.Count - MaxEntries).Select(kv => kv.Key).ToList()) + _map.Remove(k); + Save(); + } + } + + public void Clear() + { + lock (_lock) + { + _map.Clear(); + try { if (File.Exists(_path)) File.Delete(_path); } catch { } + } + } + + private void Save() + { + try + { + var dir = Path.GetDirectoryName(_path); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + var tmp = _path + ".tmp"; + File.WriteAllText(tmp, JsonSerializer.Serialize(_map)); + File.Move(tmp, _path, overwrite: true); + } + catch + { + // 저장 실패는 메모리 상태는 유지(조용한 내구성 저하) + } + } + + private static string? NormalizeHost(string? url) + { + if (string.IsNullOrWhiteSpace(url)) return null; + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || string.IsNullOrEmpty(uri.Host)) return null; + var host = uri.Host; + return host.StartsWith("www.", StringComparison.OrdinalIgnoreCase) ? host[4..] : host; + } +} diff --git a/VideoDownloader.Tests/Browser/SearchEngineResolverTests.cs b/VideoDownloader.Tests/Browser/SearchEngineResolverTests.cs new file mode 100644 index 0000000..105bffc --- /dev/null +++ b/VideoDownloader.Tests/Browser/SearchEngineResolverTests.cs @@ -0,0 +1,113 @@ +using VideoDownloader.App.Browser; + +namespace VideoDownloader.Tests.Browser; + +// T1-4 검색엔진: 주소창 입력이 URL인지 검색어인지 판별하고, 키워드(g, yt, ...)로 엔진을 전환한다. +public class SearchEngineResolverTests +{ + private readonly SearchEngineResolver _resolver = new("Google"); + + [Theory] + [InlineData("https://example.com", "https://example.com")] // 스킴 있음 → 그대로 + [InlineData("http://example.com/path?q=1", "http://example.com/path?q=1")] + [InlineData("example.com", "https://example.com")] // 도메인 → https 보정 + [InlineData("www.example.co.kr/a/b", "https://www.example.co.kr/a/b")] + [InlineData("sub.domain.org", "https://sub.domain.org")] + [InlineData("localhost", "https://localhost")] // localhost → URL + [InlineData("localhost:8080/x", "https://localhost:8080/x")] + [InlineData("192.168.1.10", "https://192.168.1.10")] // IPv4 → URL + [InlineData("192.168.1.10:9000/v.html", "https://192.168.1.10:9000/v.html")] + [InlineData("file:///C:/tmp/a.html", "file:///C:/tmp/a.html")] + public void Url_입력은_URL_의도로_판별한다(string input, string expected) + { + var intent = _resolver.Resolve(input); + Assert.True(intent.IsUrl); + Assert.Equal(expected, intent.Target); + } + + [Theory] + [InlineData("hello world")] // 공백 포함 일반어 → 검색 + [InlineData("cats")] // 점 없는 단일 단어 → 검색 + [InlineData("how to download mp4")] + [InlineData("나는 한국어 검색어")] + public void 검색어_입력은_기본_엔진으로_검색한다(string input) + { + var intent = _resolver.Resolve(input); + Assert.False(intent.IsUrl); + Assert.Equal("https://www.google.com/search?q=" + Uri.EscapeDataString(input).Replace("%20", "+"), intent.Target); + Assert.Equal("Google", intent.EngineName); + } + + [Fact] + public void 키워드_g는_Google로_라우팅한다() + { + var intent = _resolver.Resolve("g cute cats"); + Assert.False(intent.IsUrl); + Assert.Contains("google.com/search", intent.Target); + Assert.Contains("cute+cats", intent.Target); + } + + [Fact] + public void 키워드_yt는_YouTube_검색으로_라우팅한다() + { + var intent = _resolver.Resolve("yt lofi beats"); + Assert.False(intent.IsUrl); + Assert.Contains("youtube.com/results", intent.Target); + Assert.Equal("YouTube", intent.EngineName); + } + + [Fact] + public void 기본_엔진을_Bing으로_바꾸면_일반_검색어가_Bing으로_간다() + { + var r = new SearchEngineResolver("Bing"); + var intent = r.Resolve("weather today"); + Assert.False(intent.IsUrl); + Assert.Contains("bing.com/search", intent.Target); + } + + [Fact] + public void 알_수_없는_키워드는_검색어의_일부로_취급한다() + { + // "zzz query" — zzz 는 등록된 키워드가 아님 → 전체가 기본 엔진 검색어 + var intent = _resolver.Resolve("zzz query"); + Assert.False(intent.IsUrl); + Assert.Contains("zzz+query", intent.Target); + } + + [Fact] + public void 키워드_단독_입력은_검색어로_취급한다() + { + var intent = _resolver.Resolve("g"); + Assert.False(intent.IsUrl); + Assert.EndsWith("q=g", intent.Target); // "g" 자체를 기본 엔진으로 검색 + } + + [Fact] + public void 커스텀_엔진_키워드를_등록하면_사용_가능하다() + { + var r = new SearchEngineResolver("Google", customEngines: + new[] { new SearchEngine("Wikipedia", "w", "https://en.wikipedia.org/wiki/Special:Search?search={0}") }); + var intent = r.Resolve("w mars rover"); + Assert.False(intent.IsUrl); + Assert.Contains("wikipedia.org", intent.Target); + Assert.Equal("Wikipedia", intent.EngineName); + } + + [Fact] + public void 빈_입력은_Null을_반환한다() + { + Assert.Null(_resolver.Resolve("")); + Assert.Null(_resolver.Resolve(" ")); + } + + [Fact] + public void 프리셋에는_주요_엔진이_포함된다() + { + var names = SearchEngines.Presets.Select(e => e.Name).ToList(); + Assert.Contains("Google", names); + Assert.Contains("Bing", names); + Assert.Contains("DuckDuckGo", names); + Assert.Contains("Naver", names); // 한국 사용자 + Assert.Contains("YouTube", names); + } +} diff --git a/VideoDownloader.Tests/Browser/SiteZoomStoreTests.cs b/VideoDownloader.Tests/Browser/SiteZoomStoreTests.cs new file mode 100644 index 0000000..72f6f46 --- /dev/null +++ b/VideoDownloader.Tests/Browser/SiteZoomStoreTests.cs @@ -0,0 +1,95 @@ +using VideoDownloader.App.Browser; + +namespace VideoDownloader.Tests.Browser; + +// T1-7 사이트별 줌 영속: 도메인→배율 저장, 범위 클램프, JSON 영속, 최대 항목 제한. +public class SiteZoomStoreTests : IDisposable +{ + private readonly string _path; + private readonly SiteZoomStore _store; + + public SiteZoomStoreTests() + { + _path = Path.Combine(Path.GetTempPath(), $"zoomstore_{Guid.NewGuid():N}.json"); + _store = new SiteZoomStore(_path); + } + + public void Dispose() + { + try { File.Delete(_path); } catch { } + } + + [Fact] + public void 미등록_사이트는_기본값_1_0을_반환한다() + { + Assert.Equal(1.0, _store.Get("https://example.com/page")); + } + + [Fact] + public void 배율을_저장하면_같은_호스트의_다른_경로에서도_읽힌다() + { + _store.Set("https://example.com/a", 1.5); + Assert.Equal(1.5, _store.Get("https://example.com/b/c/d")); + } + + [Fact] + public void www_접두사는_동일_호스트로_정규화된다() + { + _store.Set("https://www.example.com/a", 2.0); + Assert.Equal(2.0, _store.Get("https://example.com/b")); + } + + [Theory] + [InlineData(0.05, 0.1)] // 하한 클램프 + [InlineData(9.0, 5.0)] // 상한 클램프 + [InlineData(1.25, 1.25)] // 정상 범위 + public void 배율은_0_1에서_5_0으로_클램프된다(double input, double expected) + { + _store.Set("https://clamp.com/", input); + Assert.Equal(expected, _store.Get("https://clamp.com/")); + } + + [Fact] + public void 저장_즉시_파일에_영속되어_새_인스턴스가_읽는다() + { + _store.Set("https://persist.com/", 1.75); + Assert.True(File.Exists(_path)); + var reloaded = new SiteZoomStore(_path); + Assert.Equal(1.75, reloaded.Get("https://persist.com/other")); + } + + [Fact] + public void 최대_300개_초과시_가장_오래_사용하지_한_항목부터_제거된다() + { + // 300개 채우기 + for (int i = 0; i < 300; i++) + _store.Set($"https://site{i}.com/", 1.0 + i * 0.001); + // 가장 먼저 넣은 site0 을 최근에 다시 설정(LRU 갱신) → site1 이 제거 후보가 됨 + _store.Set("https://site0.com/", 1.9); + // 하나 더 추가 → 초과분 제거 + _store.Set("https://new.com/", 2.0); + + Assert.Equal(300, _store.Count); + Assert.Equal(1.0, _store.Get("https://site1.com/")); // 제거됨 → 기본값 + Assert.Equal(2.0, _store.Get("https://new.com/")); // 유지 + Assert.Equal(1.9, _store.Get("https://site0.com/")); // LRU 갱신됨 → 유지 + } + + [Fact] + public void Clear는_모든_항목과_파일을_지운다() + { + _store.Set("https://x.com/", 2.0); + _store.Clear(); + Assert.Equal(0, _store.Count); + Assert.Equal(1.0, _store.Get("https://x.com/")); + Assert.False(File.Exists(_path)); + } + + [Fact] + public void 손상된_JSON은_무시하고_빈_상태로_시작한다() + { + File.WriteAllText(_path, "{ not json !!"); + var store = new SiteZoomStore(_path); + Assert.Equal(0, store.Count); + } +} diff --git a/VideoDownloader.Tests/GlobalUsings.cs b/VideoDownloader.Tests/GlobalUsings.cs new file mode 100644 index 0000000..8bc619f --- /dev/null +++ b/VideoDownloader.Tests/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Xunit; +global using System.IO; diff --git a/VideoDownloader.Tests/Infrastructure/StaDispatcher.cs b/VideoDownloader.Tests/Infrastructure/StaDispatcher.cs new file mode 100644 index 0000000..9be198f --- /dev/null +++ b/VideoDownloader.Tests/Infrastructure/StaDispatcher.cs @@ -0,0 +1,72 @@ +using System.Windows.Threading; + +namespace VideoDownloader.Tests.Infrastructure; + +/// +/// WPF/WebView2 E2E 테스트용 STA 디스패처. 전용 STA 스레드에서 Dispatcher.Run() 으로 +/// 메시지 펌프를 돌리며, 테스트 코드는 InvokeAsync 로 UI 스레드에 작업을 올린다. +/// WebView2 이벤트(비동기)는 UI 스레드 디스패처에서 발생하므로 async 흐름도 이 펌프 위에서 완결된다. +/// +public sealed class StaDispatcher : IDisposable +{ + private readonly Thread _thread; + private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously); + private Dispatcher? _dispatcher; + + private StaDispatcher(Thread thread) + { + _thread = thread; + } + + public Dispatcher Dispatcher => + _dispatcher ?? throw new InvalidOperationException("STA dispatcher not started"); + + public static StaDispatcher Start() + { + var sta = default(StaDispatcher)!; + var thread = new Thread(() => + { + sta._dispatcher = Dispatcher.CurrentDispatcher; + sta._ready.TrySetResult(); + Dispatcher.Run(); + }); + thread.SetApartmentState(ApartmentState.STA); + thread.IsBackground = true; + sta = new StaDispatcher(thread); + thread.Start(); + sta._ready.Task.Wait(5000); + return sta; + } + + public Task InvokeAsync(Func> func) + => Dispatcher.InvokeAsync(func).Task.Unwrap(); + + public Task InvokeAsync(Func func) + => Dispatcher.InvokeAsync(func).Task.Unwrap(); + + public T Invoke(Func func) => Dispatcher.Invoke(func); + + public void Invoke(Action action) => Dispatcher.Invoke(action); + + public void Dispose() + { + try { Dispatcher.InvokeShutdown(); } catch { } + try { _thread.Join(5000); } catch { } + } +} + +/// WebView2 비동기 초기화/이벤트 대기 공통 유틸. +public static class Wait +{ + public static async Task Until(Func> poll, Func done, int timeoutMs = 15000, int intervalMs = 100) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (sw.ElapsedMilliseconds < timeoutMs) + { + var v = await poll(); + if (done(v)) return v; + await Task.Delay(intervalMs); + } + throw new TimeoutException($"조건 미충족 ({timeoutMs}ms 초과)"); + } +} diff --git a/VideoDownloader.Tests/VideoDownloader.Tests.csproj b/VideoDownloader.Tests/VideoDownloader.Tests.csproj new file mode 100644 index 0000000..edc0567 --- /dev/null +++ b/VideoDownloader.Tests/VideoDownloader.Tests.csproj @@ -0,0 +1,19 @@ + + + net8.0-windows + enable + enable + false + true + true + + + + + + + + + + + diff --git a/VideoDownloader.slnx b/VideoDownloader.slnx index 1415fc8..a6b90d2 100644 --- a/VideoDownloader.slnx +++ b/VideoDownloader.slnx @@ -3,4 +3,5 @@ +