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);
}
}