UI 복잡 버전 234 RED→GREEN 완료(docs/UI_USECASES.md §A~§J): 라이브 테마 전환(DynamicResource 전환+ThemeResolver+HighContrast 테마, 재시작 없음), 디자인 토큰 체계·WCAG 대비 결함 5건 수정, 설정창 카테고리 내비/검색 재구성+AppConfig 40여 속성, 다운로드 관리자(속도/ETA/세그먼트맵/전체 일시정지-이어받기/CSV 내보내기/예약 게이트/중복 확인), 라이브러리·아카이브·작업공간·재생목록·즐겨찾기 편집 뷰, 접근성(자동화 속성 30여 개·커스텀 피어·docs/ACCESSIBILITY.md), 인터랙션(휠 클릭 새 탭·XButton 탐색·F12·영역 캡처·프로필 전환), 상태/알림(토스트·알림센터·온보딩·CrashReportDialog). 보강 테스트 35건+실창 E2E 4건(StaDispatcher 기반 — 프록시 설정이 기시작 핸들러를 건드리는 크래시 실결함 수정 포함) — 453/453 GREEN. 이전 미커밋 작업(CI 워크플로·Mdns·QueueHub·UpdateChecker·AGENTS/CLAUDE·빌드 스크립트) 일괄 포함

This commit is contained in:
Yun Chan 2026-08-17 15:54:53 +09:00
parent 5a0ce730b2
commit 17f2f91152
88 changed files with 9463 additions and 1127 deletions

View file

@ -0,0 +1,388 @@
using VideoDownloader.App;
using VideoDownloader.App.Browser.Features;
using VideoDownloader.App.Services;
using VideoDownloader.App.ViewModels;
using VideoDownloader.Browser;
using VideoDownloader.Browser.Tabs;
using VideoDownloader.Core.Config;
using VideoDownloader.Tests.Infrastructure;
namespace VideoDownloader.Tests.Browser;
/// <summary>
/// UI 유즈케이스 GREEN 구현 보강 테스트 — docs/UI_USECASES.md 계약(존재 확인)이 놓친
/// "실제 동작" 을 검증한다. RED 스펙 234건이 요구하지 않은 각진 부분:
/// 탭 잠금/상한, 아카이브 복원·비움, 다운로드 예약 창 계산, 단축키 충돌 검출,
/// 카드 VM 속도/ETA 계산, 페이지 다운로드 전체 일시정지, 북마크 HTML 파싱,
/// 테마 사전 키 동일성(런타임 MissingKey 방지).
/// </summary>
public class UiUsecaseHardeningTests
{
// ---------- §D 탭: 잠금·상한·그룹 ----------
[Fact]
public void 잠긴_탭은_CloseTab_거부하고_해제후_닫힌다()
{
var tabs = new TabManager(() => new TabItem());
var t = tabs.NewTab("https://a");
t.IsLocked = true;
Assert.False(tabs.CloseTab(t)); // UC-B01: 잠금이 닫기 거부
Assert.Single(tabs.Tabs);
t.IsLocked = false;
Assert.True(tabs.CloseTab(t));
}
[Fact]
public void MaxTabs_도달시_새_탭_대신_마지막_탭_활성화()
{
var tabs = new TabManager(() => new TabItem()) { MaxTabs = 2 };
var t1 = tabs.NewTab("https://1");
var t2 = tabs.NewTab("https://2");
var t3 = tabs.NewTab("https://3"); // 상한 도달 → 새 탭 추가 없음
Assert.Equal(2, tabs.Tabs.Count);
Assert.Same(t2, t3); // 마지막 탭 반환(활성화)
Assert.Same(t2, tabs.ActiveTab);
}
[Fact]
public void RenameGroup_같은_그룹_전체_이름_변경()
{
var tabs = new TabManager(() => new TabItem());
var a = tabs.NewTab("https://a");
var b = tabs.NewTab("https://b");
tabs.SetGroup(a, "G1", "#fff");
tabs.SetGroup(b, "G1", "#fff");
Assert.True(tabs.RenameGroup("G1", "작업"));
Assert.Equal("작업", a.GroupName);
Assert.Equal("작업", b.GroupName);
Assert.False(tabs.RenameGroup("없는그룹", "x"));
}
[Fact]
public void ToggleGroupCollapsed_그룹_전체_접힘_토글()
{
var tabs = new TabManager(() => new TabItem());
var a = tabs.NewTab("https://a");
var b = tabs.NewTab("https://b");
tabs.SetGroup(a, "G1", "#fff");
tabs.SetGroup(b, "G1", "#fff");
b.IsLocked = true; // CloseGroup 대상 아님(접기는 잠금과 무관)
Assert.Equal(2, tabs.ToggleGroupCollapsed("G1"));
Assert.True(a.IsGroupCollapsed);
Assert.True(b.IsGroupCollapsed);
}
// ---------- §D 탭: 아카이브 ----------
[Fact]
public void TabArchive_Clear_후_Search_빈값_Restore_null()
{
var path = Path.Combine(Path.GetTempPath(), $"vd_arch_{Guid.NewGuid():N}.json");
try
{
var arch = new TabArchive(path);
arch.Add("제목", "https://example.com");
arch.Clear();
Assert.Empty(arch.Search(""));
Assert.Null(arch.Restore("https://example.com"));
}
finally { try { File.Delete(path); } catch { } }
}
[Fact]
public void TabArchive_Restore_꺼내고_파일에도_반영()
{
var path = Path.Combine(Path.GetTempPath(), $"vd_arch_{Guid.NewGuid():N}.json");
try
{
var arch = new TabArchive(path);
arch.Add("제목A", "https://a.example.com");
var got = arch.Restore("https://a.example.com");
Assert.NotNull(got);
Assert.Equal("제목A", got!.Title);
// 꺼낸 항목은 목록에서 제거(다시 복구 불가)
Assert.Null(arch.Restore("https://a.example.com"));
Assert.Empty(arch.Search(""));
// 새 인스턴스(재시작)로도 제거 상태 유지
Assert.Empty(new TabArchive(path).Search(""));
}
finally { try { File.Delete(path); } catch { } }
}
// ---------- §E 다운로드: 예약·VM 계산·페이지 일시정지 ----------
[Theory]
[InlineData("02:00-08:00", "2026-01-01T03:00:00", true)]
[InlineData("02:00-08:00", "2026-01-01T12:00:00", false)]
[InlineData("22:00-06:00", "2026-01-01T23:30:00", true)] // 자정 가로지름
[InlineData("22:00-06:00", "2026-01-01T03:00:00", true)]
[InlineData("22:00-06:00", "2026-01-01T12:00:00", false)]
public void DownloadScheduler_시간대_판정(string window, string nowIso, bool allowed)
{
var sched = new DownloadScheduler(window);
Assert.True(sched.Enabled);
Assert.Equal(allowed, sched.CanRunNow(DateTime.Parse(nowIso)));
}
[Fact]
public void DownloadScheduler_빈창_항상허용_형식오류_게이트열림()
{
Assert.True(new DownloadScheduler(null).CanRunNow(DateTime.Now));
Assert.False(new DownloadScheduler(null).Enabled);
// 형식 오류 창은 닫힌 게이트로 막지 않는다(파싱 실패 = 항상 허용)
Assert.True(new DownloadScheduler("bad-format").CanRunNow(DateTime.Now));
}
[Fact]
public void DownloadScheduler_TryParse_형식검증()
{
Assert.True(DownloadScheduler.TryParse("09:00-18:00", out _, out _));
Assert.False(DownloadScheduler.TryParse("9-18", out _, out _));
Assert.False(DownloadScheduler.TryParse("25:00-26:00", out _, out _));
Assert.False(DownloadScheduler.TryParse("", out _, out _));
}
[Fact]
public void DownloadScheduler_다음창까지_남은시간_양수()
{
var sched = new DownloadScheduler("02:00-08:00");
var now = new DateTime(2026, 1, 1, 12, 0, 0);
var remain = sched.TimeUntilNextWindow(now);
Assert.True(remain > TimeSpan.Zero);
// 다음날 02:00 이어야 한다(14시간)
Assert.Equal(TimeSpan.FromHours(14), remain);
}
[Fact]
public void DownloadItemViewModel_속도와_ETA_계산()
{
var vm = new DownloadItemViewModel("테스트");
vm.UpdateSpeedEta(receivedBytes: 50 * 1024 * 1024, totalBytes: 100 * 1024 * 1024, speedBps: 10 * 1024 * 1024);
Assert.Equal("10.0 MB/s", vm.SpeedText);
Assert.Equal("100.0 MB", vm.TotalSizeText);
Assert.Equal("5초 남음", vm.RemainingText);
Assert.Equal(50 * 1024 * 1024L, vm.ReceivedBytes);
vm.UpdateSpeedEta(0, -1, 0); // 정지 → 표시 지움
Assert.Equal("", vm.SpeedText);
Assert.Equal("", vm.RemainingText);
}
[Theory]
[InlineData(0.5, "1초 남음")]
[InlineData(90, "2분 남음")]
[InlineData(3700, "1시간 1분 남음")]
public void DownloadItemViewModel_FormatEta_단위(double seconds, string expected)
=> Assert.Equal(expected, DownloadItemViewModel.FormatEta(TimeSpan.FromSeconds(seconds)));
[Fact]
public void DownloadItemViewModel_재시도_횟수와_우선순위_완료시각()
{
var vm = new DownloadItemViewModel("테스트");
Assert.Equal(0, vm.RetryCount);
Assert.Equal(0, vm.Priority);
vm.RetryCount = 3;
vm.Priority = 5;
vm.FinalFileName = "video.mp4";
vm.CompletedAt = DateTime.UtcNow;
Assert.Equal(3, vm.RetryCount);
Assert.Equal(5, vm.Priority);
Assert.Equal("video.mp4", vm.FinalFileName);
Assert.NotNull(vm.CompletedAt);
}
[Fact]
public void DownloadItemViewModel_SegmentMap_완료_세그먼트_채움()
{
var vm = new DownloadItemViewModel("HLS");
Assert.Empty(vm.SegmentMap);
vm.SegmentMap.Add(1.0);
vm.SegmentMap.Add(0.0);
vm.SegmentMap.Add(0.5);
Assert.Equal(new[] { 1.0, 0.0, 0.5 }, vm.SegmentMap);
}
[Fact]
public void PageDownloadTracker_PauseAll_ResumeAll_진행중만_제어()
{
var tracker = new PageDownloadTracker();
var runningOp = new FakeOp { State = "InProgress" };
var doneOp = new FakeOp { State = "Completed" };
tracker.Add(runningOp, "a.bin");
tracker.Add(doneOp, "b.bin");
tracker.PauseAll();
Assert.Equal(1, runningOp.PauseCount);
Assert.Equal(0, doneOp.PauseCount);
tracker.ResumeAll();
Assert.Equal(1, runningOp.ResumeCount);
Assert.Equal(0, doneOp.ResumeCount); // 완료 항목은 재개 대상 아님
}
private sealed class FakeOp : IDownloadOperation
{
public string Uri => "https://example.com/f";
public string FileName => "f";
public long BytesReceived => 10;
public long TotalBytesToReceive => 100;
public string State { get; set; } = "InProgress";
public int PauseCount, ResumeCount, CancelCount;
#pragma warning disable CS0067 // 인터페이스 계약상 선언만 필요(테스트에서 발화 안 함)
public event Action? Changed;
#pragma warning restore CS0067
public void Pause() => PauseCount++;
public void Resume() => ResumeCount++;
public void Cancel() => CancelCount++;
}
// ---------- §H 단축키 ----------
[Fact]
public void ShortcutManager_기본조합과_사용자오버라이드()
{
var sm = new ShortcutManager();
Assert.Equal("Ctrl+T", sm.GestureFor("newtab"));
var custom = new ShortcutManager(new Dictionary<string, string> { ["newtab"] = "Ctrl+Shift+Y" });
Assert.Equal("Ctrl+Shift+Y", custom.GestureFor("newtab"));
Assert.Equal("Ctrl+W", custom.GestureFor("closetab"));
}
[Fact]
public void ShortcutManager_충돌검출()
{
var sm = new ShortcutManager(new Dictionary<string, string> { ["find"] = "Ctrl+T" }); // newtab 과 충돌
var conflicts = sm.FindConflicts();
Assert.Contains(conflicts, g => g.Count == 2 &&
g.Any(b => b.Command == "newtab") && g.Any(b => b.Command == "find"));
Assert.Empty(new ShortcutManager().FindConflicts()); // 기본 표에는 충돌 없음
}
[Theory]
[InlineData("Ctrl+Shift+P", System.Windows.Input.ModifierKeys.Control | System.Windows.Input.ModifierKeys.Shift, System.Windows.Input.Key.P)]
[InlineData("F12", System.Windows.Input.ModifierKeys.None, System.Windows.Input.Key.F12)]
public void ShortcutManager_Parse_조합문자열(string gesture, System.Windows.Input.ModifierKeys mods, System.Windows.Input.Key key)
{
var parsed = ShortcutManager.Parse(gesture);
Assert.NotNull(parsed);
Assert.Equal(mods, parsed!.Value.Mods);
Assert.Equal(key, parsed.Value.Key);
Assert.Null(ShortcutManager.Parse("Not+A+Key"));
Assert.Null(ShortcutManager.Parse(""));
}
[Fact]
public void ShortcutManager_Normalize_순서무관_동일취급()
=> Assert.Equal(ShortcutManager.Normalize("Ctrl+Shift+P"), ShortcutManager.Normalize("shift+p+ctrl"));
// ---------- §I 즐겨찾기 ----------
[Fact]
public void 북마크_Netscape_HTML_파싱_및_역호환()
{
var html = """
<!DOCTYPE NETSCAPE-Bookmark-file-1>
<DL><p>
<DT><A HREF="https://a.example.com/x?a=1&amp;b=2">사이트 &lt;A&gt;</A>
<DT><A HREF="https://b.example.com">Plain</A>
</DL><p>
""";
var parsed = BookmarkManagerWindow.ParseNetscapeBookmarks(html).ToList();
Assert.Equal(2, parsed.Count);
Assert.Equal("https://a.example.com/x?a=1&b=2", parsed[0].Url);
Assert.Equal("사이트 <A>", parsed[0].Title);
Assert.Equal("Plain", parsed[1].Title);
}
[Fact]
public void Bookmark_레코드_Folder_기존_JSON_역직렬화_호환()
{
// 기존 저장 파일(2필드)이 Folder 기본값(null)으로 읽히는지
var old = System.Text.Json.JsonSerializer.Deserialize<Bookmark>("""{"Title":"T","Url":"https://x"}""");
Assert.NotNull(old);
Assert.Null(old!.Folder);
var @new = new Bookmark("T", "https://x", "작업");
Assert.Equal("작업", @new.Folder);
}
// ---------- §A 테마 ----------
[Theory]
[InlineData("dark")]
[InlineData("light")]
[InlineData("highcontrast")]
public void ThemeResolver_고정값_매핑(string setting)
{
var (theme, custom) = ThemeResolver.Resolve(setting);
Assert.Equal(setting, theme);
Assert.Null(custom);
}
[Fact]
public void ThemeResolver_system_은_dark_또는_light_로_확정()
{
var (theme, _) = ThemeResolver.Resolve("system");
Assert.Contains(theme, new[] { "dark", "light" }); // 레지스트리 결과에 따라
}
[Fact]
public void ThemeResolver_커스텀_경로_우선()
{
var file = Path.GetTempFileName();
try
{
var (theme, custom) = ThemeResolver.Resolve("whatever", file);
Assert.Equal("custom", theme);
Assert.Equal(file, custom);
// 없는 파일이면 다크 폴백
Assert.Equal("dark", ThemeResolver.Resolve("whatever", @"Z:\nope.xaml").Theme);
}
finally { try { File.Delete(file); } catch { } }
}
/// <summary>Dark/Light/HighContrast 세 테마가 동일한 키 집합을 정의하는지 —
/// 한 테마에만 있는 키는 전환 시 MissingKey 런타임 예외로 이어진다.</summary>
[Fact]
public void 세_테마_사전의_키_집합이_동일하다()
{
var keysOf = (string xaml) =>
System.Text.RegularExpressions.Regex.Matches(xaml, "x:Key=\"([^\"]+)\"")
.Select(m => m.Groups[1].Value).ToHashSet();
var dark = keysOf(SourceTree.ThemeDarkXaml);
var light = keysOf(SourceTree.ThemeLightXaml);
var hc = keysOf(File.ReadAllText(SourceTree.AppPath("Themes/HighContrast.xaml")));
Assert.True(dark.SetEquals(light), $"Dark/Light 키 불일치: dark-only={string.Join(",", dark.Except(light))} light-only={string.Join(",", light.Except(dark))}");
Assert.True(dark.SetEquals(hc), $"Dark/HighContrast 키 불일치: dark-only={string.Join(",", dark.Except(hc))} hc-only={string.Join(",", hc.Except(dark))}");
}
// ---------- §F 설정 ----------
[Fact]
public void ResetToDefaults_원격토큰은_유지_나머지는_초기화()
{
var c = new AppConfig
{
RemoteAccessToken = "keep-me",
Concurrency = 1,
Theme = "light",
ToastDurationMs = 100,
DownloadSortOrder = "title",
};
c.ResetToDefaults();
Assert.Equal("keep-me", c.RemoteAccessToken); // 기기 페어링 유지
Assert.Equal(new AppConfig().Concurrency, c.Concurrency);
Assert.Equal("dark", c.Theme);
Assert.Equal(4000, c.ToastDurationMs);
Assert.Equal("newest", c.DownloadSortOrder);
}
}

View file

@ -0,0 +1,47 @@
using System.Windows;
using VideoDownloader.App;
using VideoDownloader.Core.Config;
using VideoDownloader.Tests.Infrastructure;
namespace VideoDownloader.Tests.E2E;
/// <summary>
/// E2E: 설정창 "앱 업데이트" 섹션(DEPLOYMENT_PLAN §6 자동 업데이트 v1) —
/// 실제 SettingsWindow 를 띄워 설정값 로드 배선과 잘못된 저장소 형식의 즉시 안내(네트워크 불필요 경로)를 검증.
/// </summary>
[Collection("E2E")]
public class SettingsUpdateUiE2ETests
{
[Fact]
public void 설정창_업데이트_저장소_로드와_형식오류_안내()
{
// VD_DATA_DIR 격리: SettingsWindow 가 참조하는 프로필 경로를 실사용자와 분리
var dataDir = Path.Combine(Path.GetTempPath(), $"vd_set_{Guid.NewGuid():N}");
Environment.SetEnvironmentVariable("VD_DATA_DIR", dataDir);
try
{
using var sta = StaDispatcher.Start();
WpfTestApp.Ensure(sta.Dispatcher);
sta.Invoke(() =>
{
var config = new AppConfig { UpdateRepo = "user/videodownloader" };
var win = new SettingsWindow(config);
// 설정 → UI 로드 배선
Assert.Equal("user/videodownloader", win.UpdateRepoBox.Text);
// owner/repo 형식이 아니면 네트워크 호출 없이 즉시 안내
win.UpdateRepoBox.Text = "invalid-format";
win.CheckUpdate_Click(null, new RoutedEventArgs());
Assert.Contains("owner/repo", win.UpdateResultText.Text);
win.Close();
});
}
finally
{
Environment.SetEnvironmentVariable("VD_DATA_DIR", null);
try { Directory.Delete(dataDir, recursive: true); } catch (DirectoryNotFoundException) { }
}
}
}

View file

@ -0,0 +1,142 @@
using VideoDownloader.Core.Config;
using VideoDownloader.Tests.Infrastructure;
namespace VideoDownloader.Tests.E2E;
/// <summary>
/// UI 복잡 버전 RED 스펙 — §G 접근성 유즈케이스(docs/UI_USECASES.md §G, UC-A01~A20).
/// RED 단계: 전부 실패하는 것이 정상이다.
/// </summary>
public class UiUsecaseAccessibilityRedTests
{
[Fact]
public void UC_A01_자동화_속성이_하나라도_사용된다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("AutomationProperties"),
"UC-A01", "화면 낭독기 지원을 위한 AutomationProperties 사용이 필요하다");
[Fact]
public void UC_A02_주요_컨트롤에_자동화_이름이_부여된다()
{
var count = System.Text.RegularExpressions.Regex.Matches(SourceTree.MainWindowXaml, "AutomationProperties\\.Name").Count;
SourceTree.Usecase(
count >= 15,
$"UC-A02", $"주요 컨트롤 15개 이상에 AutomationProperties.Name 이 필요하다 (현재 {count}개)");
}
[Fact]
public void UC_A03_상태_변화가_낭독기에_자동_전달된다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("LiveSetting"),
"UC-A03", "상태바·진행률의 LiveSetting 선언이 필요하다");
[Fact]
public void UC_A04_시스템_고대비_모드를_감지한다()
=> SourceTree.Usecase(
SourceTree.MainWindowCs.Contains("HighContrast"),
"UC-A04", "SystemParameters.HighContrast 감지 처리가 필요하다");
[Fact]
public void UC_A05_키보드_포커스_비주얼이_정의된다()
=> SourceTree.Usecase(
SourceTree.SharedXaml.Contains("FocusVisualStyle"),
"UC-A05", "공용 FocusVisualStyle 정의가 필요하다");
[Fact]
public void UC_A06_Tab_순서가_지정된다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("TabIndex"),
"UC-A06", "논리적 Tab 이동을 위한 TabIndex 지정이 필요하다");
[Fact]
public void UC_A07_ToolTipService_설정이_사용된다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("ToolTipService"),
"UC-A07", "툴팁 표시 시간 등 ToolTipService 설정이 필요하다");
[Fact]
public void UC_A08_접근_키_AccessText가_사용된다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("AccessText"),
"UC-A08", "Alt 접근 키를 위한 AccessText 사용이 필요하다");
[Fact]
public void UC_A09_사용자_지정_자동화_피어가_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowCs.Contains("AutomationPeer"),
"UC-A09", "목록·탭 컨트롤용 AutomationPeer 구현이 필요하다");
[Fact]
public void UC_A10_HelpText가_제공된다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("AutomationProperties.HelpText"),
"UC-A10", "컨트롤 추가 설명 AutomationProperties.HelpText 가 필요하다");
[Fact]
public void UC_A11_완료_상태가_색과_무관하게_표시된다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("SuccessIcon"),
"UC-A11", "완료 상태 아이콘(SuccessIcon)으로 색 무의존 표시가 필요하다");
[Fact]
public void UC_A12_접근성_설정_섹션이_있다()
=> SourceTree.Usecase(
SourceTree.SettingsWindowXaml.Contains("접근성"),
"UC-A12", "설정창에 '접근성' 섹션이 필요하다");
[Fact]
public void UC_A13_UI_확대_배율을_설정할_수_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "UiFontScale"),
"UC-A13", "저시력 지원용 AppConfig.UiFontScale 속성이 필요하다");
[Fact]
public void UC_A14_팝업_키보드_탐색이_설정된다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("KeyboardNavigation.TabNavigation"),
"UC-A14", "팝업 내 키보드 탐색(KeyboardNavigation) 설정이 필요하다");
[Fact]
public void UC_A15_자막_표시_옵션이_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "ShowCaptions"),
"UC-A15", "AppConfig.ShowCaptions 속성이 필요하다");
[Fact]
public void UC_A16_애니메이션_감소_설정이_스타일에_반영된다()
=> SourceTree.Usecase(
SourceTree.SharedXaml.Contains("ReduceMotion"),
"UC-A16", "Shared.xaml 이 ReduceMotion 설정을 스타일에 반영해야 한다");
[Fact]
public void UC_A17_리더_모드_HTML에_언어가_선언된다()
=> SourceTree.Usecase(
SourceTree.ReaderModeCs.Contains("lang="),
"UC-A17", "리더 모드 HTML 에 lang=\"ko\" 선언이 필요하다");
[Fact]
public void UC_A18_접근성_선언_문서가_있다()
=> SourceTree.Usecase(
SourceTree.DocFileExists("ACCESSIBILITY.md"),
"UC-A18", "접근성 지원 범위 문서(docs/ACCESSIBILITY.md)가 필요하다");
[Fact]
public void UC_A19_라이트_진행_트랙이_배경과_구별된다()
{
var fg = SourceTree.BrushHex(SourceTree.ThemeLightXaml, "TrackBrush");
var bg = SourceTree.BrushHex(SourceTree.ThemeLightXaml, "PanelBg");
Assert.False(fg is null || bg is null, "UC-A19 — 테마 브러시 파싱 실패");
var ratio = SourceTree.ContrastRatio(fg!, bg!);
Assert.True(ratio >= 3.0, $"UC-A19 RED — 라이트 TrackBrush/PanelBg 대비 {ratio:F2}:1 < 3:1 (비텍스트 구성요소 기준)");
}
[Fact]
public void UC_A20_버튼_테두리가_카드_배경과_구별된다()
{
var fg = SourceTree.BrushHex(SourceTree.ThemeDarkXaml, "BorderBrush");
var bg = SourceTree.BrushHex(SourceTree.ThemeDarkXaml, "CardBg");
Assert.False(fg is null || bg is null, "UC-A20 — 테마 브러시 파싱 실패");
var ratio = SourceTree.ContrastRatio(fg!, bg!);
Assert.True(ratio >= 3.0, $"UC-A20 RED — 다크 BorderBrush/CardBg 대비 {ratio:F2}:1 < 3:1 (클릭 대상 식별 기준)");
}
}

View file

@ -0,0 +1,194 @@
using VideoDownloader.App;
using VideoDownloader.App.Browser.Features;
using VideoDownloader.App.ViewModels;
using VideoDownloader.Core.Config;
using VideoDownloader.Tests.Infrastructure;
namespace VideoDownloader.Tests.E2E;
/// <summary>
/// UI 복잡 버전 RED 스펙 — §E 다운로드 관리자 유즈케이스(docs/UI_USECASES.md §E, UC-D01~D30).
/// RED 단계: 전부 실패하는 것이 정상이다.
/// </summary>
public class UiUsecaseDownloadsRedTests
{
[Fact]
public void UC_D01_다운로드_정렬_순서를_설정할_수_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "DownloadSortOrder"),
"UC-D01", "AppConfig.DownloadSortOrder 속성이 필요하다");
[Fact]
public void UC_D02_진행중_완료_실패_필터_탭이_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("DownloadFilterTabs"),
"UC-D02", "다운로드 필터 탭(DownloadFilterTabs) 요소가 필요하다");
[Fact]
public void UC_D03_다운로드_목록을_검색할_수_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("DownloadSearchBox"),
"UC-D03", "다운로드 검색 상자(DownloadSearchBox)가 필요하다");
[Fact]
public void UC_D04_남은_시간_ETA가_카드에_표시된다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(DownloadItemViewModel), "RemainingText"),
"UC-D04", "다운로드 카드 VM 에 RemainingText(남은 시간) 속성이 필요하다");
[Fact]
public void UC_D05_현재_속도가_카드에_표시된다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(DownloadItemViewModel), "SpeedText"),
"UC-D05", "다운로드 카드 VM 에 SpeedText 속성이 필요하다");
[Fact]
public void UC_D06_전체_일시정지를_할_수_있다()
=> SourceTree.Usecase(
SourceTree.MethodExists(typeof(MainWindow), "PauseAllDownloads"),
"UC-D06", "MainWindow.PauseAllDownloads() 가 필요하다");
[Fact]
public void UC_D07_전체_재개를_할_수_있다()
=> SourceTree.Usecase(
SourceTree.MethodExists(typeof(MainWindow), "ResumeAllDownloads"),
"UC-D07", "MainWindow.ResumeAllDownloads() 가 필요하다");
[Fact]
public void UC_D08_완료_항목_자동_치움을_설정할_수_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "AutoClearCompleted"),
"UC-D08", "AppConfig.AutoClearCompleted 속성이 필요하다");
[Fact]
public void UC_D09_재시도_횟수가_카드에_표시된다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(DownloadItemViewModel), "RetryCount"),
"UC-D09", "다운로드 카드 VM 에 RetryCount 속성이 필요하다");
[Fact]
public void UC_D10_세그먼트_진행이_시각화된다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(DownloadItemViewModel), "SegmentMap"),
"UC-D10", "세그먼트별 진행 맵(SegmentMap) 속성이 필요하다");
[Fact]
public void UC_D11_토스트_알림_컴포넌트가_있다()
=> SourceTree.Usecase(
SourceTree.FindType("ToastNotifier") is not null,
"UC-D11", "다운로드 완료 알림을 담당하는 ToastNotifier 컴포넌트가 필요하다");
[Fact]
public void UC_D12_알림_소리를_설정할_수_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "NotifySound"),
"UC-D12", "AppConfig.NotifySound 속성이 필요하다");
[Fact]
public void UC_D13_완료_시_폴더_자동_열기를_설정할_수_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "OpenFolderOnComplete"),
"UC-D13", "AppConfig.OpenFolderOnComplete 속성이 필요하다");
[Fact]
public void UC_D14_카드_우클릭_컨텍스트_메뉴가_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("CardContextMenu"),
"UC-D14", "다운로드 카드 컨텍스트 메뉴(CardContextMenu)가 필요하다");
[Fact]
public void UC_D15_카드를_펼쳐_상세_정보를_본다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("CardDetailExpander"),
"UC-D15", "카드 상세 펼침(CardDetailExpander) 요소가 필요하다");
[Fact]
public void UC_D16_대기_현황_요약이_표시된다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("QueueSummaryText"),
"UC-D16", "패널의 대기 현황 요약(QueueSummaryText)이 필요하다");
[Fact]
public void UC_D17_속도_스파크라인이_표시된다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("SpeedSparkline"),
"UC-D17", "속도 추이 스파크라인(SpeedSparkline) 요소가 필요하다");
[Fact]
public void UC_D18_전체_속도_합계가_표시된다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("GlobalSpeedText"),
"UC-D18", "전체 다운로드 속도(GlobalSpeedText) 표시가 필요하다");
[Fact]
public void UC_D19_파일_총_크기가_카드에_표시된다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(DownloadItemViewModel), "TotalSizeText"),
"UC-D19", "카드 VM 에 TotalSizeText 속성이 필요하다");
[Fact]
public void UC_D20_자막_언어를_설정할_수_있다()
=> SourceTree.Usecase(
SourceTree.SettingsWindowXaml.Contains("SubtitleLangBox"),
"UC-D20", "설정창에 자막 언어 선택(SubtitleLangBox)이 필요하다");
[Fact]
public void UC_D21_다운로드_스케줄러가_있다()
=> SourceTree.Usecase(
SourceTree.FindType("DownloadScheduler") is not null,
"UC-D21", "시간대 예약을 담당하는 DownloadScheduler 컴포넌트가 필요하다");
[Fact]
public void UC_D22_속도_상한을_설정할_수_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "MaxDownloadSpeedKbps"),
"UC-D22", "AppConfig.MaxDownloadSpeedKbps 속성이 필요하다");
[Fact]
public void UC_D23_목록을_CSV로_내보낼_수_있다()
=> SourceTree.Usecase(
SourceTree.MethodExists(typeof(MainWindow), "ExportDownloads"),
"UC-D23", "MainWindow.ExportDownloads() 내보내기 기능이 필요하다");
[Fact]
public void UC_D24_항목_우선순위를_정할_수_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(DownloadItemViewModel), "Priority"),
"UC-D24", "카드 VM 에 대기열 우선순위(Priority) 속성이 필요하다");
[Fact]
public void UC_D25_중복_다운로드_경고를_받는다()
=> SourceTree.Usecase(
SourceTree.MethodExists(typeof(MainWindow), "ConfirmDuplicateDownload"),
"UC-D25", "MainWindow.ConfirmDuplicateDownload() 확인 절차가 필요하다");
[Fact]
public void UC_D26_최종_저장_파일명이_카드에_표시된다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(DownloadItemViewModel), "FinalFileName"),
"UC-D26", "카드 VM 에 충돌 회피 후 파일명(FinalFileName)이 필요하다");
[Fact]
public void UC_D27_오류_메시지_복사_버튼이_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("CopyErrorButton"),
"UC-D27", "오류 복사 버튼(CopyErrorButton) 요소가 필요하다");
[Fact]
public void UC_D28_페이지_다운로드도_전체_일시정지가_된다()
=> SourceTree.Usecase(
SourceTree.MethodExists(typeof(PageDownloadTracker), "PauseAll"),
"UC-D28", "PageDownloadTracker.PauseAll() 메서드가 필요하다");
[Fact]
public void UC_D29_미디어_종류_배지가_표시된다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("MediaKindBadge"),
"UC-D29", "감지 미디어 종류 배지(MediaKindBadge) 요소가 필요하다");
[Fact]
public void UC_D30_사이트별_그룹_헤더가_표시된다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("SiteGroupHeader"),
"UC-D30", "사이트별 묶음 헤더(SiteGroupHeader) 요소가 필요하다");
}

View file

@ -0,0 +1,131 @@
using VideoDownloader.Core.Config;
using VideoDownloader.Tests.Infrastructure;
namespace VideoDownloader.Tests.E2E;
/// <summary>
/// UI 복잡 버전 RED 스펙 — §H 키보드·마우스 인터랙션 유즈케이스(docs/UI_USECASES.md §H, UC-K01~K20).
/// RED 단계: 전부 실패하는 것이 정상이다.
/// </summary>
public class UiUsecaseInteractionRedTests
{
[Fact]
public void UC_K01_단축키_설정_항목이_있다()
=> SourceTree.Usecase(
SourceTree.SettingsWindowXaml.Contains("단축키"),
"UC-K01", "설정창에 '단축키' 항목이 필요하다");
[Fact]
public void UC_K02_단축키를_사용자가_바꿀_수_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "Shortcuts"),
"UC-K02", "AppConfig.Shortcuts 사용자 지정 단축키 맵이 필요하다");
[Fact]
public void UC_K03_단축키_관리_컴포넌트가_있다()
=> SourceTree.Usecase(
SourceTree.FindType("ShortcutManager") is not null,
"UC-K03", "충돌 검증을 포함한 ShortcutManager 컴포넌트가 필요하다");
[Fact]
public void UC_K04_단축키_전체_목록_팝업이_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("ShortcutHelpPopup"),
"UC-K04", "단축키 도움말 팝업(ShortcutHelpPopup)이 필요하다");
[Fact]
public void UC_K05_마우스_제스처로_탐색할_수_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowCs.Contains("MouseGesture") || SourceTree.MainWindowCs.Contains("XButton"),
"UC-K05", "마우스 제스처(XButton 등) 뒤로/앞처 처리가 필요하다");
[Fact]
public void UC_K06_휠_클릭으로_새_탭을_연다()
=> SourceTree.Usecase(
SourceTree.MainWindowCs.Contains("MiddleButton"),
"UC-K06", "링크 휠 클릭(MiddleButton) 새 탭 처리가 필요하다");
[Fact]
public void UC_K07_붙여넣기_즉시_이동_옵션이_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "PasteAndGo"),
"UC-K07", "AppConfig.PasteAndGo 속성이 필요하다");
[Fact]
public void UC_K08_주소창_포커스_시_전체_선택_옵션이_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "FocusSelectsAddress"),
"UC-K08", "AppConfig.FocusSelectsAddress 속성이 필요하다");
[Fact]
public void UC_K09_모두_일시정지_빠른_명령이_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowCs.Contains("모두 일시정지"),
"UC-K09", "팔레트/버튼의 '모두 일시정지' 명령이 필요하다");
[Fact]
public void UC_K10_Esc로_모든_팝업을_한번에_닫는다()
=> SourceTree.Usecase(
SourceTree.MainWindowCs.Contains("CloseAllPopups") || SourceTree.MainWindowCs.Contains("CloseAllOverlays"),
"UC-K10", "열린 팝업 일괄 닫기(CloseAllPopups) 처리가 필요하다");
[Fact]
public void UC_K11_다운로드_중_종료_확인을_받는다()
=> SourceTree.Usecase(
SourceTree.MainWindowCs.Contains("종료하시겠습니까"),
"UC-K11", "다운로드 진행 중 종료 확인 대화상자가 필요하다");
[Fact]
public void UC_K12_F12_개발자_도구를_연다()
=> SourceTree.Usecase(
SourceTree.MainWindowCs.Contains("OpenDevTools"),
"UC-K12", "F12 개발자 도구(OpenDevTools) 처리가 필요하다");
[Fact]
public void UC_K13_프로필_전환_기능이_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowCs.Contains("프로필 전환"),
"UC-K13", "세션 프로필 전환 기능이 필요하다");
[Fact]
public void UC_K14_현재_줌_배율이_표시된다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("ZoomIndicator"),
"UC-K14", "줌 배율 표시(ZoomIndicator) 요소가 필요하다");
[Fact]
public void UC_K15_기본_줌을_설정할_수_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "DefaultZoomFactor"),
"UC-K15", "AppConfig.DefaultZoomFactor 속성이 필요하다");
[Fact]
public void UC_K16_즐겨찾기_관리_창이_있다()
=> SourceTree.Usecase(
SourceTree.FindType("BookmarkManagerWindow") is not null,
"UC-K16", "즐겨찾기 관리 창(BookmarkManagerWindow)이 필요하다");
[Fact]
public void UC_K17_방문_기록_개별_삭제가_된다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("DeleteHistory_Click"),
"UC-K17", "방문 기록 개별 삭제(DeleteHistory_Click) 처리기가 필요하다");
[Fact]
public void UC_K18_영역을_지정해_캡처할_수_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowCs.Contains("RegionCapture"),
"UC-K18", "영역 지정 캡처(RegionCapture) 기능이 필요하다");
[Fact]
public void UC_K19_노트를_Ctrl_S로_저장한다()
=> SourceTree.Usecase(
SourceTree.MainWindowCs.Contains("NotesSave_Click"),
"UC-K19", "노트 저장(NotesSave_Click) 처리기가 필요하다");
[Fact]
public void UC_K20_링크_우클릭으로_바로_받는다()
=> SourceTree.Usecase(
SourceTree.MainWindowCs.Contains("링크 다운로드"),
"UC-K20", "페이지 링크 우클릭 '링크 다운로드' 메뉴가 필요하다");
}

View file

@ -0,0 +1,168 @@
using VideoDownloader.App;
using VideoDownloader.Core.Config;
using VideoDownloader.Tests.Infrastructure;
namespace VideoDownloader.Tests.E2E;
/// <summary>
/// UI 복잡 버전 RED 스펙 — §C 레이아웃·밀도·창 상태 유즈케이스(docs/UI_USECASES.md §C, UC-L01~L26).
/// RED 단계: 전부 실패하는 것이 정상이다.
/// </summary>
public class UiUsecaseLayoutRedTests
{
[Fact]
public void UC_L01_UI_밀도_설정이_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "UiDensity"),
"UC-L01", "컴팩트/보통/여유 밀도를 고르는 AppConfig.UiDensity 가 필요하다");
[Fact]
public void UC_L02_설정창에서_밀도를_바꿀_수_있다()
=> SourceTree.Usecase(
SourceTree.SettingsWindowXaml.Contains("밀도"),
"UC-L02", "설정창에 '밀도' 항목이 노출되어야 한다");
[Fact]
public void UC_L03_작은_간격_토큰이_정의된다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.SharedXaml, "Spacing.S"),
"UC-L03", "간격 토큰 Spacing.S 가 필요하다");
[Fact]
public void UC_L04_중간_간격_토큰이_정의된다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.SharedXaml, "Spacing.M"),
"UC-L04", "간격 토큰 Spacing.M 이 필요하다");
[Fact]
public void UC_L05_큰_간격_토큰이_정의된다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.SharedXaml, "Spacing.L"),
"UC-L05", "간격 토큰 Spacing.L 이 필요하다");
[Fact]
public void UC_L06_카드_모서리_둥글기_토큰이_정의된다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.SharedXaml, "CornerRadius.Card"),
"UC-L06", "카드 둥근 모서리 토큰 CornerRadius.Card 가 필요하다");
[Fact]
public void UC_L07_컴팩트_모드_상태가_저장된다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "CompactMode"),
"UC-L07", "AppConfig.CompactMode 로 재시작 후에도 컴팩트 상태가 유지되어야 한다");
[Fact]
public void UC_L08_툴바에_컴팩트_토글_버튼이_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("Name=\"CompactModeBtn\""),
"UC-L08", "툴바의 CompactModeBtn 로 컴팩트 모드를 빠르게 켜고 끌 수 있어야 한다");
[Fact]
public void UC_L09_세로_탭_기본_여부를_설정할_수_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "VerticalTabsDefault"),
"UC-L09", "AppConfig.VerticalTabsDefault 속성이 필요하다");
[Fact]
public void UC_L10_분할_화면_비율이_저장된다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "SplitViewRatio"),
"UC-L10", "분할 화면 비율(AppConfig.SplitViewRatio)이 저장·복원되어야 한다");
[Fact]
public void UC_L11_창_위치_크기가_저장된다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "WindowBounds"),
"UC-L11", "AppConfig.WindowBounds 로 창 위치·크기가 복원되어야 한다");
[Fact]
public void UC_L12_최대화_상태가_저장된다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "WindowMaximized"),
"UC-L12", "AppConfig.WindowMaximized 로 최대화 여부가 복원되어야 한다");
[Fact]
public void UC_L13_루트_요소가_레이아웃_반올림을_켠다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("UseLayoutRounding"),
"UC-L13", "MainWindow 가 UseLayoutRounding 으로 고DPI 흐림을 방지해야 한다");
[Fact]
public void UC_L14_공용_스타일이_픽셀_정렬을_켠다()
=> SourceTree.Usecase(
SourceTree.SharedXaml.Contains("SnapsToDevicePixels"),
"UC-L14", "Shared.xaml 스타일이 SnapsToDevicePixels 로 선명도를 보장해야 한다");
[Fact]
public void UC_L15_상태바_자동_숨김을_설정할_수_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "StatusBarAutoHide"),
"UC-L15", "AppConfig.StatusBarAutoHide 속성이 필요하다");
[Fact]
public void UC_L16_툴바_버튼_노출을_직접_고른다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "ToolbarButtons"),
"UC-L16", "AppConfig.ToolbarButtons 로 버튼 표시 여부를 지정할 수 있어야 한다");
[Fact]
public void UC_L17_북마크바_표시_여부가_저장된다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "BookmarkBarVisible"),
"UC-L17", "AppConfig.BookmarkBarVisible 속성이 필요하다");
[Fact]
public void UC_L18_탭_스트립_위치를_설정할_수_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "TabStripPosition"),
"UC-L18", "AppConfig.TabStripPosition 로 탭 스트립 위치를 고를 수 있어야 한다");
[Fact]
public void UC_L19_패널_폭을_더블클릭으로_초기화한다()
=> SourceTree.Usecase(
SourceTree.MethodExists(typeof(MainWindow), "ResetPanelWidth"),
"UC-L19", "MainWindow.ResetPanelWidth 로 패널 폭을 기본값으로 되돌릴 수 있어야 한다");
[Fact]
public void UC_L20_패널_최소_폭을_설정할_수_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "RightPanelMinWidth"),
"UC-L20", "AppConfig.RightPanelMinWidth 속성이 필요하다");
[Fact]
public void UC_L21_애니메이션_축소_설정이_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "ReduceMotion"),
"UC-L21", "AppConfig.ReduceMotion 로 애니메이션을 줄일 수 있어야 한다");
[Fact]
public void UC_L22_빠른_애니메이션_지속시간_토큰이_정의된다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.SharedXaml, "Duration.Fast"),
"UC-L22", "지속 시간 토큰 Duration.Fast 가 필요하다");
[Fact]
public void UC_L23_일반_애니메이션_지속시간_토큰이_정의된다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.SharedXaml, "Duration.Normal"),
"UC-L23", "지속 시간 토큰 Duration.Normal 이 필요하다");
[Fact]
public void UC_L24_컴팩트_엣지_호버_반응_속도를_설정한다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "EdgeRevealDelayMs"),
"UC-L24", "AppConfig.EdgeRevealDelayMs 속성이 필요하다");
[Fact]
public void UC_L25_UI_전체_배율을_설정할_수_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "UiScale"),
"UC-L25", "AppConfig.UiScale 로 UI 전체 배율을 조절할 수 있어야 한다");
[Fact]
public void UC_L26_전체화면_여부가_저장된다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "FullScreen"),
"UC-L26", "AppConfig.FullScreen 속성이 필요하다");
}

View file

@ -0,0 +1,143 @@
using VideoDownloader.Core.Config;
using VideoDownloader.Tests.Infrastructure;
namespace VideoDownloader.Tests.E2E;
/// <summary>
/// UI 복잡 버전 RED 스펙 — §I 라이브러리·재생목록·즐겨찾기·기록·노트 유즈케이스(docs/UI_USECASES.md §I, UC-M01~M22).
/// 데스크톱 앱에는 아직 라이브러리 뷰가 없으므로 전부 RED 다.
/// </summary>
public class UiUsecaseLibraryRedTests
{
[Fact]
public void UC_M01_라이브러리_뷰가_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("LibraryPopup") || SourceTree.MainWindowXaml.Contains("LibraryBtn"),
"UC-M01", "받은 파일을 보는 라이브러리 뷰(LibraryPopup/LibraryBtn)가 필요하다");
[Fact]
public void UC_M02_라이브러리를_그리드_리스트로_전환한다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("LibraryViewToggle"),
"UC-M02", "그리드/리스트 전환(LibraryViewToggle)이 필요하다");
[Fact]
public void UC_M03_라이브러리를_검색할_수_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("LibrarySearchBox"),
"UC-M03", "라이브러리 검색 상자(LibrarySearchBox)가 필요하다");
[Fact]
public void UC_M04_라이브러리를_정렬할_수_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("LibrarySortMenu"),
"UC-M04", "라이브러리 정렬 메뉴(LibrarySortMenu)가 필요하다");
[Fact]
public void UC_M05_항목에_추가일이_표시된다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("AddedDateText"),
"UC-M05", "라이브러리 항목의 추가일 표시(AddedDateText)가 필요하다");
[Fact]
public void UC_M06_항목에_썸네일이_표시된다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("LibraryThumbnail"),
"UC-M06", "라이브러리 썸네일(LibraryThumbnail)이 필요하다");
[Fact]
public void UC_M07_항목을_더블클릭해_재생한다()
=> SourceTree.Usecase(
SourceTree.MainWindowCs.Contains("LibraryPlay_Click"),
"UC-M07", "라이브러리 재생(LibraryPlay_Click) 처리기가 필요하다");
[Fact]
public void UC_M08_항목을_삭제할_수_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowCs.Contains("LibraryDelete_Click"),
"UC-M08", "라이브러리 삭제(LibraryDelete_Click) 처리기가 필요하다");
[Fact]
public void UC_M09_항목_폴더를_열_수_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowCs.Contains("LibraryOpenFolder_Click"),
"UC-M09", "라이브러리 폴더 열기(LibraryOpenFolder_Click)가 필요하다");
[Fact]
public void UC_M10_라이브러리_빈_상태_안내가_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("LibraryEmptyHint"),
"UC-M10", "라이브러리 빈 상태 안내(LibraryEmptyHint)가 필요하다");
[Fact]
public void UC_M11_재생목록_셔플이_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("PlaylistShuffle"),
"UC-M11", "재생목록 셔플(PlaylistShuffle) 요소가 필요하다");
[Fact]
public void UC_M12_반복_모드가_저장된다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "PlaylistLoopMode"),
"UC-M12", "AppConfig.PlaylistLoopMode 속성이 필요하다");
[Fact]
public void UC_M13_재생목록을_파일로_저장_불러온다()
=> SourceTree.Usecase(
SourceTree.MainWindowCs.Contains("PlaylistExport"),
"UC-M13", "재생목록 내보내기/불러오기(PlaylistExport)가 필요하다");
[Fact]
public void UC_M14_재생목록_순서를_바꾼다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("PlaylistMoveUp"),
"UC-M14", "재생목록 순서 변경(PlaylistMoveUp) 요소가 필요하다");
[Fact]
public void UC_M15_재생목록을_검색한다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("PlaylistSearchBox"),
"UC-M15", "재생목록 검색 상자(PlaylistSearchBox)가 필요하다");
[Fact]
public void UC_M16_즐겨찾기를_폴더로_정리한다()
=> SourceTree.Usecase(
SourceTree.MainWindowCs.Contains("BookmarkFolder"),
"UC-M16", "즐겨찾기 폴더(BookmarkFolder) 기능이 필요하다");
[Fact]
public void UC_M17_즐겨찾기를_편집한다()
=> SourceTree.Usecase(
SourceTree.MainWindowCs.Contains("EditBookmark"),
"UC-M17", "즐겨찾기 편집(EditBookmark) 기능이 필요하다");
[Fact]
public void UC_M18_즐겨찾기를_검색한다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("BookmarkSearchBox"),
"UC-M18", "즐겨찾기 검색 상자(BookmarkSearchBox)가 필요하다");
[Fact]
public void UC_M19_즐겨찾기를_HTML로_내보낸다()
=> SourceTree.Usecase(
SourceTree.MainWindowCs.Contains("ExportBookmarks"),
"UC-M19", "즐겨찾기 내보내기(ExportBookmarks) 기능이 필요하다");
[Fact]
public void UC_M20_방문_기록을_날짜별로_묶는다()
=> SourceTree.Usecase(
SourceTree.MainWindowCs.Contains("GroupHistoryByDate"),
"UC-M20", "방문 기록 날짜 묶음(GroupHistoryByDate) 기능이 필요하다");
[Fact]
public void UC_M21_방문_기록을_검색한다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("HistorySearchBox"),
"UC-M21", "방문 기록 검색 상자(HistorySearchBox)가 필요하다");
[Fact]
public void UC_M22_노트를_검색한다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("NotesSearchBox"),
"UC-M22", "노트 검색 상자(NotesSearchBox)가 필요하다");
}

View file

@ -0,0 +1,249 @@
using System.Windows;
using System.Windows.Controls;
using VideoDownloader.App;
using VideoDownloader.App.Browser.Features;
using VideoDownloader.App.Core;
using VideoDownloader.App.Services;
using VideoDownloader.App.ViewModels;
using VideoDownloader.Core.Config;
using VideoDownloader.Tests.Infrastructure;
using WpfApp = VideoDownloader.App.App;
namespace VideoDownloader.Tests.E2E;
/// <summary>
/// E2E: "UI 복잡 버전"(docs/UI_USECASES.md §A~§J) GREEN 구현의 실창 실증 —
/// MainWindowSmokeE2ETests 와 동일한 StaDispatcher/WpfTestApp 패턴으로 실제 윈도우를 띄워
/// ① 신규 유즈케이스 컨트롤이 실 트리에 존재하는지 ② 테마가 재시작 없이 라이브 전환되는지(UC-T03/T04)
/// ③ 다운로드 일시정지/재개 상태 전이(UC-D06/D07) ④ 설정창 검색 필터·테마 즉시 적용(UC-S01)
/// ⑤ 토스트 방해 금지 억제·알림센터 히스토리(UC-N14/N15)를 검증한다.
/// </summary>
[Collection("E2E")]
public class UiUsecaseLiveE2ETests
{
private static string TempDataDir()
{
var dir = Path.Combine(Path.GetTempPath(), $"vd_uc_e2e_{Guid.NewGuid():N}");
Environment.SetEnvironmentVariable("VD_DATA_DIR", dir);
return dir;
}
[Fact]
public async Task MainWindow_유즈케이스_컨트롤_실존과_라이브_테마_전환_E2E()
{
var dataDir = TempDataDir();
try
{
using var sta = StaDispatcher.Start();
WpfTestApp.Ensure(sta.Dispatcher);
var ok = await sta.InvokeAsync(async () =>
{
var window = new MainWindow();
window.Show();
var first = await Wait.Until(
() => Task.FromResult(window.Tabs.ActiveTab),
t => t?.Core != null, 30000);
// ── §C/§D/§E/§I/§J 신규 컨트롤이 실 트리에 존재(최상위 x:Name 필드) ──
var mustExist = new FrameworkElement?[]
{
// 툴바
window.CompactModeBtn, window.LibraryBtn, window.PairingBtn,
window.DownloadsBtnBadge, window.UpdateBadge,
// 탭 스트립
window.WorkspaceIndicator, window.WorkspaceSwitcher, window.NewTabMoreBtn,
window.ArchiveBtn, window.TabInsertIndicator,
// 다운로드 패널
window.DownloadFilterTabs, window.DownloadSearchBox, window.QueueSummaryText,
window.GlobalSpeedText, window.SpeedSparkline,
// 상태바·오버레이
window.StatusRight, window.ZoomIndicator, window.ServerStatusIcon,
window.NotificationCenter, window.ShortcutHelpBtn, window.AggregateProgress,
window.ToastHost, window.ErrorBanner, window.ErrorDetailExpander,
window.ClipboardHint, window.Onboarding,
// 팝업 본문
window.ArchivePopup, window.ArchiveSearchBox,
window.LibraryPopup, window.LibrarySearchBox, window.LibraryViewToggle, window.LibrarySortMenu,
window.BookmarkSearchBox, window.HistorySearchBox, window.NotesSearchBox,
window.PlaylistSearchBox, window.PlaylistShuffle,
window.ShortcutHelpPopup, window.NotificationCenterPopup, window.TabVolumePopup,
};
var missing = mustExist.Count(e => e == null);
// ── §J 초기 상태 계약: 온보딩 표시(세션 없음)·오류배너·클립보드힌트 숨김 ──
var onboardingShown = window.Onboarding.Visibility == Visibility.Visible;
var errorHidden = window.ErrorBanner.Visibility == Visibility.Collapsed;
var clipboardHidden = window.ClipboardHint.Visibility == Visibility.Collapsed;
// ── §A 라이브 테마 전환(UC-T03/T04) — 창을 다시 만들지 않고 즉시 ──
window.ApplyTheme("light");
var lightApplied = WpfApp.CurrentTheme == "light" && WpfApp.IsLightTheme;
var aliveInLight = window.IsLoaded;
window.ApplyTheme("dark");
var darkApplied = WpfApp.CurrentTheme == "dark" && !WpfApp.IsLightTheme;
var aliveInDark = window.IsLoaded;
var lightRestore = WpfApp.CurrentTheme == "dark"; // 원복 확인
window.Close();
await Task.Delay(800); // WebView2 파일 핸들 해제 여유(smoke 테스트 패턴)
var fails = new List<string>();
if (missing != 0) fails.Add($"컨트롤 누락 {missing}개");
if (!onboardingShown) fails.Add("온보딩 미표시");
if (!errorHidden) fails.Add("오류배너 초기 미숨김");
if (!clipboardHidden) fails.Add("클립보드힌트 초기 미숨김");
if (!lightApplied) fails.Add("라이트 테마 미적용");
if (!aliveInLight) fails.Add("라이트 전환 후 창 사망");
if (!darkApplied) fails.Add("다크 원복 미적용");
if (!aliveInDark) fails.Add("다크 전환 후 창 사망");
return string.Join(" | ", fails);
});
Assert.True(string.IsNullOrEmpty(ok), $"MainWindow 유즈케이스 E2E 실패: {ok}");
}
finally
{
Environment.SetEnvironmentVariable("VD_DATA_DIR", null);
try { Directory.Delete(dataDir, recursive: true); } catch { } // WebView2 파일 점유 시 남김(smoke 테스트 패턴)
}
}
[Fact]
public void DownloadManager_전체_일시정지_및_재개_상태전이_E2E()
{
var dataDir = TempDataDir();
var outDir = Path.Combine(dataDir, "out");
try
{
using var sta = StaDispatcher.Start();
WpfTestApp.Ensure(sta.Dispatcher);
var ok = sta.Invoke(() =>
{
var config = new AppConfig { OutputFolder = outDir, MaxConcurrentDownloads = 2 };
var manager = new DownloadManager(
config,
_ => Task.FromResult(new List<Microsoft.Web.WebView2.Core.CoreWebView2Cookie>()),
_ => { });
var running = new DownloadItemViewModel("진행 중 항목")
{
Status = "다운로드 중",
Started = true,
MediaUrl = new Uri("https://127.0.0.1:9/video.mp4"), // 비라우팅 — 재개가 네트워크를 치지 않게
OutputBase = Path.Combine(outDir, "video"),
};
var queued = new DownloadItemViewModel("대기 항목")
{
Status = "대기",
MediaUrl = new Uri("https://example.com/video2.mp4"),
OutputBase = Path.Combine(outDir, "video2"),
};
manager.Items.Add(running);
manager.Items.Add(queued);
// UC-D06: 전체 일시정지 — 진행 중만 "일시정지" 전이, 대기 항목은 그대로
manager.PauseAll();
var pausedStatus = running.Status == "일시정지" && running.PausedForResume;
var queuedUntouched = queued.Status == "대기";
// UC-D07: 전체 재개 — 일시정지 항목만 재투입(PausedForResume 해제 + Started 재설정)
manager.ResumeAll();
var resumed = !running.PausedForResume && running.Started; // 재시작 큐 진입
manager.Cancel(running); // 실측 목적 달성 — 원격 접속 시도 중단
var queuedStillQueued = queued.Started == false && queued.PausedForResume == false;
var fails = new List<string>();
if (!pausedStatus) fails.Add($"PauseAll 후 상태={running.Status}/PausedForResume={running.PausedForResume}");
if (!queuedUntouched) fails.Add($"대기 항목이 건드려짐(상태={queued.Status})");
if (!resumed) fails.Add($"ResumeAll 후 재투입 안 됨(Started={running.Started}/PausedForResume={running.PausedForResume})");
if (!queuedStillQueued) fails.Add("대기 항목이 재개 대상이 됨");
return string.Join(" | ", fails);
});
Assert.True(string.IsNullOrEmpty(ok), $"일시정지/재개 E2E 실패: {ok}");
}
finally
{
Environment.SetEnvironmentVariable("VD_DATA_DIR", null);
try { Directory.Delete(dataDir, recursive: true); } catch { } // WebView2 파일 점유 시 남김(smoke 테스트 패턴)
}
}
[Fact]
public void SettingsWindow_검색_필터와_테마_즉시적용_E2E()
{
var dataDir = TempDataDir();
try
{
using var sta = StaDispatcher.Start();
WpfTestApp.Ensure(sta.Dispatcher);
var ok = sta.Invoke(() =>
{
var config = new AppConfig(); // 기본 테마 dark
var win = new SettingsWindow(config);
// 생성 시점 로드: 테마 dark 반영 + 검색창 비어 전 섹션 표시
var initialTheme = WpfApp.CurrentTheme == "dark";
var allVisible = win.SectionFont.Visibility == Visibility.Visible
&& win.SectionDownload.Visibility == Visibility.Visible;
// UC-T01/T02: 콤보박스로 라이트 선택 → 앱 테마가 즉시 전환(재시작 없음)
win.ThemeModeBox.SelectedValue = "light";
var lightLive = WpfApp.CurrentTheme == "light" && WpfApp.IsLightTheme;
win.ThemeModeBox.SelectedValue = "dark";
var backToDark = WpfApp.CurrentTheme == "dark";
// UC-S01: 설정 검색 — 해당 섹션만 노출
win.SettingsSearchBox.Text = "글꼴";
var fontOnly = win.SectionFont.Visibility == Visibility.Visible
&& win.SectionDownload.Visibility == Visibility.Collapsed
&& win.SectionNotify.Visibility == Visibility.Collapsed;
win.SettingsSearchBox.Text = "알림";
var downloadOnly = win.SectionNotify.Visibility == Visibility.Visible
&& win.SectionDownload.Visibility == Visibility.Collapsed
&& win.SectionAccessibility.Visibility == Visibility.Collapsed;
win.SettingsSearchBox.Text = "";
var restored = win.SectionFont.Visibility == Visibility.Visible
&& win.SectionDownload.Visibility == Visibility.Visible;
win.Close();
var fails = new List<string>();
if (!initialTheme) fails.Add($"초기 테마 dark 아님(현재 {WpfApp.CurrentTheme})");
if (!allVisible) fails.Add("초기 전 섹션 표시 아님");
if (!lightLive) fails.Add($"라이트 즉시 적용 실패(현재 {WpfApp.CurrentTheme}/{WpfApp.IsLightTheme})");
if (!backToDark) fails.Add($"다크 원복 실패(현재 {WpfApp.CurrentTheme})");
if (!fontOnly) fails.Add("'글꼴' 검색 시 글꼴 섹션만 표시 아님");
if (!downloadOnly) fails.Add("'알림' 검색 시 알림 섹션만 표시 아님");
if (!restored) fails.Add("빈 검색어로 복원 안 됨");
return string.Join(" | ", fails);
});
Assert.True(string.IsNullOrEmpty(ok), $"설정창 E2E 실패: {ok}");
}
finally
{
Environment.SetEnvironmentVariable("VD_DATA_DIR", null);
try { Directory.Delete(dataDir, recursive: true); } catch { } // WebView2 파일 점유 시 남김(smoke 테스트 패턴)
}
}
[Fact]
public void ToastNotifier_방해금지_억제와_알림센터_히스토리_E2E()
{
using var sta = StaDispatcher.Start();
WpfTestApp.Ensure(sta.Dispatcher);
var ok = sta.Invoke(() =>
{
var host = new StackPanel();
var toast = new ToastNotifier(host) { Duration = TimeSpan.FromMilliseconds(500) };
// 일반 표시: 토스트 1장 + 히스토리 1건(UC-N02)
toast.Show("완료: 첫 번째", "success");
var shown = host.Children.Count == 1 && toast.History.Count == 1;
// UC-N15 방해 금지: 표시는 억제되고 히스토리에만 기록(UC-N14 알림 센터 원천)
toast.DoNotDisturb = true;
toast.Show("실패: 두 번째", "error");
var suppressed = host.Children.Count == 1 && toast.History.Count == 2;
return shown && suppressed;
});
Assert.True(ok, "토스트 방해 금지/히스토리 계약 위반");
}
}

View file

@ -0,0 +1,132 @@
using VideoDownloader.App;
using VideoDownloader.Core.Config;
using VideoDownloader.Tests.Infrastructure;
namespace VideoDownloader.Tests.E2E;
/// <summary>
/// UI 복잡 버전 RED 스펙 — §F 설정 창 유즈케이스(docs/UI_USECASES.md §F, UC-S01~S20).
/// RED 단계: 전부 실패하는 것이 정상이다.
/// </summary>
public class UiUsecaseSettingsRedTests
{
[Fact]
public void UC_S01_설정을_검색으로_찾을_수_있다()
=> SourceTree.Usecase(
SourceTree.SettingsWindowXaml.Contains("SettingsSearchBox"),
"UC-S01", "설정 검색 상자(SettingsSearchBox)가 필요하다");
[Fact]
public void UC_S02_카테고리_내비게이션이_있다()
=> SourceTree.Usecase(
SourceTree.SettingsWindowXaml.Contains("SettingsNavList"),
"UC-S02", "좌측 카테고리 내비게이션(SettingsNavList)이 필요하다");
[Fact]
public void UC_S03_재시작_필요_변경을_배너로_알린다()
=> SourceTree.Usecase(
SourceTree.SettingsWindowXaml.Contains("RestartBanner"),
"UC-S03", "재시작 필요 알림 배너(RestartBanner)가 필요하다");
[Fact]
public void UC_S04_기본값으로_한번에_되돌릴_수_있다()
=> SourceTree.Usecase(
SourceTree.SettingsWindowXaml.Contains("ResetDefaultsButton"),
"UC-S04", "기본값 복원 버튼(ResetDefaultsButton)이 필요하다");
[Fact]
public void UC_S05_설정을_내보낼_수_있다()
=> SourceTree.Usecase(
SourceTree.SettingsWindowXaml.Contains("ExportSettingsButton"),
"UC-S05", "설정 내보내기 버튼(ExportSettingsButton)이 필요하다");
[Fact]
public void UC_S06_설정을_가져올_수_있다()
=> SourceTree.Usecase(
SourceTree.SettingsWindowXaml.Contains("ImportSettingsButton"),
"UC-S06", "설정 가져오기 버튼(ImportSettingsButton)이 필요하다");
[Fact]
public void UC_S07_고급_항목을_접을_수_있다()
=> SourceTree.Usecase(
SourceTree.SettingsWindowXaml.Contains("AdvancedSection"),
"UC-S07", "고급 섹션 접힘(AdvancedSection)이 필요하다");
[Fact]
public void UC_S08_Ctrl_S로_저장할_수_있다()
=> SourceTree.Usecase(
SourceTree.SettingsWindowCs.Contains("Key.S"),
"UC-S08", "설정창 코드비하인드가 Ctrl+S(Key.S) 저장을 처리해야 한다");
[Fact]
public void UC_S09_취소_시_폐기_확인을_받는다()
=> SourceTree.Usecase(
SourceTree.MethodExists(typeof(SettingsWindow), "ConfirmDiscardChanges"),
"UC-S09", "SettingsWindow.ConfirmDiscardChanges() 확인 절차가 필요하다");
[Fact]
public void UC_S10_설정창_크기_위치가_기억된다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "SettingsWindowBounds"),
"UC-S10", "AppConfig.SettingsWindowBounds 속성이 필요하다");
[Fact]
public void UC_S11_재시도_횟수를_UI에서_바꾼다()
=> SourceTree.Usecase(
SourceTree.SettingsWindowXaml.Contains("Name=\"RetriesBox\""),
"UC-S11", "재시도 횟수 입력(RetriesBox)이 필요하다");
[Fact]
public void UC_S12_UserAgent를_UI에서_바꾼다()
=> SourceTree.Usecase(
SourceTree.SettingsWindowXaml.Contains("Name=\"UserAgentBox\""),
"UC-S12", "User-Agent 입력(UserAgentBox)이 필요하다");
[Fact]
public void UC_S13_프록시를_설정할_수_있다()
=> SourceTree.Usecase(
SourceTree.SettingsWindowXaml.Contains("Name=\"ProxyBox\""),
"UC-S13", "프록시 설정(ProxyBox)이 필요하다");
[Fact]
public void UC_S14_UI_언어를_고를_수_있다()
=> SourceTree.Usecase(
SourceTree.SettingsWindowXaml.Contains("Name=\"LanguageBox\""),
"UC-S14", "언어 선택(LanguageBox)이 필요하다");
[Fact]
public void UC_S15_자동_실행을_설정할_수_있다()
=> SourceTree.Usecase(
SourceTree.SettingsWindowXaml.Contains("Name=\"AutoStartBox\""),
"UC-S15", "시작 시 자동 실행(AutoStartBox)이 필요하다");
[Fact]
public void UC_S16_트레이로_최소화할_수_있다()
=> SourceTree.Usecase(
SourceTree.SettingsWindowXaml.Contains("MinimizeToTrayBox"),
"UC-S16", "트레이 최소화 옵션(MinimizeToTrayBox)이 필요하다");
[Fact]
public void UC_S17_알림_설정_섹션이_있다()
=> SourceTree.Usecase(
SourceTree.SettingsWindowXaml.Contains("알림"),
"UC-S17", "설정창에 '알림' 섹션이 필요하다");
[Fact]
public void UC_S18_실험적_기능_섹션이_있다()
=> SourceTree.Usecase(
SourceTree.SettingsWindowXaml.Contains("실험적"),
"UC-S18", "설정창에 '실험적' 기능 섹션이 필요하다");
[Fact]
public void UC_S19_실시간_검증_힌트가_있다()
=> SourceTree.Usecase(
SourceTree.SettingsWindowXaml.Contains("ValidationHint"),
"UC-S19", "입력 검증 힌트(ValidationHint) 요소가 필요하다");
[Fact]
public void UC_S20_버전_정보가_표시된다()
=> SourceTree.Usecase(
SourceTree.SettingsWindowXaml.Contains("Name=\"VersionText\""),
"UC-S20", "앱 버전 표시(VersionText)가 필요하다");
}

View file

@ -0,0 +1,131 @@
using VideoDownloader.Core.Config;
using VideoDownloader.Tests.Infrastructure;
namespace VideoDownloader.Tests.E2E;
/// <summary>
/// UI 복잡 버전 RED 스펙 — §J 상태 표시·알림·피드백 유즈케이스(docs/UI_USECASES.md §J, UC-N01~N20).
/// RED 단계: 전부 실패하는 것이 정상이다.
/// </summary>
public class UiUsecaseStatusRedTests
{
[Fact]
public void UC_N01_상태바에_전체_다운로드_진행률이_표시된다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("AggregateProgress"),
"UC-N01", "상태바 통합 진행률(AggregateProgress) 요소가 필요하다");
[Fact]
public void UC_N02_토스트_호스트가_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("ToastHost"),
"UC-N02", "알림 토스트 컨테이너(ToastHost)가 필요하다");
[Fact]
public void UC_N03_토스트_표시_시간을_설정한다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "ToastDurationMs"),
"UC-N03", "AppConfig.ToastDurationMs 속성이 필요하다");
[Fact]
public void UC_N04_치명_오류_배너가_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("ErrorBanner"),
"UC-N04", "상단 고정 오류 배너(ErrorBanner)가 필요하다");
[Fact]
public void UC_N05_오류_상세를_펼쳐_볼_수_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("ErrorDetailExpander"),
"UC-N05", "오류 상세 펼침(ErrorDetailExpander) 요소가 필요하다");
[Fact]
public void UC_N06_클립보드_URL_감지_제안이_뜬다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("ClipboardHint"),
"UC-N06", "클립보드 URL 감지 배지(ClipboardHint)가 필요하다");
[Fact]
public void UC_N07_원격_서버_상태가_상시_표시된다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("ServerStatusIcon"),
"UC-N07", "서버 상태 아이콘(ServerStatusIcon)이 필요하다");
[Fact]
public void UC_N08_툴바에서_QR_페어링을_바로_연다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("PairingBtn"),
"UC-N08", "툴바 페어링 버튼(PairingBtn)이 필요하다");
[Fact]
public void UC_N09_업데이트_가능_배지가_뜬다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("UpdateBadge"),
"UC-N09", "새 버전 알림 배지(UpdateBadge)가 필요하다");
[Fact]
public void UC_N10_차단_도메인별_통계가_보인다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("ShieldDomainStats"),
"UC-N10", "도메인별 차단 통계(ShieldDomainStats)가 필요하다");
[Fact]
public void UC_N11_네트워크_끊김이_표시된다()
=> SourceTree.Usecase(
SourceTree.MainWindowCs.Contains("NetworkChange"),
"UC-N11", "NetworkChange 오프라인 감지 처리가 필요하다");
[Fact]
public void UC_N12_절전_모드_진입_시_일시정지_옵션이_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "PauseOnPowerSaver"),
"UC-N12", "AppConfig.PauseOnPowerSaver 속성이 필요하다");
[Fact]
public void UC_N13_상태바_텍스트를_클릭해_복사한다()
=> SourceTree.Usecase(
SourceTree.MainWindowCs.Contains("CopyStatus_Click"),
"UC-N13", "상태 복사(CopyStatus_Click) 처리기가 필요하다");
[Fact]
public void UC_N14_알림_센터가_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("NotificationCenter"),
"UC-N14", "지나간 알림 목록(NotificationCenter)이 필요하다");
[Fact]
public void UC_N15_방해_금지_모드를_설정할_수_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "DoNotDisturb"),
"UC-N15", "AppConfig.DoNotDisturb 속성이 필요하다");
[Fact]
public void UC_N16_온보딩_안내가_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("Onboarding") || SourceTree.MainWindowCs.Contains("Onboarding"),
"UC-N16", "첫 실행 온보딩(Onboarding) 요소가 필요하다");
[Fact]
public void UC_N17_빈_상태_공용_스타일이_정의된다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.SharedXaml, "EmptyStateStyle"),
"UC-N17", "빈 상태 공용 스타일(EmptyStateStyle) 토큰이 필요하다");
[Fact]
public void UC_N18_크래시_리포트_대화상자가_있다()
=> SourceTree.Usecase(
SourceTree.FindType("CrashReportDialog") is not null,
"UC-N18", "충돌 리포트 대화상자(CrashReportDialog)가 필요하다");
[Fact]
public void UC_N19_다운로드_버튼에_진행_개수_배지가_뜬다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("DownloadsBtnBadge"),
"UC-N19", "툴바 다운로드 버튼 배지(DownloadsBtnBadge)가 필요하다");
[Fact]
public void UC_N20_상태바_우측_보조_정보_영역이_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("StatusRight"),
"UC-N20", "상태바 우측 정보 영역(StatusRight)이 필요하다");
}

View file

@ -0,0 +1,163 @@
using VideoDownloader.App;
using VideoDownloader.Browser;
using VideoDownloader.Core.Config;
using VideoDownloader.Tests.Infrastructure;
namespace VideoDownloader.Tests.E2E;
/// <summary>
/// UI 복잡 버전 RED 스펙 — §D 탭 고급 관리 유즈케이스(docs/UI_USECASES.md §D, UC-B01~B24).
/// RED 단계: 전부 실패하는 것이 정상이다.
/// </summary>
public class UiUsecaseTabsRedTests
{
private static readonly Type TabItemType =
SourceTree.FindType("TabItem") ?? throw new InvalidOperationException("Browser TabItem 형식을 찾을 수 없습니다");
[Fact]
public void UC_B01_탭을_잠글_수_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(TabItemType, "IsLocked"),
"UC-B01", "탭 잠금(IsLocked)으로 실수로 닫는 것을 막을 수 있어야 한다");
[Fact]
public void UC_B02_탭마다_사용자_지정_색을_달_수_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(TabItemType, "CustomColor"),
"UC-B02", "탭 사용자 샆(CustomColor) 속성이 필요하다");
[Fact]
public void UC_B03_탭_그룹을_접을_수_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(TabItemType, "IsGroupCollapsed"),
"UC-B03", "그룹 접음(IsGroupCollapsed) 상태가 탭 항목에 있어야 한다");
[Fact]
public void UC_B04_탭_그룹_이름을_변경할_수_있다()
{
var ext = SourceTree.FindType("TabManagerGroupExtensions");
SourceTree.Usecase(
ext is not null && SourceTree.MethodExists(ext, "RenameGroup"),
"UC-B04", "그룹 이름 변경(RenameGroup) API 가 필요하다");
}
[Fact]
public void UC_B05_탭_호버_미리보기_컴포넌트가_있다()
=> SourceTree.Usecase(
SourceTree.FindType("TabPreview") is not null,
"UC-B05", "탭 호버 미리보기를 담당하는 TabPreview 컴포넌트가 필요하다");
[Fact]
public void UC_B06_잠자기_탭이_시각적으로_구분된다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("IsSuspended"),
"UC-B06", "탭 템플릿이 잠자기 상태(IsSuspended)를 시각적으로 표시해야 한다");
[Fact]
public void UC_B07_탭_번호_배지가_표시된다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("TabIndexBadge"),
"UC-B07", "탭 번호 배지(TabIndexBadge) 요소가 필요하다");
[Fact]
public void UC_B08_탭_드래그_삽입_표시기가_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("TabInsertIndicator"),
"UC-B08", "드래그 중 삽입 위치 표시기(TabInsertIndicator)가 필요하다");
[Fact]
public void UC_B09_다중_탭_닫기_확인_옵션이_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "ConfirmCloseMultipleTabs"),
"UC-B09", "AppConfig.ConfirmCloseMultipleTabs 속성이 필요하다");
[Fact]
public void UC_B10_휠로_탭을_전환하는_옵션이_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "TabWheelSwitch"),
"UC-B10", "AppConfig.TabWheelSwitch 속성이 필요하다");
[Fact]
public void UC_B11_탭_최대_개수를_설정할_수_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "MaxTabs"),
"UC-B11", "AppConfig.MaxTabs 속성이 필요하다");
[Fact]
public void UC_B12_세션_복원을_잠자기_상태로_할_수_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "RestoreTabsSuspended"),
"UC-B12", "AppConfig.RestoreTabsSuspended 속성이 필요하다");
[Fact]
public void UC_B13_닫은_탭_아카이브_전용_팝업이_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("Name=\"ArchivePopup\""),
"UC-B13", "아카이브를 보여주는 ArchivePopup 이 필요하다");
[Fact]
public void UC_B14_아카이브를_검색할_수_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("ArchiveSearchBox"),
"UC-B14", "아카이브 검색 상자(ArchiveSearchBox)가 필요하다");
[Fact]
public void UC_B15_아카이브를_한번에_비울_수_있다()
=> SourceTree.Usecase(
SourceTree.MethodExists(typeof(TabArchive), "Clear"),
"UC-B15", "TabArchive.Clear() 메서드가 필요하다");
[Fact]
public void UC_B16_아카이브_항목을_복원할_수_있다()
=> SourceTree.Usecase(
SourceTree.MethodExists(typeof(TabArchive), "Restore"),
"UC-B16", "TabArchive.Restore() 메서드가 필요하다");
[Fact]
public void UC_B17_탭_그룹_색_팔레트가_테마로_관리된다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.SharedXaml, "GroupPalette"),
"UC-B17", "그룹 색 팔레트(GroupPalette)가 공용 리소스로 정의되어야 한다");
[Fact]
public void UC_B18_현재_작업공간_이름이_표시된다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("WorkspaceIndicator"),
"UC-B18", "현재 작업공간 표시(WorkspaceIndicator) 요소가 필요하다");
[Fact]
public void UC_B19_작업공간_이름을_직접_지을_수_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "WorkspaceNames"),
"UC-B19", "AppConfig.WorkspaceNames 속성이 필요하다");
[Fact]
public void UC_B20_작업공간_전환_UI가_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("WorkspaceSwitcher"),
"UC-B20", "작업공간 전환기(WorkspaceSwitcher) 요소가 필요하다");
[Fact]
public void UC_B21_새_탭_버튼에_보조_메뉴가_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("NewTabMore_Click"),
"UC-B21", "새 탭 버튼의 보조 메뉴(최근 페이지·즐겨찾기) 핸들러가 필요하다");
[Fact]
public void UC_B22_탭_제목을_두_줄로_볼_수_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "TabTitleMultiline"),
"UC-B22", "AppConfig.TabTitleMultiline 속성이 필요하다");
[Fact]
public void UC_B23_탭에서_볼륨_슬라이더를_쓸_수_있다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("TabVolumePopup"),
"UC-B23", "탭 볼륨 조절 팝업(TabVolumePopup)이 필요하다");
[Fact]
public void UC_B24_탭_그룹을_즐겨찾기로_저장할_수_있다()
=> SourceTree.Usecase(
SourceTree.MethodExists(typeof(MainWindow), "BookmarkGroup"),
"UC-B24", "그룹 전체를 즐겨찾기로 저장하는 MainWindow.BookmarkGroup() 이 필요하다");
}

View file

@ -0,0 +1,213 @@
using VideoDownloader.App;
using VideoDownloader.Core.Config;
using VideoDownloader.Tests.Infrastructure;
namespace VideoDownloader.Tests.E2E;
/// <summary>
/// UI 복잡 버전 RED 스펙 — §A 테마·색상·대비 유즈케이스(docs/UI_USECASES.md §A, UC-T01~T30).
/// RED 단계: 전부 실패하는 것이 정상이다. 계약은 해당 문서의 "계약" 열을 따른다.
/// </summary>
public class UiUsecaseThemeRedTests
{
[Fact]
public void UC_T01_시스템_테마를_따라가는_옵션이_설정에_있다()
=> SourceTree.Usecase(
SourceTree.SettingsWindowXaml.Contains("Tag=\"system\""),
"UC-T01", "OS 테마(다크/라이트)를 자동으로 따라가는 'system' 옵션이 설정창에 노출된다");
[Fact]
public void UC_T02_테마를_콤보박스로_다중_옵션_중_고른다()
=> SourceTree.Usecase(
SourceTree.SettingsWindowXaml.Contains("Name=\"ThemeModeBox\""),
"UC-T02", "테마 선택이 체크박스(라이트만)가 아니라 다크/라이트/시스템/고대비 콤보박스여야 한다");
[Fact]
public void UC_T03_테마_전환에_재시작이_필요하다는_문구가_없어야_한다()
=> SourceTree.Usecase(
!SourceTree.SettingsWindowXaml.Contains("재시작"),
"UC-T03", "테마는 즉시 적용되어야 하며 설정창에 '재시작' 안내가 남아 있지 않아야 한다");
[Fact]
public void UC_T04_MainWindow에_라이브_테마_적용_API가_있다()
=> SourceTree.Usecase(
SourceTree.MethodExists(typeof(MainWindow), "ApplyTheme"),
"UC-T04", "테마 전환 후 열려 있는 MainWindow 자체가 리소스를 다시 적용하는 API 가 필요하다");
[Fact]
public void UC_T05_고대비_테마_사전_파일이_존재한다()
=> SourceTree.Usecase(
SourceTree.AppFileExists("Themes/HighContrast.xaml"),
"UC-T05", "고대비 사용자를 위한 Themes/HighContrast.xaml 리소스 사전이 필요하다");
[Fact]
public void UC_T06_설정에서_고대비_테마를_직접_고를_수_있다()
=> SourceTree.Usecase(
SourceTree.SettingsWindowXaml.Contains("Tag=\"highcontrast\""),
"UC-T06", "설정창 테마 옵션에 'highcontrast' 값이 제공되어야 한다");
[Fact]
public void UC_T07_사용자_지정_강조색이_설정에_저장된다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "AccentColor"),
"UC-T07", "AppConfig 에 강조색(AccentColor) 속성이 필요하다");
[Fact]
public void UC_T08_강조색을_즉시_적용하는_API가_있다()
=> SourceTree.Usecase(
SourceTree.MethodExists(typeof(VideoDownloader.App.App), "ApplyAccent"),
"UC-T08", "App.ApplyAccent 로 재시작 없이 강조색이 전체 컨트롤에 반영되어야 한다");
[Fact]
public void UC_T09_강조색_프리셋_스와치가_테마에_준비된다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.ThemeDarkXaml, "Accent.Blue"),
"UC-T09", "다크 테마에 Accent.Blue 등 강조색 프리셋 키가 정의되어야 한다");
[Fact]
public void UC_T10_진행바_스타일에_하드코딩된_강조색이_없어야_한다()
=> SourceTree.Usecase(
!SourceTree.SharedXaml.Contains("Color=\"#6366F1\""),
"UC-T10", "AccentProgressBar 템플릿이 하드코딩 색 대신 테마 강조색 리소스를 참조해야 한다");
[Fact]
public void UC_T11_대기_상태색_PendingBrush가_실제_UI에서_쓰인다()
=> SourceTree.Usecase(
SourceTree.MainWindowXaml.Contains("PendingBrush"),
"UC-T11", "테마에 정의만 된 PendingBrush 가 대기 상태 표시에 실제로 사용되어야 한다");
[Fact]
public void UC_T12_사용자_테마_파일_경로를_설정할_수_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "CustomThemePath"),
"UC-T12", "AppConfig.CustomThemePath 로 사용자 테마 파일을 불러올 수 있어야 한다");
[Fact]
public void UC_T13_시스템_테마_판별_컴포넌트가_존재한다()
=> SourceTree.Usecase(
SourceTree.FindType("ThemeResolver") is not null,
"UC-T13", "OS 테마 값을 판별하는 ThemeResolver 컴포넌트가 필요하다");
[Fact]
public void UC_T14_다크_TextMuted가_CardBg에서_AA대비를_만족한다()
{
var fg = SourceTree.BrushHex(SourceTree.ThemeDarkXaml, "TextMuted");
var bg = SourceTree.BrushHex(SourceTree.ThemeDarkXaml, "CardBg");
Assert.False(fg is null || bg is null, "UC-T14 — 테마 브러시 파싱 실패(테마 파일 구조 변경?)");
var ratio = SourceTree.ContrastRatio(fg!, bg!);
Assert.True(ratio >= 4.5, $"UC-T14 RED — 다크 TextMuted/CardBg 대비 {ratio:F2}:1 < 4.5:1 (WCAG AA)");
}
[Fact]
public void UC_T15_다크_TextMuted가_카드_호버_배경에서도_AA대비를_만족한다()
{
var fg = SourceTree.BrushHex(SourceTree.ThemeDarkXaml, "TextMuted");
var bg = SourceTree.BrushHex(SourceTree.ThemeDarkXaml, "CardBgHover");
Assert.False(fg is null || bg is null, "UC-T15 — 테마 브러시 파싱 실패");
var ratio = SourceTree.ContrastRatio(fg!, bg!);
Assert.True(ratio >= 4.5, $"UC-T15 RED — 다크 TextMuted/CardBgHover 대비 {ratio:F2}:1 < 4.5:1");
}
[Fact]
public void UC_T16_라이트_TextMuted가_CardBg에서_AA대비를_만족한다()
{
var fg = SourceTree.BrushHex(SourceTree.ThemeLightXaml, "TextMuted");
var bg = SourceTree.BrushHex(SourceTree.ThemeLightXaml, "CardBg");
Assert.False(fg is null || bg is null, "UC-T16 — 테마 브러시 파싱 실패");
var ratio = SourceTree.ContrastRatio(fg!, bg!);
Assert.True(ratio >= 4.5, $"UC-T16 RED — 라이트 TextMuted/CardBg 대비 {ratio:F2}:1 < 4.5:1");
}
[Fact]
public void UC_T17_라이트_TextMuted가_GlassBg에서_AA대비를_만족한다()
{
var fg = SourceTree.BrushHex(SourceTree.ThemeLightXaml, "TextMuted");
var bg = SourceTree.BrushHex(SourceTree.ThemeLightXaml, "GlassBg");
Assert.False(fg is null || bg is null, "UC-T17 — 테마 브러시 파싱 실패");
var ratio = SourceTree.ContrastRatio(fg!, bg!);
Assert.True(ratio >= 4.5, $"UC-T17 RED — 라이트 TextMuted/GlassBg 대비 {ratio:F2}:1 < 4.5:1");
}
[Fact]
public void UC_T18_다크_강조색_텍스트가_CardBg에서_AA대비를_만족한다()
{
var fg = SourceTree.BrushHex(SourceTree.ThemeDarkXaml, "AccentBrush");
var bg = SourceTree.BrushHex(SourceTree.ThemeDarkXaml, "CardBg");
Assert.False(fg is null || bg is null, "UC-T18 — 테마 브러시 파싱 실패");
var ratio = SourceTree.ContrastRatio(fg!, bg!);
Assert.True(ratio >= 4.5, $"UC-T18 RED — 다크 AccentBrush/CardBg 대비 {ratio:F2}:1 < 4.5:1 (포맷 배지 텍스트로 사용됨)");
}
[Fact]
public void UC_T19_비활성_텍스트_토큰이_양_테마에_정의된다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.ThemeDarkXaml, "TextDisabled")
&& SourceTree.HasKey(SourceTree.ThemeLightXaml, "TextDisabled"),
"UC-T19", "비활성 컨트롤 텍스트 샆(TextDisabled) 토큰이 다크·라이트 모두에 필요하다");
[Fact]
public void UC_T20_선택_영역_브러시_토큰이_테마에_있다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.ThemeDarkXaml, "SelectionBrush"),
"UC-T20", "텍스트 선택 영역 색(SelectionBrush)이 테마 토큰으로 관리되어야 한다");
[Fact]
public void UC_T21_포커스_링_브러시_토큰이_테마에_있다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.ThemeDarkXaml, "FocusAdornerBrush"),
"UC-T21", "키보드 포커스 링 샆(FocusAdornerBrush) 토큰이 필요하다");
[Fact]
public void UC_T22_스크롤바_썸_전용_색_토큰이_있다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.SharedXaml, "ScrollBarThumbBrush"),
"UC-T22", "스크롤바 썸 샆(ScrollBarThumbBrush)이 공용 토큰으로 정의되어야 한다");
[Fact]
public void UC_T23_툴팁_그림자_리소스가_정의된다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.SharedXaml, "TooltipShadow"),
"UC-T23", "툴팁 그림자(TooltipShadow) 리소스가 필요하다");
[Fact]
public void UC_T24_팝업_그림자_리소스가_정의된다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.SharedXaml, "PopupShadow"),
"UC-T24", "플라이아웃 공용 그림자(PopupShadow) 리소스가 필요하다");
[Fact]
public void UC_T25_경고_상태색_토큰이_대기색과_구분돼_있다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.ThemeDarkXaml, "WarningBrush"),
"UC-T25", "경고 샆(WarningBrush) 토큰이 PendingBrush 와 별도로 필요하다");
[Fact]
public void UC_T26_링크_색_토큰이_테마에_있다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.ThemeDarkXaml, "LinkBrush"),
"UC-T26", "링크 텍스트 샆(LinkBrush) 토큰이 필요하다");
[Fact]
public void UC_T27_방문한_링크_색_토큰이_테마에_있다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.ThemeDarkXaml, "VisitedLinkBrush"),
"UC-T27", "방문 링크 샆(VisitedLinkBrush) 토큰이 필요하다");
[Fact]
public void UC_T28_정보_안내색_토큰이_테마에_있다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.ThemeDarkXaml, "InfoBrush"),
"UC-T28", "정보성 안내 샆(InfoBrush) 토큰이 필요하다");
[Fact]
public void UC_T29_강조_텍스트_전용_브러시_토큰이_있다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.ThemeDarkXaml, "AccentTextBrush"),
"UC-T29", "강조색 중 텍스트 용도(AccentTextBrush) 토큰이 별도로 필요하다");
[Fact]
public void UC_T30_타이틀바_테마를_즉시_갱신하는_API가_있다()
=> SourceTree.Usecase(
SourceTree.MethodExists(typeof(MainWindow), "UpdateTitleBarTheme"),
"UC-T30", "테마 전환 시 DWM 타이틀바 특성을 다시 적용하는 MainWindow.UpdateTitleBarTheme 이 필요하다");
}

View file

@ -0,0 +1,144 @@
using VideoDownloader.App;
using VideoDownloader.Core.Config;
using VideoDownloader.Tests.Infrastructure;
namespace VideoDownloader.Tests.E2E;
/// <summary>
/// UI 복잡 버전 RED 스펙 — §B 타이포그래피·폰트 유즈케이스(docs/UI_USECASES.md §B, UC-F01~F22).
/// RED 단계: 전부 실패하는 것이 정상이다.
/// </summary>
public class UiUsecaseTypographyRedTests
{
[Fact]
public void UC_F01_UI_기본_폰트를_설정으로_고를_수_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "UiFontFamily"),
"UC-F01", "AppConfig.UiFontFamily 속성이 필요하다");
[Fact]
public void UC_F02_UI_폰트_크기를_설정으로_바꿀_수_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "UiFontSize"),
"UC-F02", "AppConfig.UiFontSize 속성이 필요하다");
[Fact]
public void UC_F03_한국어_폴백_폰트를_직접_지정할_수_있다()
=> SourceTree.Usecase(
SourceTree.PropertyExists(typeof(AppConfig), "UiFontKoreanFallback"),
"UC-F03", "AppConfig.UiFontKoreanFallback 속성이 필요하다");
[Fact]
public void UC_F04_기본_폰트_크기_토큰이_정의된다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.SharedXaml, "FontSize.Base"),
"UC-F04", "Shared.xaml 에 FontSize.Base 토큰이 필요하다");
[Fact]
public void UC_F05_작은_폰트_크기_토큰이_정의된다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.SharedXaml, "FontSize.Small"),
"UC-F05", "FontSize.Small 토큰이 필요하다");
[Fact]
public void UC_F06_큰_폰트_크기_토큰이_정의된다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.SharedXaml, "FontSize.Large"),
"UC-F06", "FontSize.Large 토큰이 필요하다");
[Fact]
public void UC_F07_초대형_폰트_크기_토큰이_정의된다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.SharedXaml, "FontSize.XLarge"),
"UC-F07", "FontSize.XLarge 토큰이 필요하다");
[Fact]
public void UC_F08_제목_H1_스타일이_공용으로_정의된다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.SharedXaml, "Type.H1"),
"UC-F08", "Type.H1 스타일 토큰이 필요하다");
[Fact]
public void UC_F09_본문_스타일이_공용으로_정의된다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.SharedXaml, "Type.Body"),
"UC-F09", "Type.Body 스타일 토큰이 필요하다");
[Fact]
public void UC_F10_캡션_스타일이_공용으로_정의된다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.SharedXaml, "Type.Caption"),
"UC-F10", "Type.Caption 스타일 토큰이 필요하다");
[Fact]
public void UC_F11_등폭_숫자_스타일이_정의된다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.SharedXaml, "Type.Mono"),
"UC-F11", "진행률·속도 숫자용 Type.Mono 스타일이 필요하다");
[Fact]
public void UC_F12_한국어_가독성_프리셋_폰트가_후보에_포함된다()
=> SourceTree.Usecase(
SourceTree.SharedXaml.Contains("Pretendard"),
"UC-F12", "UiFont 폰트 후보에 Pretendard 가 포함되어야 한다");
[Fact]
public void UC_F13_설정창에_글꼴_섹션이_있다()
=> SourceTree.Usecase(
SourceTree.SettingsWindowXaml.Contains("글꼴"),
"UC-F13", "설정창에 '글꼴' 섹션이 필요하다");
[Fact]
public void UC_F14_글꼴_미리보기_컨트롤이_있다()
=> SourceTree.Usecase(
SourceTree.SettingsWindowXaml.Contains("Name=\"FontPreviewText\""),
"UC-F14", "설정창에 FontPreviewText 미리보기 컨트롤이 필요하다");
[Fact]
public void UC_F15_폰트_크기_프리셋_콤보박스가_있다()
=> SourceTree.Usecase(
SourceTree.SettingsWindowXaml.Contains("Name=\"FontSizeBox\""),
"UC-F15", "설정창에 FontSizeBox 콤보박스가 필요하다");
[Fact]
public void UC_F16_폰트_설정을_즉시_적용하는_API가_있다()
=> SourceTree.Usecase(
SourceTree.MethodExists(typeof(VideoDownloader.App.App), "ApplyFontSettings"),
"UC-F16", "App.ApplyFontSettings 로 폰트 설정이 재시작 없이 반영되어야 한다");
[Fact]
public void UC_F17_리더_모드에_폰트가_하드코딩되지_않는다()
=> SourceTree.Usecase(
!SourceTree.ReaderModeCs.Contains("Malgun Gothic"),
"UC-F17", "리더 모드 HTML 이 폰트를 하드코딩 대신 앱 설정에서 주입받아야 한다");
[Fact]
public void UC_F18_새_탭_다이얼에_폰트가_하드코딩되지_않는다()
=> SourceTree.Usecase(
!SourceTree.MainWindowCs.Contains("Malgun Gothic"),
"UC-F18", "새 탭 다이얼 인라인 CSS 의 폰트 폴백이 앱 공용 설정으로 통일되어야 한다");
[Fact]
public void UC_F19_탭_제목_공용_스타일이_정의된다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.SharedXaml, "TabTitleStyle"),
"UC-F19", "탭 제목(말줄임 등)의 공용 스타일 TabTitleStyle 이 필요하다");
[Fact]
public void UC_F20_상태바_텍스트_공용_스타일이_정의된다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.SharedXaml, "StatusTextStyle"),
"UC-F20", "상태바 텍스트 공용 스타일 StatusTextStyle 이 필요하다");
[Fact]
public void UC_F21_아이콘_크기_토큰이_정의된다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.SharedXaml, "IconSize.Small"),
"UC-F21", "아이콘 크기 토큰 IconSize.Small 이 필요하다");
[Fact]
public void UC_F22_줄간격_토큰이_정의된다()
=> SourceTree.Usecase(
SourceTree.HasKey(SourceTree.SharedXaml, "Type.LineHeight"),
"UC-F22", "본문 줄간격 토큰 Type.LineHeight 가 필요하다");
}

View file

@ -0,0 +1,117 @@
using System.Reflection;
using System.Text.RegularExpressions;
using VideoDownloader.App;
using VideoDownloader.Browser;
using VideoDownloader.Core.Config;
namespace VideoDownloader.Tests.Infrastructure;
/// <summary>
/// UI 유즈케이스 RED 스펙 테스트(docs/UI_USECASES.md)용 소스 접근 헬퍼.
/// 컴파일된 리소스가 아니라 저장소 원본 XAML·코드비하인드를 직접 읽어
/// "UI 복잡 버전" 계약(리소스 토큰·요소·설정 속성 존재)을 검증한다.
/// GREEN 전 단계이므로 대부분의 계약은 의도적으로 실패(RED)한다.
/// </summary>
public static class SourceTree
{
/// <summary>저장소 루트(VideoDownloader.slnx 위치). 테스트 bin 에서 위로 올라가 탐색.</summary>
public static string Root { get; } = FindRoot();
private static string FindRoot()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
{
if (File.Exists(Path.Combine(dir.FullName, "VideoDownloader.slnx")))
return dir.FullName;
}
throw new InvalidOperationException(
$"솔루션 루트를 찾을 수 없습니다 (시작: {AppContext.BaseDirectory}). 빌드 산출물에서 실행 시 저장소 내 bin 디렉터리여야 합니다.");
}
public static string AppPath(string relative) => Path.Combine(Root, "VideoDownloader.App", relative);
public static bool AppFileExists(string relative) => File.Exists(AppPath(relative));
public static bool DocFileExists(string fileName) => File.Exists(Path.Combine(Root, "docs", fileName));
private static readonly Lazy<string> _mainWindowXaml = new(() => File.ReadAllText(AppPath("MainWindow.xaml")));
private static readonly Lazy<string> _settingsXaml = new(() => File.ReadAllText(AppPath("SettingsWindow.xaml")));
private static readonly Lazy<string> _sharedXaml = new(() => File.ReadAllText(AppPath("Themes/Shared.xaml")));
private static readonly Lazy<string> _themeDark = new(() => File.ReadAllText(AppPath("Themes/Dark.xaml")));
private static readonly Lazy<string> _themeLight = new(() => File.ReadAllText(AppPath("Themes/Light.xaml")));
private static readonly Lazy<string> _mainWindowCs = new(() => File.ReadAllText(AppPath("MainWindow.xaml.cs")));
private static readonly Lazy<string> _settingsCs = new(() => File.ReadAllText(AppPath("SettingsWindow.xaml.cs")));
private static readonly Lazy<string> _readerModeCs = new(() => File.ReadAllText(AppPath("Browser/Features/ReaderMode.cs")));
public static string MainWindowXaml => _mainWindowXaml.Value;
public static string SettingsWindowXaml => _settingsXaml.Value;
public static string SharedXaml => _sharedXaml.Value;
public static string ThemeDarkXaml => _themeDark.Value;
public static string ThemeLightXaml => _themeLight.Value;
public static string MainWindowCs => _mainWindowCs.Value;
public static string SettingsWindowCs => _settingsCs.Value;
public static string ReaderModeCs => _readerModeCs.Value;
/// <summary>XAML 리소스 사전에 키(x:Key)가 정의되어 있는지.</summary>
public static bool HasKey(string xaml, string key) => xaml.Contains($"x:Key=\"{key}\"");
/// <summary>SolidColorBrush 키의 색상(#RRGGBB) 조회. 없으면 null.</summary>
public static string? BrushHex(string xaml, string key)
{
var m = Regex.Match(xaml, $"<SolidColorBrush\\s+x:Key=\"{Regex.Escape(key)}\"[^>]*Color=\"#([0-9A-Fa-f]{{6}})\"");
return m.Success ? $"#{m.Groups[1].Value}" : null;
}
/// <summary>WCAG 2.x 상대 휘도 기반 명도 대비율(1:1 ~ 21:1).</summary>
public static double ContrastRatio(string hexA, string hexB)
{
var l1 = RelativeLuminance(hexA);
var l2 = RelativeLuminance(hexB);
return (Math.Max(l1, l2) + 0.05) / (Math.Min(l1, l2) + 0.05);
}
private static double RelativeLuminance(string hex)
{
double Channel(int offset) => Convert.ToInt32(hex.Substring(offset, 2), 16) / 255.0;
static double Linearize(double c) => c <= 0.03928 ? c / 12.92 : Math.Pow((c + 0.055) / 1.055, 2.4);
var r = Linearize(Channel(1));
var g = Linearize(Channel(3));
var b = Linearize(Channel(5));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
/// <summary>Core·App·Browser 어셈블리에서 단순 이름으로 형식 탐색(아직 없는 신규 컴포넌트 계약용).</summary>
public static Type? FindType(string simpleName)
{
foreach (var asm in TargetAssemblies())
{
Type[] types;
try
{
types = asm.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
// 일부 형식 로드 실패는 건너뛰고 로드된 형식만 대상으로 한다.
types = ex.Types.Where(t => t is not null).Select(t => t!).ToArray();
}
var found = types.FirstOrDefault(t => t.Name == simpleName);
if (found is not null)
return found;
}
return null;
}
private static IEnumerable<Assembly> TargetAssemblies()
{
yield return typeof(AppConfig).Assembly;
yield return typeof(MainWindow).Assembly;
yield return typeof(TabArchive).Assembly;
}
public static bool PropertyExists(Type type, string name) => type.GetProperty(name) is not null;
public static bool MethodExists(Type type, string name) => type.GetMethods().Any(m => m.Name == name);
/// <summary>유즈케이스 어설션 — 실패 메시지에 UC ID 와 사용자 스토리를 남긴다.</summary>
public static void Usecase(bool ok, string id, string story) =>
Assert.True(ok, $"{id} RED — {story}");
}

View file

@ -0,0 +1,102 @@
using System.Net;
using System.Net.Http;
using VideoDownloader.Core.Update;
namespace VideoDownloader.Tests.Platform;
/// <summary>
/// DEPLOYMENT_PLAN §6 자동 업데이트 v1 — GitHub Releases latest 태그 비교 + 아키텍처별 설치 자산 선택.
/// 네트워크는 가짜 핸들러로 격리.
/// </summary>
public class UpdateCheckerTests
{
private sealed class FakeHandler : HttpMessageHandler
{
private readonly HttpStatusCode _status;
private readonly string _json;
public HttpRequestMessage? LastRequest { get; private set; }
public FakeHandler(HttpStatusCode status, string json)
{
_status = status;
_json = json;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
LastRequest = request;
return Task.FromResult(new HttpResponseMessage(_status)
{
Content = new StringContent(_json, System.Text.Encoding.UTF8, "application/json"),
});
}
}
private const string ReleaseJson = """
{
"tag_name": "v1.2.0",
"html_url": "https://github.com/user/videodownloader/releases/tag/v1.2.0",
"assets": [
{ "name": "VideoDownloader-Setup-x64.exe", "browser_download_url": "https://github.com/user/videodownloader/releases/download/v1.2.0/VideoDownloader-Setup-x64.exe" },
{ "name": "VideoDownloader-Setup-arm64.exe", "browser_download_url": "https://github.com/user/videodownloader/releases/download/v1.2.0/VideoDownloader-Setup-arm64.exe" }
]
}
""";
[Theory]
[InlineData("1.0.0", "v1.2.0", true)] // 최신이 더 높음
[InlineData("1.2.0", "v1.2.0", false)] // 동일
[InlineData("2.0.0", "v1.9.9", false)] // 현재가 더 높음
[InlineData("1.0.0.0", "v1.0.1", true)] // 어셈블리 4자리 버전 허용
[InlineData("1.0.0", "garbage", false)] // 파싱 불가면 갱신 아님
public void 버전비교(string current, string latest, bool expected) =>
Assert.Equal(expected, UpdateChecker.IsNewer(current, latest));
[Fact]
public void 자산선택_x64와_arm64_구분()
{
var assets = new[]
{
new UpdateAsset("VideoDownloader-Setup-x64.exe", "https://dl/x64"),
new UpdateAsset("VideoDownloader-Setup-arm64.exe", "https://dl/arm64"),
};
Assert.Equal("https://dl/x64", UpdateChecker.SelectAsset(assets, "win-x64")!.DownloadUrl);
Assert.Equal("https://dl/arm64", UpdateChecker.SelectAsset(assets, "win-arm64")!.DownloadUrl);
Assert.Null(UpdateChecker.SelectAsset(assets, "linux-x64")); // 일치 없음
}
[Fact]
public async Task 최신확인_새버전이면_자산과_릴리스URL()
{
var handler = new FakeHandler(HttpStatusCode.OK, ReleaseJson);
var checker = new UpdateChecker(handler);
var result = await checker.CheckAsync("user/videodownloader", "1.0.0", "win-x64");
Assert.NotNull(result);
Assert.True(result!.IsNewer);
Assert.Equal("1.2.0", result.LatestVersion);
Assert.Equal("https://github.com/user/videodownloader/releases/tag/v1.2.0", result.ReleaseUrl);
Assert.Equal("VideoDownloader-Setup-x64.exe", result.Asset!.Name);
// GitHub API 규약: User-Agent 필수 + 올바른 엔드포인트
Assert.Equal("https://api.github.com/repos/user/videodownloader/releases/latest", handler.LastRequest!.RequestUri!.ToString());
Assert.NotEmpty(handler.LastRequest.Headers.UserAgent);
}
[Fact]
public async Task 최신확인_이미_최신이면_IsNewer_false()
{
var checker = new UpdateChecker(new FakeHandler(HttpStatusCode.OK, ReleaseJson));
var result = await checker.CheckAsync("user/videodownloader", "1.2.0", "win-x64");
Assert.False(result!.IsNewer);
}
[Fact]
public async Task 최신확인_API_실패는_예외로_드러남()
{
var checker = new UpdateChecker(new FakeHandler(HttpStatusCode.NotFound, """{"message":"Not Found"}"""));
await Assert.ThrowsAsync<HttpRequestException>(() => checker.CheckAsync("user/none", "1.0.0", "win-x64"));
}
}

View file

@ -0,0 +1,49 @@
using Makaretu.Dns;
using VideoDownloader.Server;
namespace VideoDownloader.Tests.Server;
/// <summary>
/// MOBILE_PLAN §3 mDNS 광고(<c>_videodl._tcp</c>) — 서비스 프로필 구성(순수 로직)과 광고 생명주기.
/// 멀티캐스트 수신 단정은 네트워크/방화벽에 좌우되므로 두지 않는다(수동 증거로 확인).
/// </summary>
public class MdnsAdvertiserTests
{
[Fact]
public void 프로필_서비스타입과_포트와_기기명_TXT()
{
var profile = MdnsAdvertiser.BuildProfile("데스크톱PC", 8642);
Assert.Equal("_videodl._tcp", profile.ServiceName.ToString());
var srv = Assert.Single(profile.Resources.OfType<SRVRecord>());
Assert.Equal(8642, srv.Port);
// Makaretu 는 TXT 에 ASCII 만 허용 → 한글 기기명은 퍼센트 인코딩으로 무손실 보존
var txt = Assert.Single(profile.Resources.OfType<TXTRecord>());
Assert.Contains("device=" + Uri.EscapeDataString("데스크톱PC"), txt.Strings);
// 인스턴스 라벨은 ASCII 로 접힘(직렬화 가능해야 광고 자체가 나간다)
Assert.Equal("PC", profile.InstanceName.ToString());
}
[Fact]
public void 프로필_인스턴스명은_점을_대시로_정리()
{
var profile = MdnsAdvertiser.BuildProfile("my.host.name", 1234);
Assert.Equal("my-host-name", profile.InstanceName.ToString());
}
[Fact]
public void 프로필_빈_기기명은_기본값()
{
var profile = MdnsAdvertiser.BuildProfile("", 1234);
Assert.Equal("VideoDownloader", profile.InstanceName.ToString());
}
[Fact]
public void 광고_시작과_중복해제_안전()
{
using var adv = new MdnsAdvertiser("테스트PC", 8642);
adv.Start();
adv.Dispose();
adv.Dispose(); // 중복 Dispose 안전
}
}

View file

@ -0,0 +1,90 @@
using System.Net.Http;
using Microsoft.AspNetCore.SignalR.Client;
using VideoDownloader.Server;
namespace VideoDownloader.Tests.Server;
/// <summary>
/// E2E: SignalR <c>/hubs/queue</c> — MOBILE_PLAN §3 API 설계안의 실시간 큐 푸시.
/// 실제 Kestrel 기동 → 허브 접속(쿼리 토큰 인증) → 스냅샷 조회 + 큐 변경 시 서버 푸시 수신.
/// </summary>
public class QueueHubE2ETests : IDisposable
{
private readonly string _libDir = Path.Combine(Path.GetTempPath(), $"vd_hub_{Guid.NewGuid():N}");
public QueueHubE2ETests() => Directory.CreateDirectory(_libDir);
private sealed class FakeQueue : IQueueAdapter
{
public List<QueueItemDto> Items { get; } = new();
public Task<IReadOnlyList<QueueItemDto>> GetItemsAsync() => Task.FromResult<IReadOnlyList<QueueItemDto>>(Items);
public Task<string> AddAsync(string url, string? title)
{
var item = new QueueItemDto(Guid.NewGuid().ToString("N"), title ?? url, url, "대기", 0);
Items.Add(item);
Changed?.Invoke();
return Task.FromResult(item.Id);
}
public Task PauseAsync(string id) => Task.CompletedTask;
public Task ResumeAsync(string id) => Task.CompletedTask;
public Task CancelAsync(string id) => Task.CompletedTask;
public event Action? Changed;
}
[Fact]
public async Task 큐허브_스냅샷_조회와_변경시_푸시_E2E()
{
var queue = new FakeQueue();
await using var server = await ServerHost.StartAsync(new ServerOptions
{
Port = 0,
Token = "hub-token-123",
LibraryFolder = _libDir,
Queue = queue,
});
await using var conn = new HubConnectionBuilder()
.WithUrl($"http://127.0.0.1:{server.Port}/hubs/queue?t=hub-token-123")
.Build();
var pushed = new TaskCompletionSource<List<QueueItemDto>>(TaskCreationOptions.RunContinuationsAsynchronously);
conn.On<List<QueueItemDto>>("queueUpdated", items => pushed.TrySetResult(items));
await conn.StartAsync();
// 1) 허브 메서드로 현재 스냅샷 조회
var snapshot = await conn.InvokeAsync<List<QueueItemDto>>("GetQueue");
Assert.Empty(snapshot);
// 2) 큐 변경 → 폴링 없이 서버가 밀어줌 (2초 폴링의 실시간 대체)
await queue.AddAsync("https://x.test/push.mp4", "푸시 영상");
var items = await pushed.Task.WaitAsync(TimeSpan.FromSeconds(10));
Assert.Single(items);
Assert.Equal("푸시 영상", items[0].Title);
}
[Fact]
public async Task 큐허브_잘못된_토큰은_401_거부()
{
await using var server = await ServerHost.StartAsync(new ServerOptions
{
Port = 0,
Token = "hub-token-123",
LibraryFolder = _libDir,
Queue = new FakeQueue(),
});
await using var conn = new HubConnectionBuilder()
.WithUrl($"http://127.0.0.1:{server.Port}/hubs/queue?t=wrong-token")
.Build();
await Assert.ThrowsAsync<HttpRequestException>(() => conn.StartAsync());
}
public void Dispose()
{
try { Directory.Delete(_libDir, recursive: true); } catch (IOException) { }
}
}

View file

@ -11,6 +11,7 @@
<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" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.8" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\VideoDownloader.App\VideoDownloader.App.csproj" />