Phase B (T1-5/T1-6/T2-2/T2-8/T2-12): download manager flyout w/ speed+pause/resume/cancel, bookmark bar, tab sleeping, tab search (open+closed), context menu w/ 'download video from this link' (79 tests GREEN)

This commit is contained in:
Yun Chan 2026-08-15 15:09:59 +09:00
parent af19dba8f4
commit 0caa1111e7
9 changed files with 763 additions and 3 deletions

View file

@ -0,0 +1,103 @@
using VideoDownloader.App.Browser.Features;
namespace VideoDownloader.Tests.Browser;
/// <summary>페이지 다운로드 추상화(다운로드 매니저 UI 의 로직 코어) — fake op 로 검증.</summary>
public interface IFakeOpHost { }
public class PageDownloadTrackerTests
{
private sealed class FakeOp : IDownloadOperation
{
public string Uri { get; set; } = "https://x.test/file.zip";
public string FileName { get; set; } = "file.zip";
public long BytesReceived { get; set; }
public long TotalBytesToReceive { get; set; } = 1000;
public string State { get; set; } = "InProgress";
public bool Paused, Resumed, Cancelled;
public event Action? Changed;
public void Raise() => Changed?.Invoke();
public void Pause() { Paused = true; State = "Paused"; Raise(); }
public void Resume() { Resumed = true; State = "InProgress"; Raise(); }
public void Cancel() { Cancelled = true; State = "Cancelled"; Raise(); }
}
[Fact]
public void Add는_항목을_등록하고_최근순_정렬한다()
{
var t = new PageDownloadTracker();
var a = new FakeOp { FileName = "a.zip" };
var b = new FakeOp { FileName = "b.zip" };
t.Add(a, a.FileName);
t.Add(b, b.FileName);
Assert.Equal(2, t.Items.Count);
Assert.Equal("b.zip", t.Items[0].FileName); // 최신 우선
}
[Fact]
public void 진행률과_속도를_계산한다()
{
var t = new PageDownloadTracker();
var op = new FakeOp { TotalBytesToReceive = 1000 };
var item = t.Add(op, "f.zip");
item.UpdateSpeed(500, TimeSpan.FromSeconds(2)); // 500B/2s = 250 B/s
Assert.Equal(50.0, item.Percent, 2);
Assert.Equal(250, item.BytesPerSecond);
Assert.Equal("250.0 B/s", item.SpeedText);
}
[Fact]
public void 속도_단위는_KB_MB로_표시된다()
{
var t = new PageDownloadTracker();
var item = t.Add(new FakeOp { TotalBytesToReceive = 10_000_000 }, "f");
item.UpdateSpeed(0, TimeSpan.FromSeconds(1)); // 초기
Assert.Equal("—", item.SpeedText);
item = t.Add(new FakeOp { TotalBytesToReceive = 10_000_000 }, "g");
item.UpdateSpeed(2048 * 100, TimeSpan.FromSeconds(1)); // 200 KB/s
Assert.Equal("200.0 KB/s", item.SpeedText);
item = t.Add(new FakeOp { TotalBytesToReceive = 10_000_000 }, "h");
item.UpdateSpeed(3 * 1024 * 1024L, TimeSpan.FromSeconds(1)); // 3 MB/s
Assert.Equal("3.0 MB/s", item.SpeedText);
}
[Fact]
public void Pause_Resume_Cancel_위임()
{
var t = new PageDownloadTracker();
var op = new FakeOp();
var item = t.Add(op, "f");
t.Pause(item); t.Resume(item); t.Cancel(item);
Assert.True(op.Paused && op.Resumed && op.Cancelled);
}
[Fact]
public void 완료_항목은_ClearCompleted로_일괄_제거()
{
var t = new PageDownloadTracker();
var done = new FakeOp { State = "Completed" };
var running = new FakeOp { State = "InProgress" };
t.Add(done, "d"); t.Add(running, "r");
t.ClearCompleted();
Assert.Single(t.Items);
Assert.Equal("r", t.Items[0].FileName);
}
[Fact]
public void 취소된_항목도_ClearCompleted로_제거된다()
{
var t = new PageDownloadTracker();
var c = new FakeOp { State = "Interrupted" };
t.Add(c, "c");
t.ClearCompleted();
Assert.Empty(t.Items);
}
}

View file

@ -0,0 +1,113 @@
using VideoDownloader.App.Browser.Features;
using VideoDownloader.Browser.Tabs;
namespace VideoDownloader.Tests.Browser;
// ── 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<TabItem>();
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, " "));
}
}