using System.IO; using System.Text; using Paca.App.Browser.Features; using Paca.Browser; using Paca.Browser.PageControls; using Paca.Core; using Paca.Core.Config; using Xunit; namespace Paca.Tests.Browser; /// /// 브라우저 권한 제어, 추적 차단, URL 정제, 사이트 줌/컨트롤 파이프라인. /// /// T3-11: 사이트별 권한 정책 — 기억된 상태 우선, 미기억은 Ask(프롬프트). public class PermissionGateTests { [Fact] public void 미기억_권한은_Ask() => Assert.Equal(PermissionVerdict.Ask, new PermissionGate().Decide("notifications", "https://x.com")); [Theory] [InlineData("allow")] [InlineData("deny")] public void 기억된_판정은_그대로_반환(string v) { var g = new PermissionGate(); g.Remember("notifications", "https://x.com", v == "allow" ? PermissionVerdict.Allow : PermissionVerdict.Deny); Assert.Equal(v == "allow" ? PermissionVerdict.Allow : PermissionVerdict.Deny, g.Decide("notifications", "https://x.com")); } [Fact] public void 판정은_출처_단위_격리() { var g = new PermissionGate(); g.Remember("geolocation", "https://a.com", PermissionVerdict.Allow); Assert.Equal(PermissionVerdict.Ask, g.Decide("geolocation", "https://b.com")); } [Fact] public void 판정은_종류_단위_격리() { var g = new PermissionGate(); g.Remember("notifications", "https://a.com", PermissionVerdict.Allow); Assert.Equal(PermissionVerdict.Ask, g.Decide("camera", "https://a.com")); } [Fact] public void OtherSensors_등_민감_종류는_자동_거절() => Assert.Equal(PermissionVerdict.Deny, new PermissionGate().Decide("OtherSensors", "https://x.com")); [Fact] public void MultipleAutomaticDownloads_파일_다운로드는_자동_허용() => Assert.Equal(PermissionVerdict.Allow, new PermissionGate().Decide("MultipleAutomaticDownloads", "https://x.com")); } /// T3-14: 추적 방지 — 블랙리스트 도메인 매칭(서브도메인 포함) + 차단 카운터. public class TrackerBlockTests { [Theory] [InlineData("https://google-analytics.com/analytics.js", true)] // 정확히 도메인 [InlineData("https://www.google-analytics.com/g/collect", true)] // 서브도메인 [InlineData("https://x.com/notgoogle-analytics.com/a", false)] // 접미사 함정 제외 [InlineData("https://doubleclick.net/instream/ad_status.js", true)] [InlineData("https://safe.example.com/lib.js", false)] // 무관 도메인 public void Matches_블랙리스트_도메인_판정(string url, bool expected) => Assert.Equal(expected, new TrackerBlocklist().Matches(new Uri(url))); [Fact] public void 커스텀_도메인_주입_가능() { var bl = new TrackerBlocklist(new[] { "blocked.test" }); Assert.True(bl.Matches(new Uri("https://t.blocked.test/x.js"))); Assert.False(bl.Matches(new Uri("https://ok.com/x.js"))); } [Fact] public void Counter_차단_누적_도메인별_집계_변경_통지() { var c = new TrackerCounter(); var changed = 0; c.Changed += () => changed++; Assert.Equal(0, c.Total); c.Record("https://www.google-analytics.com/a.js"); c.Record("https://google-analytics.com/b.js"); c.Record("https://scorecardresearch.com/c.js"); Assert.Equal(3, c.Total); Assert.Equal(2, c.ByDomain["google-analytics.com"]); Assert.Equal(1, c.ByDomain["scorecardresearch.com"]); Assert.Equal(3, changed); } } /// T3-15: 파라미터 제거 복사 + 주소창 진행 상태. public class UrlCleanerTests { [Theory] [InlineData("https://x.com/a?utm_source=1&b=2", "https://x.com/a")] // 쿼리 제거 [InlineData("https://x.com/a#frag", "https://x.com/a")] // 프래그먼트 제거 [InlineData("https://x.com/a?b=1#f", "https://x.com/a")] // 둘 다 [InlineData("https://x.com/a/", "https://x.com/a/")] // 없으면 무변경 [InlineData("https://x.com/a?q=한글", "https://x.com/a")] // 유니코드 쿼리 [InlineData("x.com/a?b=1", "x.com/a")] // 스킴 없어도 동작 public void StripQuery_파라미터와_프래그먼트를_제거(string input, string expected) => Assert.Equal(expected, UrlCleaner.StripQuery(input)); [Fact] public void StripQuery_빈값_null_안전() { Assert.Equal("", UrlCleaner.StripQuery("")); Assert.Null(UrlCleaner.StripQuery(null)); } } /// T3-15: 탭 전체 네비게이션 진행 집계 — 주소창 진행 바 표시 상태. public class NavProgressTrackerTests { [Fact] public void 초기값은_비진행() => Assert.False(new NavProgressTracker().IsNavigating); [Fact] public void 시작하면_진행() { var t = new NavProgressTracker(); t.Started(); Assert.True(t.IsNavigating); } [Fact] public void 완료되면_비진행() { var t = new NavProgressTracker(); t.Started(); t.Completed(); Assert.False(t.IsNavigating); } [Fact] public void 동시_내비게이션은_마지막_완료까지_진행() // 프레임 리다이렉트·중복 시작 { var t = new NavProgressTracker(); t.Started(); t.Started(); t.Completed(); Assert.True(t.IsNavigating); t.Completed(); Assert.False(t.IsNavigating); } [Fact] public void 과잉_완료로_음수_되지_않음() { var t = new NavProgressTracker(); t.Completed(); Assert.False(t.IsNavigating); } [Fact] public void IsNavigating_변경_시_INPC_발화() { var t = new NavProgressTracker(); var fired = new List(); t.PropertyChanged += (_, e) => fired.Add(e.PropertyName); t.Started(); t.Completed(); t.Completed(); // false→false → 이벤트 없음 Assert.Equal(new[] { nameof(NavProgressTracker.IsNavigating), nameof(NavProgressTracker.IsNavigating) }, fired); } } // T1-7 사이트별 줌 영속: 도메인→배율 저장, 범위 클램프, JSON 영속, 최대 항목 제한. public class SiteZoomStoreTests : IDisposable { private readonly string _path; private readonly SiteZoomStore _store; public SiteZoomStoreTests() { _path = Path.Combine(Path.GetTempPath(), $"zoomstore_{Guid.NewGuid():N}.json"); _store = new SiteZoomStore(_path); } public void Dispose() { try { File.Delete(_path); } catch { } } [Fact] public void 미등록_사이트는_기본값_1_0을_반환한다() { Assert.Equal(1.0, _store.Get("https://example.com/page")); } [Fact] public void 배율을_저장하면_같은_호스트의_다른_경로에서도_읽힌다() { _store.Set("https://example.com/a", 1.5); Assert.Equal(1.5, _store.Get("https://example.com/b/c/d")); } [Fact] public void www_접두사는_동일_호스트로_정규화된다() { _store.Set("https://www.example.com/a", 2.0); Assert.Equal(2.0, _store.Get("https://example.com/b")); } [Theory] [InlineData(0.05, 0.1)] // 하한 클램프 [InlineData(9.0, 5.0)] // 상한 클램프 [InlineData(1.25, 1.25)] // 정상 범위 public void 배율은_0_1에서_5_0으로_클램프된다(double input, double expected) { _store.Set("https://clamp.com/", input); Assert.Equal(expected, _store.Get("https://clamp.com/")); } [Fact] public void 저장_즉시_파일에_영속되어_새_인스턴스가_읽는다() { _store.Set("https://persist.com/", 1.75); Assert.True(File.Exists(_path)); var reloaded = new SiteZoomStore(_path); Assert.Equal(1.75, reloaded.Get("https://persist.com/other")); } [Fact] public void 최대_300개_초과시_가장_오래_사용하지_한_항목부터_제거된다() { // 300개 채우기 for (int i = 0; i < 300; i++) _store.Set($"https://site{i}.com/", 1.0 + i * 0.001); // 가장 먼저 넣은 site0 을 최근에 다시 설정(LRU 갱신) → site1 이 제거 후보가 됨 _store.Set("https://site0.com/", 1.9); // 하나 더 추가 → 초과분 제거 _store.Set("https://new.com/", 2.0); Assert.Equal(300, _store.Count); Assert.Equal(1.0, _store.Get("https://site1.com/")); // 제거됨 → 기본값 Assert.Equal(2.0, _store.Get("https://new.com/")); // 유지 Assert.Equal(1.9, _store.Get("https://site0.com/")); // LRU 갱신됨 → 유지 } [Fact] public void Clear는_모든_항목과_파일을_지운다() { _store.Set("https://x.com/", 2.0); _store.Clear(); Assert.Equal(0, _store.Count); Assert.Equal(1.0, _store.Get("https://x.com/")); Assert.False(File.Exists(_path)); } [Fact] public void 손상된_JSON은_무시하고_빈_상태로_시작한다() { File.WriteAllText(_path, "{ not json !!"); var store = new SiteZoomStore(_path); Assert.Equal(0, store.Count); } } public class PageControlsManagerTests { [Fact] public void ExtractDomain_ReturnsCorrectHost() { Assert.Equal("www.youtube.com", PageControlsManager.ExtractDomain("https://www.youtube.com/watch?v=12345")); Assert.Equal("github.com", PageControlsManager.ExtractDomain("https://github.com/microsoft/webview2")); Assert.Equal("", PageControlsManager.ExtractDomain("about:blank")); Assert.Equal("", PageControlsManager.ExtractDomain("invalid-url")); } [Fact] public void IsSecureScheme_DetectsHttps() { Assert.True(PageControlsManager.IsSecureScheme("https://youtube.com")); Assert.False(PageControlsManager.IsSecureScheme("http://insecure-site.com")); Assert.False(PageControlsManager.IsSecureScheme("about:blank")); } [Fact] public void Settings_StoreAndResetCorrectly() { var manager = new PageControlsManager(); var domain = "youtube.com"; var initial = manager.GetSettings(domain); Assert.True(initial.SoundAllowed); Assert.True(initial.TrackerBlocking); manager.SetSoundAllowed(domain, false); manager.SetTrackerBlocking(domain, false); manager.SetDarkModeBoost(domain, true); var modified = manager.GetSettings(domain); Assert.False(modified.SoundAllowed); Assert.False(modified.TrackerBlocking); Assert.True(modified.DarkModeBoost); manager.ResetSettings(domain); var reset = manager.GetSettings(domain); Assert.True(reset.SoundAllowed); Assert.True(reset.TrackerBlocking); Assert.False(reset.DarkModeBoost); } } /// 페이지 다운로드 추상화(다운로드 매니저 UI 의 로직 코어) — fake op 로 검증. 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); } }