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; /// /// UI 유즈케이스 GREEN 구현 보강 테스트 — docs/UI_USECASES.md 계약(존재 확인)이 놓친 /// "실제 동작" 을 검증한다. RED 스펙 234건이 요구하지 않은 각진 부분: /// 탭 잠금/상한, 아카이브 복원·비움, 다운로드 예약 창 계산, 단축키 충돌 검출, /// 카드 VM 속도/ETA 계산, 페이지 다운로드 전체 일시정지, 북마크 HTML 파싱, /// 테마 사전 키 동일성(런타임 MissingKey 방지). /// 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 { ["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 { ["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 = """

사이트 <A>
Plain

"""; 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("사이트 ", 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("""{"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 { } } } ///

Dark/Light/HighContrast 세 테마가 동일한 키 집합을 정의하는지 — /// 한 테마에만 있는 키는 전환 시 MissingKey 런타임 예외로 이어진다. [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); } }