using VideoDownloader.App.Browser.Tabs; namespace VideoDownloader.Tests.Browser; // 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); } } // 세션 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)); } public void Dispose() { try { File.Delete(_path); } catch { } } }