releases/tests/Paca.Tests/Browser/UiUsecaseHardeningTests.cs

388 lines
15 KiB
C#

using Paca.App;
using Paca.App.Browser.Features;
using Paca.App.Services;
using Paca.App.ViewModels;
using Paca.Browser;
using Paca.Browser.Tabs;
using Paca.Core.Config;
using Paca.Tests.Infrastructure;
namespace Paca.Tests.Browser;
/// <summary>
/// UI 유즈케이스 GREEN 구현 보강 테스트 — docs/UI_USECASES.md 계약(존재 확인)이 놓친
/// "실제 동작" 을 검증한다. RED 스펙 234건이 요구하지 않은 각진 부분:
/// 탭 잠금/상한, 아카이브 복원·비움, 다운로드 예약 창 계산, 단축키 충돌 검출,
/// 카드 VM 속도/ETA 계산, 페이지 다운로드 전체 일시정지, 북마크 HTML 파싱,
/// 테마 사전 키 동일성(런타임 MissingKey 방지).
/// </summary>
public class UiUsecaseHardeningTests
{
// ---------- §D 탭: 잠금·상한·그룹 ----------
[Fact]
public void 잠긴_탭은_CloseTab_거부하고_해제후_닫힌다()
{
var tabs = new TabManager(() => new TabItem());
var t = tabs.NewTab("https://a");
t.IsLocked = true;
Assert.False(tabs.CloseTab(t)); // UC-B01: 잠금이 닫기 거부
Assert.Single(tabs.Tabs);
t.IsLocked = false;
Assert.True(tabs.CloseTab(t));
}
[Fact]
public void MaxTabs_도달시_새_탭_대신_마지막_탭_활성화()
{
var tabs = new TabManager(() => new TabItem()) { MaxTabs = 2 };
var t1 = tabs.NewTab("https://1");
var t2 = tabs.NewTab("https://2");
var t3 = tabs.NewTab("https://3"); // 상한 도달 → 새 탭 추가 없음
Assert.Equal(2, tabs.Tabs.Count);
Assert.Same(t2, t3); // 마지막 탭 반환(활성화)
Assert.Same(t2, tabs.ActiveTab);
}
[Fact]
public void RenameGroup_같은_그룹_전체_이름_변경()
{
var tabs = new TabManager(() => new TabItem());
var a = tabs.NewTab("https://a");
var b = tabs.NewTab("https://b");
tabs.SetGroup(a, "G1", "#fff");
tabs.SetGroup(b, "G1", "#fff");
Assert.True(tabs.RenameGroup("G1", "작업"));
Assert.Equal("작업", a.GroupName);
Assert.Equal("작업", b.GroupName);
Assert.False(tabs.RenameGroup("없는그룹", "x"));
}
[Fact]
public void ToggleGroupCollapsed_그룹_전체_접힘_토글()
{
var tabs = new TabManager(() => new TabItem());
var a = tabs.NewTab("https://a");
var b = tabs.NewTab("https://b");
tabs.SetGroup(a, "G1", "#fff");
tabs.SetGroup(b, "G1", "#fff");
b.IsLocked = true; // CloseGroup 대상 아님(접기는 잠금과 무관)
Assert.Equal(2, tabs.ToggleGroupCollapsed("G1"));
Assert.True(a.IsGroupCollapsed);
Assert.True(b.IsGroupCollapsed);
}
// ---------- §D 탭: 아카이브 ----------
[Fact]
public void TabArchive_Clear_후_Search_빈값_Restore_null()
{
var path = Path.Combine(Path.GetTempPath(), $"vd_arch_{Guid.NewGuid():N}.json");
try
{
var arch = new TabArchive(path);
arch.Add("제목", "https://example.com");
arch.Clear();
Assert.Empty(arch.Search(""));
Assert.Null(arch.Restore("https://example.com"));
}
finally { try { File.Delete(path); } catch { } }
}
[Fact]
public void TabArchive_Restore_꺼내고_파일에도_반영()
{
var path = Path.Combine(Path.GetTempPath(), $"vd_arch_{Guid.NewGuid():N}.json");
try
{
var arch = new TabArchive(path);
arch.Add("제목A", "https://a.example.com");
var got = arch.Restore("https://a.example.com");
Assert.NotNull(got);
Assert.Equal("제목A", got!.Title);
// 꺼낸 항목은 목록에서 제거(다시 복구 불가)
Assert.Null(arch.Restore("https://a.example.com"));
Assert.Empty(arch.Search(""));
// 새 인스턴스(재시작)로도 제거 상태 유지
Assert.Empty(new TabArchive(path).Search(""));
}
finally { try { File.Delete(path); } catch { } }
}
// ---------- §E 다운로드: 예약·VM 계산·페이지 일시정지 ----------
[Theory]
[InlineData("02:00-08:00", "2026-01-01T03:00:00", true)]
[InlineData("02:00-08:00", "2026-01-01T12:00:00", false)]
[InlineData("22:00-06:00", "2026-01-01T23:30:00", true)] // 자정 가로지름
[InlineData("22:00-06:00", "2026-01-01T03:00:00", true)]
[InlineData("22:00-06:00", "2026-01-01T12:00:00", false)]
public void DownloadScheduler_시간대_판정(string window, string nowIso, bool allowed)
{
var sched = new DownloadScheduler(window);
Assert.True(sched.Enabled);
Assert.Equal(allowed, sched.CanRunNow(DateTime.Parse(nowIso)));
}
[Fact]
public void DownloadScheduler_빈창_항상허용_형식오류_게이트열림()
{
Assert.True(new DownloadScheduler(null).CanRunNow(DateTime.Now));
Assert.False(new DownloadScheduler(null).Enabled);
// 형식 오류 창은 닫힌 게이트로 막지 않는다(파싱 실패 = 항상 허용)
Assert.True(new DownloadScheduler("bad-format").CanRunNow(DateTime.Now));
}
[Fact]
public void DownloadScheduler_TryParse_형식검증()
{
Assert.True(DownloadScheduler.TryParse("09:00-18:00", out _, out _));
Assert.False(DownloadScheduler.TryParse("9-18", out _, out _));
Assert.False(DownloadScheduler.TryParse("25:00-26:00", out _, out _));
Assert.False(DownloadScheduler.TryParse("", out _, out _));
}
[Fact]
public void DownloadScheduler_다음창까지_남은시간_양수()
{
var sched = new DownloadScheduler("02:00-08:00");
var now = new DateTime(2026, 1, 1, 12, 0, 0);
var remain = sched.TimeUntilNextWindow(now);
Assert.True(remain > TimeSpan.Zero);
// 다음날 02:00 이어야 한다(14시간)
Assert.Equal(TimeSpan.FromHours(14), remain);
}
[Fact]
public void DownloadItemViewModel_속도와_ETA_계산()
{
var vm = new DownloadItemViewModel("테스트");
vm.UpdateSpeedEta(receivedBytes: 50 * 1024 * 1024, totalBytes: 100 * 1024 * 1024, speedBps: 10 * 1024 * 1024);
Assert.Equal("10.0 MB/s", vm.SpeedText);
Assert.Equal("100.0 MB", vm.TotalSizeText);
Assert.Equal("5초 남음", vm.RemainingText);
Assert.Equal(50 * 1024 * 1024L, vm.ReceivedBytes);
vm.UpdateSpeedEta(0, -1, 0); // 정지 → 표시 지움
Assert.Equal("", vm.SpeedText);
Assert.Equal("", vm.RemainingText);
}
[Theory]
[InlineData(0.5, "1초 남음")]
[InlineData(90, "2분 남음")]
[InlineData(3700, "1시간 1분 남음")]
public void DownloadItemViewModel_FormatEta_단위(double seconds, string expected)
=> Assert.Equal(expected, DownloadItemViewModel.FormatEta(TimeSpan.FromSeconds(seconds)));
[Fact]
public void DownloadItemViewModel_재시도_횟수와_우선순위_완료시각()
{
var vm = new DownloadItemViewModel("테스트");
Assert.Equal(0, vm.RetryCount);
Assert.Equal(0, vm.Priority);
vm.RetryCount = 3;
vm.Priority = 5;
vm.FinalFileName = "video.mp4";
vm.CompletedAt = DateTime.UtcNow;
Assert.Equal(3, vm.RetryCount);
Assert.Equal(5, vm.Priority);
Assert.Equal("video.mp4", vm.FinalFileName);
Assert.NotNull(vm.CompletedAt);
}
[Fact]
public void DownloadItemViewModel_SegmentMap_완료_세그먼트_채움()
{
var vm = new DownloadItemViewModel("HLS");
Assert.Empty(vm.SegmentMap);
vm.SegmentMap.Add(1.0);
vm.SegmentMap.Add(0.0);
vm.SegmentMap.Add(0.5);
Assert.Equal(new[] { 1.0, 0.0, 0.5 }, vm.SegmentMap);
}
[Fact]
public void PageDownloadTracker_PauseAll_ResumeAll_진행중만_제어()
{
var tracker = new PageDownloadTracker();
var runningOp = new FakeOp { State = "InProgress" };
var doneOp = new FakeOp { State = "Completed" };
tracker.Add(runningOp, "a.bin");
tracker.Add(doneOp, "b.bin");
tracker.PauseAll();
Assert.Equal(1, runningOp.PauseCount);
Assert.Equal(0, doneOp.PauseCount);
tracker.ResumeAll();
Assert.Equal(1, runningOp.ResumeCount);
Assert.Equal(0, doneOp.ResumeCount); // 완료 항목은 재개 대상 아님
}
private sealed class FakeOp : IDownloadOperation
{
public string Uri => "https://example.com/f";
public string FileName => "f";
public long BytesReceived => 10;
public long TotalBytesToReceive => 100;
public string State { get; set; } = "InProgress";
public int PauseCount, ResumeCount, CancelCount;
#pragma warning disable CS0067 // 인터페이스 계약상 선언만 필요(테스트에서 발화 안 함)
public event Action? Changed;
#pragma warning restore CS0067
public void Pause() => PauseCount++;
public void Resume() => ResumeCount++;
public void Cancel() => CancelCount++;
}
// ---------- §H 단축키 ----------
[Fact]
public void ShortcutManager_기본조합과_사용자오버라이드()
{
var sm = new ShortcutManager();
Assert.Equal("Ctrl+T", sm.GestureFor("newtab"));
var custom = new ShortcutManager(new Dictionary<string, string> { ["newtab"] = "Ctrl+Shift+Y" });
Assert.Equal("Ctrl+Shift+Y", custom.GestureFor("newtab"));
Assert.Equal("Ctrl+W", custom.GestureFor("closetab"));
}
[Fact]
public void ShortcutManager_충돌검출()
{
var sm = new ShortcutManager(new Dictionary<string, string> { ["find"] = "Ctrl+T" }); // newtab 과 충돌
var conflicts = sm.FindConflicts();
Assert.Contains(conflicts, g => g.Count == 2 &&
g.Any(b => b.Command == "newtab") && g.Any(b => b.Command == "find"));
Assert.Empty(new ShortcutManager().FindConflicts()); // 기본 표에는 충돌 없음
}
[Theory]
[InlineData("Ctrl+Shift+P", System.Windows.Input.ModifierKeys.Control | System.Windows.Input.ModifierKeys.Shift, System.Windows.Input.Key.P)]
[InlineData("F12", System.Windows.Input.ModifierKeys.None, System.Windows.Input.Key.F12)]
public void ShortcutManager_Parse_조합문자열(string gesture, System.Windows.Input.ModifierKeys mods, System.Windows.Input.Key key)
{
var parsed = ShortcutManager.Parse(gesture);
Assert.NotNull(parsed);
Assert.Equal(mods, parsed!.Value.Mods);
Assert.Equal(key, parsed.Value.Key);
Assert.Null(ShortcutManager.Parse("Not+A+Key"));
Assert.Null(ShortcutManager.Parse(""));
}
[Fact]
public void ShortcutManager_Normalize_순서무관_동일취급()
=> Assert.Equal(ShortcutManager.Normalize("Ctrl+Shift+P"), ShortcutManager.Normalize("shift+p+ctrl"));
// ---------- §I 즐겨찾기 ----------
[Fact]
public void 북마크_Netscape_HTML_파싱_및_역호환()
{
var html = """
<!DOCTYPE NETSCAPE-Bookmark-file-1>
<DL><p>
<DT><A HREF="https://a.example.com/x?a=1&amp;b=2">사이트 &lt;A&gt;</A>
<DT><A HREF="https://b.example.com">Plain</A>
</DL><p>
""";
var parsed = BookmarkManagerWindow.ParseNetscapeBookmarks(html).ToList();
Assert.Equal(2, parsed.Count);
Assert.Equal("https://a.example.com/x?a=1&b=2", parsed[0].Url);
Assert.Equal("사이트 <A>", parsed[0].Title);
Assert.Equal("Plain", parsed[1].Title);
}
[Fact]
public void Bookmark_레코드_Folder_기존_JSON_역직렬화_호환()
{
// 기존 저장 파일(2필드)이 Folder 기본값(null)으로 읽히는지
var old = System.Text.Json.JsonSerializer.Deserialize<Bookmark>("""{"Title":"T","Url":"https://x"}""");
Assert.NotNull(old);
Assert.Null(old!.Folder);
var @new = new Bookmark("T", "https://x", "작업");
Assert.Equal("작업", @new.Folder);
}
// ---------- §A 테마 ----------
[Theory]
[InlineData("dark")]
[InlineData("light")]
[InlineData("highcontrast")]
public void ThemeResolver_고정값_매핑(string setting)
{
var (theme, custom) = ThemeResolver.Resolve(setting);
Assert.Equal(setting, theme);
Assert.Null(custom);
}
[Fact]
public void ThemeResolver_system_은_dark_또는_light_로_확정()
{
var (theme, _) = ThemeResolver.Resolve("system");
Assert.Contains(theme, new[] { "dark", "light" }); // 레지스트리 결과에 따라
}
[Fact]
public void ThemeResolver_커스텀_경로_우선()
{
var file = Path.GetTempFileName();
try
{
var (theme, custom) = ThemeResolver.Resolve("whatever", file);
Assert.Equal("custom", theme);
Assert.Equal(file, custom);
// 없는 파일이면 다크 폴백
Assert.Equal("dark", ThemeResolver.Resolve("whatever", @"Z:\nope.xaml").Theme);
}
finally { try { File.Delete(file); } catch { } }
}
/// <summary>Dark/Light/HighContrast 세 테마가 동일한 키 집합을 정의하는지 —
/// 한 테마에만 있는 키는 전환 시 MissingKey 런타임 예외로 이어진다.</summary>
[Fact]
public void 세_테마_사전의_키_집합이_동일하다()
{
var keysOf = (string xaml) =>
System.Text.RegularExpressions.Regex.Matches(xaml, "x:Key=\"([^\"]+)\"")
.Select(m => m.Groups[1].Value).ToHashSet();
var dark = keysOf(SourceTree.ThemeDarkXaml);
var light = keysOf(SourceTree.ThemeLightXaml);
var hc = keysOf(File.ReadAllText(SourceTree.AppPath("Themes/HighContrast.xaml")));
Assert.True(dark.SetEquals(light), $"Dark/Light 키 불일치: dark-only={string.Join(",", dark.Except(light))} light-only={string.Join(",", light.Except(dark))}");
Assert.True(dark.SetEquals(hc), $"Dark/HighContrast 키 불일치: dark-only={string.Join(",", dark.Except(hc))} hc-only={string.Join(",", hc.Except(dark))}");
}
// ---------- §F 설정 ----------
[Fact]
public void ResetToDefaults_원격토큰은_유지_나머지는_초기화()
{
var c = new AppConfig
{
RemoteAccessToken = "keep-me",
Concurrency = 1,
Theme = "light",
ToastDurationMs = 100,
DownloadSortOrder = "title",
};
c.ResetToDefaults();
Assert.Equal("keep-me", c.RemoteAccessToken); // 기기 페어링 유지
Assert.Equal(new AppConfig().Concurrency, c.Concurrency);
Assert.Equal("dark", c.Theme);
Assert.Equal(4000, c.ToastDurationMs);
Assert.Equal("newest", c.DownloadSortOrder);
}
}