421 lines
14 KiB
C#
421 lines
14 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 브라우저 권한 제어, 추적 차단, URL 정제, 사이트 줌/컨트롤 파이프라인.
|
|
/// </summary>
|
|
|
|
|
|
/// <summary>T3-11: 사이트별 권한 정책 — 기억된 상태 우선, 미기억은 Ask(프롬프트).</summary>
|
|
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"));
|
|
}
|
|
|
|
|
|
|
|
/// <summary>T3-14: 추적 방지 — 블랙리스트 도메인 매칭(서브도메인 포함) + 차단 카운터.</summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// <summary>T3-15: 파라미터 제거 복사 + 주소창 진행 상태.</summary>
|
|
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));
|
|
}
|
|
}
|
|
|
|
/// <summary>T3-15: 탭 전체 네비게이션 진행 집계 — 주소창 진행 바 표시 상태.</summary>
|
|
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<string?>();
|
|
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);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// <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);
|
|
}
|
|
}
|
|
|