T1-4/T1-7: search engine resolver + site zoom store (TDD RED->GREEN, 32 tests)
This commit is contained in:
parent
21b7c4d8bd
commit
470c9267f2
9 changed files with 522 additions and 0 deletions
113
VideoDownloader.Tests/Browser/SearchEngineResolverTests.cs
Normal file
113
VideoDownloader.Tests/Browser/SearchEngineResolverTests.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
95
VideoDownloader.Tests/Browser/SiteZoomStoreTests.cs
Normal file
95
VideoDownloader.Tests/Browser/SiteZoomStoreTests.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
2
VideoDownloader.Tests/GlobalUsings.cs
Normal file
2
VideoDownloader.Tests/GlobalUsings.cs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
global using Xunit;
|
||||
global using System.IO;
|
||||
72
VideoDownloader.Tests/Infrastructure/StaDispatcher.cs
Normal file
72
VideoDownloader.Tests/Infrastructure/StaDispatcher.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
using System.Windows.Threading;
|
||||
|
||||
namespace VideoDownloader.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// WPF/WebView2 E2E 테스트용 STA 디스패처. 전용 STA 스레드에서 Dispatcher.Run() 으로
|
||||
/// 메시지 펌프를 돌리며, 테스트 코드는 InvokeAsync 로 UI 스레드에 작업을 올린다.
|
||||
/// WebView2 이벤트(비동기)는 UI 스레드 디스패처에서 발생하므로 async 흐름도 이 펌프 위에서 완결된다.
|
||||
/// </summary>
|
||||
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<T> InvokeAsync<T>(Func<Task<T>> func)
|
||||
=> Dispatcher.InvokeAsync(func).Task.Unwrap();
|
||||
|
||||
public Task InvokeAsync(Func<Task> func)
|
||||
=> Dispatcher.InvokeAsync(func).Task.Unwrap();
|
||||
|
||||
public T Invoke<T>(Func<T> func) => Dispatcher.Invoke(func);
|
||||
|
||||
public void Invoke(Action action) => Dispatcher.Invoke(action);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Dispatcher.InvokeShutdown(); } catch { }
|
||||
try { _thread.Join(5000); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>WebView2 비동기 초기화/이벤트 대기 공통 유틸.</summary>
|
||||
public static class Wait
|
||||
{
|
||||
public static async Task<T> Until<T>(Func<Task<T>> poll, Func<T, bool> 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 초과)");
|
||||
}
|
||||
}
|
||||
19
VideoDownloader.Tests/VideoDownloader.Tests.csproj
Normal file
19
VideoDownloader.Tests/VideoDownloader.Tests.csproj
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<UseWPF>true</UseWPF>
|
||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\VideoDownloader.App\VideoDownloader.App.csproj" />
|
||||
<ProjectReference Include="..\VideoDownloader.Core\VideoDownloader.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Loading…
Add table
Add a link
Reference in a new issue