using System.IO; using System.Text; using System.Windows; using System.Windows.Controls; using Paca.App; using Paca.App.Browser.Features; using Paca.Browser; using Paca.Browser.Tabs; using Paca.Tests.Infrastructure; using Xunit; namespace Paca.Tests.Browser; /// /// 다운로드창 리사이즈 시 브라우저 탭 및 탭 스트립 동적 리사이즈 단위/E2E 테스트. /// [Collection("E2E")] public class TabDynamicResizeTests { [Fact] public void TabStripRow_위치가_브라우저_열에_배치되어_우측패널과_분리된다() { using var sta = StaDispatcher.Start(); WpfTestApp.Ensure(sta.Dispatcher); sta.Invoke(() => { var dataDir = Path.Combine(Path.GetTempPath(), $"vd_tabresize_{Guid.NewGuid():N}"); Environment.SetEnvironmentVariable("VD_DATA_DIR", dataDir); try { var window = new MainWindow(); // MainGrid 열 분할 검증: Col 0 = 브라우저(*), Col 1 = 스플리터(Auto), Col 2 = 다운로드 패널(RightCol) Assert.Equal(3, window.MainGrid.ColumnDefinitions.Count); Assert.Equal(GridUnitType.Star, window.MainGrid.ColumnDefinitions[0].Width.GridUnitType); Assert.Same(window.RightCol, window.MainGrid.ColumnDefinitions[2]); // TabStripRow가 Col 0에 위치하는지 검증 Assert.Equal(0, Grid.GetColumn(window.TabStripRow)); Assert.Equal(2, Grid.GetRow(window.TabStripRow)); // ContentRoot(브라우저 본체)가 Col 0에 위치하는지 검증 Assert.Equal(0, Grid.GetColumn(window.ContentRoot)); Assert.Equal(3, Grid.GetRow(window.ContentRoot)); // RightSplitter가 Col 1에 위치하는지 검증 Assert.Equal(1, Grid.GetColumn(window.RightSplitter)); } finally { try { if (Directory.Exists(dataDir)) Directory.Delete(dataDir, true); } catch { } } }); } [Fact] public void 탭_개수_및_가용너비에_따라_TabItemWidth가_동적으로_조절된다() { using var sta = StaDispatcher.Start(); WpfTestApp.Ensure(sta.Dispatcher); sta.Invoke(() => { var dataDir = Path.Combine(Path.GetTempPath(), $"vd_tabresize_{Guid.NewGuid():N}"); Environment.SetEnvironmentVariable("VD_DATA_DIR", dataDir); try { var window = new MainWindow(); Assert.True(window.TabItemWidth >= 48.0 && window.TabItemWidth <= 220.0); // 탭이 추가되면 UpdateDynamicTabWidth를 통해 너비가 다시 계산됨 window.UpdateDynamicTabWidth(); Assert.True(window.TabItemWidth <= 220.0); } finally { try { if (Directory.Exists(dataDir)) Directory.Delete(dataDir, true); } catch { } } }); } } // ── T2-2 탭 절전: 유휴 비활성 탭 자동 서스펜드 ── public class TabSuspendServiceTests { [Fact] public void 활성_탭은_절전하지_않는다() { var m = new TabManager(() => new TabItem()); var active = m.NewTab("https://a.com"); var svc = new TabSuspendService(TimeSpan.FromMinutes(10), t => throw new InvalidOperationException("suspend should not be called"), t => t == active); svc.SuspendIdleTabs(m.Tabs, active, now: active.LastActivated.AddMinutes(30)); Assert.False(active.IsSuspended); } [Fact] public void 유휴_비활성_탭은_절전한다() { var m = new TabManager(() => new TabItem()); var old = m.NewTab("https://a.com"); var active = m.NewTab("https://b.com"); var suspended = new List(); var svc = new TabSuspendService(TimeSpan.FromMinutes(10), t => { suspended.Add(t); return true; }, t => t != active); var now = old.LastActivated.AddMinutes(11); svc.SuspendIdleTabs(m.Tabs, active, now); Assert.Contains(old, suspended); Assert.DoesNotContain(active, suspended); } [Fact] public void 이미_절전된_탭은_중복_서스펜드_하지_않는다() { var m = new TabManager(() => new TabItem()); var old = m.NewTab("https://a.com"); var active = m.NewTab("https://b.com"); old.IsSuspended = true; var calls = 0; var svc = new TabSuspendService(TimeSpan.FromMinutes(10), t => { calls++; return true; }, t => t != active); svc.SuspendIdleTabs(m.Tabs, active, old.LastActivated.AddHours(1)); Assert.Equal(0, calls); } [Fact] public void 최근에_활성화된_탭은_절전하지_않는다() { var m = new TabManager(() => new TabItem()); var recent = m.NewTab("https://a.com"); var active = m.NewTab("https://b.com"); m.Activate(recent); // 방금 사용 var calls = 0; var svc = new TabSuspendService(TimeSpan.FromMinutes(10), t => { calls++; return true; }, t => t != active); svc.SuspendIdleTabs(m.Tabs, active, recent.LastActivated.AddMinutes(1)); Assert.Equal(0, calls); } } // ── T2-8 탭 검색(Ctrl+Shift+A): 열린+닫힌 탭 필터 ── public class TabSearchFilterTests { [Fact] public void 제목과_URL_부분일치로_필터링한다() { var m = new TabManager(() => new TabItem()); m.NewTab("https://youtube.com/watch?v=1"); m.Tabs[0].Title = "My Favorite Video"; m.NewTab("https://news.example.com"); m.Tabs[1].Title = "News"; var hits = TabSearchFilter.Search(m.Tabs, "favorite"); Assert.Single(hits); Assert.Equal("My Favorite Video", hits[0].Title); hits = TabSearchFilter.Search(m.Tabs, "youtube"); Assert.Single(hits); hits = TabSearchFilter.Search(m.Tabs, "EXAMPLE"); // 대소문자 무시 Assert.Single(hits); } [Fact] public void 닫힌_탭도_검색_대상에_포함된다() { var m = new TabManager(() => new TabItem()); var t = m.NewTab("https://github.com/x"); m.Tabs[0].Title = "GitHub Repo"; m.CloseTab(t); var hits = TabSearchFilter.Search(m.Tabs, m.ClosedStack, "github"); Assert.Single(hits); Assert.Equal("GitHub Repo", hits[0].Title); Assert.True(hits[0].IsClosed); } [Fact] public void 빈_질의는_빈_결과() { var m = new TabManager(() => new TabItem()); m.NewTab("https://a.com"); Assert.Empty(TabSearchFilter.Search(m.Tabs, "")); Assert.Empty(TabSearchFilter.Search(m.Tabs, " ")); } } // ── T2-11 탭 그룹: 색상 라벨 + 그룹 단위 이동/닫기 ── public class TabGroupsTests { private static TabManager NewManager() => new(() => new TabItem()); [Fact] public void 그룹_지정과_해제() { var m = NewManager(); var t = m.NewTab("https://a.com"); m.SetGroup(t, "Work", "#e11"); Assert.Equal("Work", t.GroupName); Assert.Equal("#e11", t.GroupColor); m.SetGroup(t, null, null); Assert.Null(t.GroupName); } [Fact] public void 같은_그룹_탭은_서로_인접한다() { var m = NewManager(); var a = m.NewTab("a"); m.SetGroup(a, "G", "#111"); m.NewTab("b"); // 비그룹 var c = m.NewTab("c"); m.SetGroup(c, "G", "#111"); // 그룹으로 → a 옆으로 이동 Assert.Equal(1, m.Tabs.IndexOf(c)); Assert.Equal(0, m.Tabs.IndexOf(a)); } [Fact] public void 그룹_탭_전체_닫기() { var m = NewManager(); var a = m.NewTab("a"); m.SetGroup(a, "G", "#111"); var b = m.NewTab("b"); m.SetGroup(b, "G", "#111"); m.NewTab("solo"); var closed = m.CloseGroup("G"); Assert.Equal(2, closed); Assert.Single(m.Tabs); } } // ── T3-1 워크스페이스: 탭 세트 저장/전환 ── public class WorkspaceTests { private static TabManager NewManager() => new(() => new TabItem()); [Fact] public void 워크스페이스_저장후_스왑하면_탭_세트가_교체된다() { var m = NewManager(); m.NewTab("https://work1.com"); m.NewTab("https://work2.com"); var ws = m.CaptureWorkspace("Work"); m.NewTab("https://personal.com"); Assert.Equal(3, m.Tabs.Count); m.SwitchWorkspace(ws); Assert.Equal(2, m.Tabs.Count); Assert.Equal("https://work1.com", m.Tabs[0].Url); Assert.Equal("https://work2.com", m.Tabs[1].Url); Assert.Equal("Work", ws.Name); } [Fact] public void 워크스페이스_캡처는_현재_탭을_그대로_유지한다() { var m = NewManager(); m.NewTab("https://a.com"); var ws = m.CaptureWorkspace("X"); Assert.Single(m.Tabs); // 캡처 자체는 현재 상태 유지(스냅샷) Assert.Single(ws.Urls); } } // Phase A 탭 아키텍처: TabManager 순수 로직(뷰 팩토리 주입 — WebView2 없이 검증). public class TabManagerTests { private static TabManager NewManager() => new(() => new TabItem()); [Fact] public void NewTab은_탭을_추가하고_활성화한다() { var m = NewManager(); var t = m.NewTab("https://a.com"); Assert.Single(m.Tabs); Assert.Same(t, m.ActiveTab); Assert.Equal("https://a.com", t.Url); } [Fact] public void 두_번째_탭은_이전_탭을_비활성화하고_활성화된다() { var m = NewManager(); var t1 = m.NewTab("https://a.com"); var t2 = m.NewTab("https://b.com"); Assert.Same(t2, m.ActiveTab); Assert.False(t1.IsActive); Assert.True(t2.IsActive); } [Fact] public void Activate는_활성_탭을_바꾸고_이벤트를_발생시킨다() { var m = NewManager(); var t1 = m.NewTab(); var t2 = m.NewTab(); var fired = 0; m.ActiveChanged += _ => fired++; m.Activate(t1); Assert.Same(t1, m.ActiveTab); Assert.True(t1.IsActive); Assert.False(t2.IsActive); Assert.Equal(1, fired); } [Fact] public void CloseTab은_제거하고_이웃_탭을_활성화한다() { var m = NewManager(); m.NewTab("https://a.com"); // index 0 var t2 = m.NewTab("https://b.com"); // index 1 (active) var t3 = m.NewTab("https://c.com"); // index 2 m.Activate(t2); m.CloseTab(t2); Assert.Equal(2, m.Tabs.Count); Assert.DoesNotContain(t2, m.Tabs); // 닫힌 탭 오른쪽이 있으면 그 탭(t3) 활성화 (Chrome 동작) Assert.Same(t3, m.ActiveTab); } [Fact] public void 마지막_탭을_닫으면_새_빈_탭으로_대체된다() { var m = NewManager(); var only = m.NewTab("https://solo.com"); m.CloseTab(only); Assert.Single(m.Tabs); Assert.NotSame(only, m.ActiveTab); Assert.Same(m.ActiveTab, m.Tabs[0]); } [Fact] public void 닫힌_탭은_스택에_쌓이고_ReopenClosedTab으로_원래_위치에_복원된다() { var m = NewManager(); var t1 = m.NewTab("https://a.com"); m.NewTab("https://b.com"); m.NewTab("https://c.com"); // index 2, active m.CloseTab(m.Tabs[2]); // 마지막 인덱스 탭 닫기 var reopened = m.ReopenClosedTab(); Assert.NotNull(reopened); Assert.Equal("https://c.com", reopened!.Url); Assert.Equal(3, m.Tabs.Count); Assert.Equal(2, m.Tabs.IndexOf(reopened)); // 원래 인덱스 복원 Assert.Same(reopened, m.ActiveTab); Assert.Empty(m.ClosedStack); } [Fact] public void 닫힌_탭_스택은_최대_25개다() { var m = NewManager(); for (int i = 0; i < 30; i++) { var t = m.NewTab($"https://x{i}.com"); m.CloseTab(t); } Assert.Equal(25, m.ClosedStack.Count); Assert.Equal("https://x29.com", m.ClosedStack.First().Url); // LIFO 최신 우선 } [Fact] public void ActivateNext_Previous는_순환한다() { var m = NewManager(); var t1 = m.NewTab("https://a.com"); var t2 = m.NewTab("https://b.com"); m.Activate(t1); m.ActivateNext(); Assert.Same(t2, m.ActiveTab); m.ActivateNext(); // 순환 Assert.Same(t1, m.ActiveTab); m.ActivatePrevious(); Assert.Same(t2, m.ActiveTab); } [Fact] public void ActivateAt와_ActivateLast가_동작한다() { var m = NewManager(); m.NewTab("a"); m.NewTab("b"); var last = m.NewTab("c"); m.ActivateAt(0); Assert.Same(m.Tabs[0], m.ActiveTab); m.ActivateLast(); Assert.Same(last, m.ActiveTab); } [Fact] public void Move는_탭_순서를_바꾼다() { var m = NewManager(); var t1 = m.NewTab("a"); var t2 = m.NewTab("b"); var t3 = m.NewTab("c"); m.Move(t1, 2); // a 를 끝으로 Assert.Equal(2, m.Tabs.IndexOf(t1)); Assert.Equal(0, m.Tabs.IndexOf(t2)); Assert.Equal(1, m.Tabs.IndexOf(t3)); } [Fact] public void Pin은_탭을_좌측_끝으로_이동시키고_핀_표시한다() { var m = NewManager(); m.NewTab("a"); m.NewTab("b"); var t3 = m.NewTab("c"); m.SetPinned(t3, true); Assert.True(t3.IsPinned); Assert.Equal(0, m.Tabs.IndexOf(t3)); // 핀 탭은 좌측 정렬 m.SetPinned(t3, false); Assert.False(t3.IsPinned); } [Fact] public void CaptureSession은_URL과_활성_인덱스와_핀을_담는다() { var m = NewManager(); m.NewTab("https://a.com"); var t2 = m.NewTab("https://b.com"); m.NewTab("https://c.com"); m.SetPinned(t2, true); // b 가 좌측으로 이동 → 순서: b,a,c m.Activate(t2); var s = m.CaptureSession(); Assert.Equal(new[] { "https://b.com", "https://a.com", "https://c.com" }, s.Urls); Assert.Equal(0, s.ActiveIndex); Assert.Equal(new[] { true, false, false }, s.Pinned); } [Fact] public void RestoreSession은_저장된_탭들을_재생성한다() { var m = NewManager(); // CaptureSession 불변식: 핀 탭이 먼저 오는 순서로 저장됨 var snap = new SessionSnapshot( new() { "https://b.com", "https://a.com" }, 0, new() { true, false }); m.RestoreSession(snap); Assert.Equal(2, m.Tabs.Count); Assert.Equal("https://b.com", m.ActiveTab!.Url); Assert.True(m.Tabs[0].IsPinned); // 핀 탭이 좌측 Assert.False(m.Tabs[1].IsPinned); } [Fact] public void RestoreSession은_기존_탭을_치환한다() { var m = NewManager(); m.NewTab("https://old.com"); m.RestoreSession(new SessionSnapshot(new() { "https://new.com" }, 0, new() { false })); Assert.Single(m.Tabs); Assert.Equal("https://new.com", m.Tabs[0].Url); } [Fact] public void CaptureSession_유효한탭이있을때_비활성빈탭은_제외된다() { var m = NewManager(); var t1 = m.NewTab("https://a.com"); var t2 = m.NewTab(""); // 비활성 빈 탭 m.Activate(t1); var snap = m.CaptureSession(); Assert.Single(snap.Urls); Assert.Equal("https://a.com", snap.Urls[0]); } [Fact] public void CaptureSession_모든탭이_빈탭일때_단일빈탭만_저장된다() { var m = NewManager(); m.NewTab(""); m.NewTab(""); var snap = m.CaptureSession(); Assert.Single(snap.Urls); Assert.Equal("", snap.Urls[0]); } } // 세션 JSON 저장소(T1-10) 분리 테스트 public class TabSessionStoreTests : IDisposable { private readonly string _path = Path.Combine(Path.GetTempPath(), $"session_{Guid.NewGuid():N}.json"); [Fact] public void 저장_후_로드_라운드트립() { var snap = new SessionSnapshot( new() { "https://a.com", "https://b.com", "https://c.com" }, 2, new() { true, false, false }); TabSessionStore.Save(_path, snap); var loaded = TabSessionStore.Load(_path); Assert.NotNull(loaded); Assert.Equal(snap.Urls, loaded!.Urls); Assert.Equal(2, loaded.ActiveIndex); Assert.Equal(snap.Pinned, loaded.Pinned); } [Fact] public void 파일_없으면_null() { Assert.Null(TabSessionStore.Load(_path)); } [Fact] public void 손상된_JSON이면_null() { File.WriteAllText(_path, "{ broken"); Assert.Null(TabSessionStore.Load(_path)); } [Fact] public void TabSessionStore_Load는_중복빈탭을_정제한다() { var snap = new SessionSnapshot(new() { "https://a.com", "", "" }, 0, new() { false, false, false }); TabSessionStore.Save(_path, snap); var loaded = TabSessionStore.Load(_path); Assert.NotNull(loaded); Assert.Single(loaded!.Urls); Assert.Equal("https://a.com", loaded.Urls[0]); } [Fact] public void TabSessionStore_Load는_모두빈탭일때_단일탭만_반환한다() { var snap = new SessionSnapshot(new() { "", "" }, 0, new() { false, false }); TabSessionStore.Save(_path, snap); var loaded = TabSessionStore.Load(_path); Assert.NotNull(loaded); Assert.Single(loaded!.Urls); Assert.Equal("", loaded.Urls[0]); } public void Dispose() { try { File.Delete(_path); } catch { } } } public class ModernVerticalTabsFeatureTests { [Fact] public void AppConfig_VerticalTabs_기본값_및_직렬화_검증() { var config = new Paca.Core.Config.AppConfig(); Assert.Equal("Left", config.VerticalTabsPosition); Assert.False(config.VerticalTabsCollapsed); Assert.Equal(220.0, config.VerticalTabsWidth); Assert.True(config.VerticalTabsAutoExpand); config.VerticalTabsPosition = "Right"; config.VerticalTabsCollapsed = true; config.VerticalTabsWidth = 260.0; config.VerticalTabsAutoExpand = false; string json = System.Text.Json.JsonSerializer.Serialize(config); var restored = System.Text.Json.JsonSerializer.Deserialize(json); Assert.NotNull(restored); Assert.Equal("Right", restored!.VerticalTabsPosition); Assert.True(restored.VerticalTabsCollapsed); Assert.Equal(260.0, restored.VerticalTabsWidth); Assert.False(restored.VerticalTabsAutoExpand); } [Fact] public void SettingsViewModel_VerticalTabs_바인딩_및_커밋_검증() { var vm = new Paca.App.ViewModels.SettingsViewModel(new Paca.Core.Config.AppConfig()); var cfg = new Paca.Core.Config.AppConfig { VerticalTabsPosition = "Right", VerticalTabsCollapsed = true, VerticalTabsWidth = 280, VerticalTabsAutoExpand = false }; vm.LoadFromConfig(cfg); Assert.Equal("Right", vm.VerticalTabsPosition); Assert.True(vm.VerticalTabsCollapsed); Assert.Equal(280, vm.VerticalTabsWidth); Assert.False(vm.VerticalTabsAutoExpand); vm.VerticalTabsPosition = "Left"; vm.VerticalTabsCollapsed = false; vm.VerticalTabsWidth = 240; vm.VerticalTabsAutoExpand = true; var targetCfg = new Paca.Core.Config.AppConfig(); vm.CommitToConfig(targetCfg); Assert.Equal("Left", targetCfg.VerticalTabsPosition); Assert.False(targetCfg.VerticalTabsCollapsed); Assert.Equal(240, targetCfg.VerticalTabsWidth); Assert.True(targetCfg.VerticalTabsAutoExpand); } }