Phase C (T2-1..T3-10): command palette, reader mode (real Readability.js, E2E), boost force-dark, screenshot, split view, vertical tabs, Glance (Alt+click), tab groups (6 colors), workspaces, speed dial, InPrivate tabs, quick find, source view, print, PiP, TTS (94 tests GREEN)

This commit is contained in:
Yun Chan 2026-08-15 15:22:44 +09:00
parent 0caa1111e7
commit 406957088f
14 changed files with 3496 additions and 7 deletions

View file

@ -0,0 +1,128 @@
using VideoDownloader.App.Browser.Features;
namespace VideoDownloader.Tests.Browser;
// ── T2-1 명령 팔레트: 등록·검색·실행 ──
public class CommandPaletteTests
{
private sealed class CommandHost
{
public List<string> Executed = new();
public List<string> Tabs = new() { "https://a.com", "https://b.com" };
public List<string> History = new() { "https://news.com" };
public CommandPalette Build()
{
var palette = new CommandPalette();
palette.Register("새 탭", "tab new open", () => Executed.Add("newtab"));
palette.Register("탭 닫기", "close tab", () => Executed.Add("close"));
palette.Register("전체화면", "fullscreen f11", () => Executed.Add("fs"));
palette.Register("리더 모드", "reader reading", () => Executed.Add("reader"));
palette.Register("다크 모드", "dark theme boost", () => Executed.Add("dark"));
palette.RegisterSeparator();
// 동적 소스: 탭/히스토리 → 명령으로
palette.RegisterDynamic(() => Tabs.Select(t => new PaletteCommand($"탭: {t}", "tab go", () => Executed.Add("go:" + t))).ToList());
palette.RegisterDynamic(() => History.Select(h => new PaletteCommand($"기록: {h}", "history", () => Executed.Add("hist:" + h))).ToList());
return palette;
}
}
[Fact]
public void 등록된_명령을_검색한다_라벨_키워드_부분일치()
{
var p = new CommandHost().Build();
var hits = p.Search("리더");
Assert.Single(hits);
Assert.Equal("리더 모드", hits[0].Title);
hits = p.Search("F11"); // 키워드 검색
Assert.Single(hits);
hits = p.Search("TAB"); // 대소문자 무시
Assert.Contains(hits, h => h.Title == "새 탭");
}
[Fact]
public void 동적_소스_탭과_기록도_결과에_포함된다()
{
var p = new CommandHost().Build();
var hits = p.Search("탭:");
Assert.Equal(2, hits.Count);
hits = p.Search("기록:");
Assert.Single(hits);
}
[Fact]
public void 실행은_콜백을_호출한다()
{
var host = new CommandHost();
var p = host.Build();
p.Execute(p.Search("전체").Single());
Assert.Equal(new[] { "fs" }, host.Executed);
}
[Fact]
public void 빈_질의는_정적_명령_전체를_순서대로_반환()
{
var p = new CommandHost().Build();
var hits = p.Search("");
Assert.Equal("새 탭", hits[0].Title);
Assert.Equal("다크 모드", hits[^1].Title); // 동적 제외, 정적만
}
[Fact]
public void 매치_순위는_시작_일치_우선()
{
var p = new CommandPalette();
p.Register("탭 닫기", "close", () => { });
p.Register("닫힌 탭 복원", "reopen closed", () => { });
var hits = p.Search("탭");
Assert.Equal("탭 닫기", hits[0].Title); // "탭"으로 시작하는 것이 우선
}
}
// ── T2-7 Boost: 사이트별 강제 다크/커스텀 CSS 영속 ──
public class BoostStoreTests : IDisposable
{
private readonly string _path = Path.Combine(Path.GetTempPath(), $"boost_{Guid.NewGuid():N}.json");
[Fact]
public void 기본은_빈_부스트()
{
var s = new BoostStore(_path);
Assert.False(s.Get("example.com").ForceDark);
Assert.Equal("", s.Get("example.com").CustomCss);
}
[Fact]
public void 강제다크와_CSS를_저장하면_영속된다()
{
var s = new BoostStore(_path);
s.Set("example.com", forceDark: true, css: "body{zoom:1.1}");
var re = new BoostStore(_path);
Assert.True(re.Get("example.com").ForceDark);
Assert.Equal("body{zoom:1.1}", re.Get("example.com").CustomCss);
}
[Fact]
public void www_정규화_및_빈_부스트는_CSS를_만들지_않는다()
{
var s = new BoostStore(_path);
s.Set("www.example.com", true, "");
Assert.True(s.Get("example.com").ForceDark); // www 정규화
var js = BoostCssBuilder.BuildScript("plain.org", s.Get("plain.org"));
Assert.Equal("", js); // 미설정 사이트 → 빈 스크립트
}
[Fact]
public void 강제다크_스크립트는_반전필터를_포함하고_이미지_비디오를_보호한다()
{
var js = BoostCssBuilder.BuildScript("d.com", new BoostSettings(ForceDark: true, "h1{color:red}"));
Assert.Contains("invert(1)", js);
Assert.Contains("img", js); // 미디어 보호 셀렉터
Assert.Contains("video", js);
Assert.Contains("h1{color:red}", js);
}
public void Dispose() { try { File.Delete(_path); } catch { } }
}

View file

@ -0,0 +1,78 @@
using VideoDownloader.Browser.Tabs;
namespace VideoDownloader.Tests.Browser;
// ── T2-11 탭 그룹: 색상 라벨 + 그룹 단위 이동/닫기 ──
public class TabGroupsTests
{
private static TabManager NewManager() => new(() => new TabItem());
[Fact]
public void 그룹_지정과_해제()
{
var m = NewManager();
var t = m.NewTab("https://a.com");
m.SetGroup(t, "Work", "#e11");
Assert.Equal("Work", t.GroupName);
Assert.Equal("#e11", t.GroupColor);
m.SetGroup(t, null, null);
Assert.Null(t.GroupName);
}
[Fact]
public void 같은_그룹_탭은_서로_인접한다()
{
var m = NewManager();
var a = m.NewTab("a"); m.SetGroup(a, "G", "#111");
m.NewTab("b"); // 비그룹
var c = m.NewTab("c"); m.SetGroup(c, "G", "#111"); // 그룹으로 → a 옆으로 이동
Assert.Equal(1, m.Tabs.IndexOf(c));
Assert.Equal(0, m.Tabs.IndexOf(a));
}
[Fact]
public void 그룹_탭_전체_닫기()
{
var m = NewManager();
var a = m.NewTab("a"); m.SetGroup(a, "G", "#111");
var b = m.NewTab("b"); m.SetGroup(b, "G", "#111");
m.NewTab("solo");
var closed = m.CloseGroup("G");
Assert.Equal(2, closed);
Assert.Single(m.Tabs);
}
}
// ── T3-1 워크스페이스: 탭 세트 저장/전환 ──
public class WorkspaceTests
{
private static TabManager NewManager() => new(() => new TabItem());
[Fact]
public void 워크스페이스_저장후_스왑하면_탭_세트가_교체된다()
{
var m = NewManager();
m.NewTab("https://work1.com");
m.NewTab("https://work2.com");
var ws = m.CaptureWorkspace("Work");
m.NewTab("https://personal.com");
Assert.Equal(3, m.Tabs.Count);
m.SwitchWorkspace(ws);
Assert.Equal(2, m.Tabs.Count);
Assert.Equal("https://work1.com", m.Tabs[0].Url);
Assert.Equal("https://work2.com", m.Tabs[1].Url);
Assert.Equal("Work", ws.Name);
}
[Fact]
public void 워크스페이스_캡처는_현재_탭을_그대로_유지한다()
{
var m = NewManager();
m.NewTab("https://a.com");
var ws = m.CaptureWorkspace("X");
Assert.Equal(1, m.Tabs.Count); // 캡처 자체는 현재 상태 유지(스냅샷)
Assert.Single(ws.Urls);
}
}

View file

@ -0,0 +1,85 @@
using System.Windows;
using Microsoft.Web.WebView2.Core;
using VideoDownloader.App.Browser.Features;
using VideoDownloader.Browser.Tabs;
using VideoDownloader.Tests.Infrastructure;
namespace VideoDownloader.Tests.E2E;
/// <summary>
/// E2E (T2-4): 실제 WebView2 + 번들된 Mozilla Readability.js 로
/// 기사 페이지에서 본문을 추출해 리더 뷰로 교체하고 원본 URL 복귀까지 검증.
/// </summary>
[Collection("E2E")]
public class ReaderModeE2ETests
{
private const string ArticleHtml = """
<html><head><title>테스트 기사</title></head><body>
<nav>광고 광고 광고 내비게이션</nav>
<article>
<h1>리더 모드 E2E 기사</h1>
<p>첫 번째 문단입니다. 이 문단은 Readability 가 본문으로 추출해야 합니다. 충분한 길이의 텍스트가 필요하므로 몇 번 더 반복합니다. 본문 본문 본문.</p>
<p>두 번째 문단입니다. 리더 모드는 광고와 내비게이션을 제거하고 본문만 남깁니다. 추출 결과에 이 텍스트가 포함되어야 합니다.</p>
<p>세 번째 문단입니다. 문단이 세 개 이상이면 Readability 의 휴리스틱이 본문으로 판정합니다. 마커텍스트-고유문구-레인보우4747.</p>
</article>
<footer>푸터 링크 모음</footer>
</body></html>
""";
[Fact]
public async Task 기사를_리더로_추출하고_원본으로_복귀한다()
{
using var sta = StaDispatcher.Start();
var udf = Path.Combine(Path.GetTempPath(), $"vd_reader_{Guid.NewGuid():N}");
var ok = 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.NavigateToString(ArticleHtml);
await WaitNav(cv);
var tab = new TabItem { View = browser };
// 1) 리더 진입
var entered = await ReaderMode.ToggleAsync(tab, lightTheme: false);
await Wait.Until(() => Task.FromResult(cv.DocumentTitle ?? ""), t => t.Contains("테스트 기사"), 15000);
var titleAfter = cv.DocumentTitle ?? "";
var bodyText = await cv.ExecuteScriptAsync("document.body.innerText");
var hasMarker = bodyText.Contains("마커텍스트-고유문구-레인보우4747");
var hasNav = bodyText.Contains("광고 광고 광고");
var isReader = tab.IsReader;
var originalSaved = tab.ReaderOriginalUrl;
// 2) 복귀
var exited = await ReaderMode.ToggleAsync(tab, lightTheme: false);
var restoredReader = tab.IsReader == false;
window.Close();
return (entered: entered == true, titleAfter, hasMarker, navRemoved: !hasNav, isReader, originalSaved, exited: exited == false, restoredReader);
});
try { Directory.Delete(udf, true); } catch { }
Assert.True(ok.entered, "리더 진입 실패");
Assert.Contains("테스트 기사", ok.titleAfter);
Assert.True(ok.hasMarker, "본문 마커 누락");
Assert.True(ok.navRemoved, "내비/광고 제거 안됨");
Assert.True(ok.isReader);
Assert.True(ok.exited);
Assert.True(ok.restoredReader);
}
private static Task WaitNav(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));
}
}