feat(ads/seo/release): v1.2.0 with live Google Ads, isolated ad profile, ads.txt, and SEO/AEO/GEO optimization
This commit is contained in:
parent
a88aafa1e5
commit
0e568f1f0a
975 changed files with 130593 additions and 16783 deletions
388
tests/Paca.Tests/Browser/UiUsecaseHardeningTests.cs
Normal file
388
tests/Paca.Tests/Browser/UiUsecaseHardeningTests.cs
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
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&b=2">사이트 <A></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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue