From 603ad5140672b127541d81cb1fd41d979ce2ef82 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Sat, 15 Aug 2026 12:18:05 +0900 Subject: [PATCH] Phase A logic: TabManager/TabItem/session store (TDD, 17 tests) --- VideoDownloader.App/Browser/Tabs/TabItem.cs | 59 +++++ .../Browser/Tabs/TabManager.cs | 164 +++++++++++++ .../Browser/Tabs/TabSessionStore.cs | 38 +++ .../Browser/TabManagerTests.cs | 228 ++++++++++++++++++ 4 files changed, 489 insertions(+) create mode 100644 VideoDownloader.App/Browser/Tabs/TabItem.cs create mode 100644 VideoDownloader.App/Browser/Tabs/TabManager.cs create mode 100644 VideoDownloader.App/Browser/Tabs/TabSessionStore.cs create mode 100644 VideoDownloader.Tests/Browser/TabManagerTests.cs diff --git a/VideoDownloader.App/Browser/Tabs/TabItem.cs b/VideoDownloader.App/Browser/Tabs/TabItem.cs new file mode 100644 index 0000000..4f98a45 --- /dev/null +++ b/VideoDownloader.App/Browser/Tabs/TabItem.cs @@ -0,0 +1,59 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using Microsoft.Web.WebView2.Wpf; + +namespace VideoDownloader.App.Browser.Tabs; + +/// 탭 하나의 상태. View(WebView2 컨트롤)는 앱 레이어에서 주입한다(단위 테스트에선 null). +public sealed class TabItem : INotifyPropertyChanged +{ + public Guid Id { get; } = Guid.NewGuid(); + + /// 탭에 연결된 WebView2 컨트롤. 닫힌 탭 재오픈 시 재생성될 수 있음. + public WebView2? View { get; set; } + + /// CoreWebView2 (초기화 전 null) + public Microsoft.Web.WebView2.Core.CoreWebView2? Core => View?.CoreWebView2; + + private string _title = "새 탭"; + public string Title { get => _title; set => Set(ref _title, value); } + + private string _url = ""; + public string Url { get => _url; set => Set(ref _url, value); } + + private bool _isPinned; + public bool IsPinned { get => _isPinned; set => Set(ref _isPinned, value); } + + private bool _isActive; + public bool IsActive { get => _isActive; set => Set(ref _isActive, value); } + + private bool _isAudioPlaying; + public bool IsAudioPlaying { get => _isAudioPlaying; set => Set(ref _isAudioPlaying, value); } + + private bool _isMuted; + public bool IsMuted { get => _isMuted; set => Set(ref _isMuted, value); } + + private bool _isSuspended; + public bool IsSuspended { get => _isSuspended; set => Set(ref _isSuspended, value); } + + private bool _isLoading; + public bool IsLoading { get => _isLoading; set => Set(ref _isLoading, value); } + + /// 마지막 활성 시각(탭 절전 판정용) + public DateTime LastActivated { get; set; } = DateTime.UtcNow; + + /// 닫힌 시점의 인덱스(닫힌 탭 복원용). 열려 있는 동안은 무의미. + internal int ClosedIndex { get; set; } = -1; + + public event PropertyChangedEventHandler? PropertyChanged; + + private void Set(ref T field, T value, [CallerMemberName] string? name = null) + { + if (EqualityComparer.Default.Equals(field, value)) return; + field = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + } +} + +/// 세션 스냅샷(T1-10): 탭 URL 순서 + 활성 인덱스 + 핀 여부. +public sealed record SessionSnapshot(List Urls, int ActiveIndex, List Pinned); diff --git a/VideoDownloader.App/Browser/Tabs/TabManager.cs b/VideoDownloader.App/Browser/Tabs/TabManager.cs new file mode 100644 index 0000000..8cfde04 --- /dev/null +++ b/VideoDownloader.App/Browser/Tabs/TabManager.cs @@ -0,0 +1,164 @@ +using System.Collections.ObjectModel; + +namespace VideoDownloader.App.Browser.Tabs; + +/// +/// 탭 관리 순수 로직(Phase A). WebView2 생성은 주입된 팩토리/어태처가 담당해 +/// 단위 테스트에서 UI 없이 검증 가능. 닫힌 탭 스택(25) · 핀 좌측 정렬 · 세션 캡처/복원 포함. +/// +public sealed class TabManager +{ + private const int MaxClosed = 25; + + private readonly Func _createTab; + private readonly Action? _attachView; + private readonly List<(TabItem Tab, int Index)> _closed = new(); + + public ObservableCollection Tabs { get; } = new(); + public TabItem? ActiveTab { get; private set; } + + public event Action? ActiveChanged; + public event Action? TabAdded; + public event Action? TabRemoved; + + /// 닫힌 탭(LIFO — 최신 우선). Ctrl+Shift+T 복원 대상. + public IReadOnlyList ClosedStack => _closed.Select(e => e.Tab).ToList(); + + public TabManager(Func createTab, Action? attachView = null) + { + _createTab = createTab; + _attachView = attachView; + } + + public TabItem NewTab(string? url = null, bool activate = true) + { + var tab = _createTab(); + if (!string.IsNullOrEmpty(url)) tab.Url = url; + _attachView?.Invoke(tab); + Tabs.Add(tab); // 신규 탭은 핀 여부와 무관하게 끝에(핀 상태로 만들면 SetPinned 이동) + TabAdded?.Invoke(tab); + if (activate) Activate(tab); + else tab.IsActive = false; + return tab; + } + + public void Activate(TabItem tab) + { + if (!Tabs.Contains(tab)) return; + foreach (var t in Tabs) t.IsActive = ReferenceEquals(t, tab); + ActiveTab = tab; + tab.LastActivated = DateTime.UtcNow; + ActiveChanged?.Invoke(tab); + } + + /// 탭 닫기. 활성 탭이면 오른쪽 이웃(없으면 왼쪽) 활성화. 마지막 탭이면 새 빈 탭으로 대체. + public bool CloseTab(TabItem tab) + { + var index = Tabs.IndexOf(tab); + if (index < 0) return false; + + _closed.Insert(0, (tab, index)); + while (_closed.Count > MaxClosed) _closed.RemoveAt(_closed.Count - 1); + tab.ClosedIndex = index; + + var wasActive = ReferenceEquals(ActiveTab, tab); + Tabs.RemoveAt(index); + TabRemoved?.Invoke(tab); + + if (Tabs.Count == 0) + { + NewTab(); // 창 유지 — 마지막 탭은 새 빈 탭으로 대체 + } + else if (wasActive) + { + var next = Tabs[Math.Min(index, Tabs.Count - 1)]; // 오른쪽 우선, 끝이면 왼쪽 + Activate(next); + } + return true; + } + + /// Ctrl+Shift+T: 닫힌 탭을 닫힌 위치에 복원. + public TabItem? ReopenClosedTab() + { + if (_closed.Count == 0) return null; + var (tab, index) = _closed[0]; + _closed.RemoveAt(0); + + if (tab.View == null) _attachView?.Invoke(tab); // 뷰가 정리된 경우 재생성 + Tabs.Insert(Math.Min(index, Tabs.Count), tab); + TabAdded?.Invoke(tab); + Activate(tab); + return tab; + } + + public void ActivateNext() + { + if (ActiveTab == null || Tabs.Count < 2) return; + var i = Tabs.IndexOf(ActiveTab); + Activate(Tabs[(i + 1) % Tabs.Count]); + } + + public void ActivatePrevious() + { + if (ActiveTab == null || Tabs.Count < 2) return; + var i = Tabs.IndexOf(ActiveTab); + Activate(Tabs[(i - 1 + Tabs.Count) % Tabs.Count]); + } + + public void ActivateAt(int index) + { + if (index >= 0 && index < Tabs.Count) Activate(Tabs[index]); + } + + public void ActivateLast() => ActivateAt(Tabs.Count - 1); + + public void Move(TabItem tab, int newIndex) + { + var old = Tabs.IndexOf(tab); + if (old < 0) return; + newIndex = Math.Clamp(newIndex, 0, Tabs.Count - 1); + if (newIndex == old) return; + Tabs.Move(old, newIndex); + } + + /// 핀 설정. 핀 탭은 좌측 끝에 정렬(Chrome 동작). + public void SetPinned(TabItem tab, bool pinned) + { + if (!Tabs.Contains(tab)) return; + tab.IsPinned = pinned; + if (pinned) + { + var target = Tabs.Count(t => t.IsPinned) - 1; // 자신 포함 핀 탭 수 - 1 + Move(tab, Math.Max(0, target)); + } + else + { + var target = Tabs.Count(t => t.IsPinned); // 핀 탭 블록 바로 뒤 + Move(tab, target); + } + } + + public SessionSnapshot CaptureSession() + { + var active = ActiveTab == null ? 0 : Math.Max(0, Tabs.IndexOf(ActiveTab)); + return new SessionSnapshot( + Tabs.Select(t => t.Url).ToList(), + active, + Tabs.Select(t => t.IsPinned).ToList()); + } + + public void RestoreSession(SessionSnapshot snap) + { + foreach (var t in Tabs.ToList()) + { + Tabs.Remove(t); + TabRemoved?.Invoke(t); + } + for (var i = 0; i < snap.Urls.Count; i++) + { + var tab = NewTab(snap.Urls[i], activate: false); + if (i < snap.Pinned.Count && snap.Pinned[i]) tab.IsPinned = true; // 순서 유지(핀 우선 저장됨) + } + ActivateAt(Math.Clamp(snap.ActiveIndex, 0, Math.Max(0, Tabs.Count - 1))); + } +} diff --git a/VideoDownloader.App/Browser/Tabs/TabSessionStore.cs b/VideoDownloader.App/Browser/Tabs/TabSessionStore.cs new file mode 100644 index 0000000..c15c733 --- /dev/null +++ b/VideoDownloader.App/Browser/Tabs/TabSessionStore.cs @@ -0,0 +1,38 @@ +using System.IO; +using System.Text.Json; + +namespace VideoDownloader.App.Browser.Tabs; + +/// 세션 JSON 저장소(T1-10). 종료 시 탭 URL 저장, 시작 시 복원. +public static class TabSessionStore +{ + public static void Save(string path, SessionSnapshot snap) + { + try + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + var tmp = path + ".tmp"; + File.WriteAllText(tmp, JsonSerializer.Serialize(snap)); + File.Move(tmp, path, overwrite: true); + } + catch { } + } + + public static SessionSnapshot? Load(string path) + { + try + { + if (!File.Exists(path)) return null; + var snap = JsonSerializer.Deserialize(File.ReadAllText(path)); + if (snap == null || snap.Urls == null || snap.Urls.Count == 0) return null; + var pinned = snap.Pinned ?? new List(); + while (pinned.Count < snap.Urls.Count) pinned.Add(false); + return snap with { Pinned = pinned }; + } + catch + { + return null; + } + } +} diff --git a/VideoDownloader.Tests/Browser/TabManagerTests.cs b/VideoDownloader.Tests/Browser/TabManagerTests.cs new file mode 100644 index 0000000..8bc7125 --- /dev/null +++ b/VideoDownloader.Tests/Browser/TabManagerTests.cs @@ -0,0 +1,228 @@ +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 { } } +}