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:
Yun Chan 2026-09-14 00:39:44 +09:00
parent a88aafa1e5
commit 0e568f1f0a
975 changed files with 130593 additions and 16783 deletions

View file

@ -0,0 +1,70 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Mobile;
public class MobileAdMobInterstitialAndRewardedGateTests
{
private static readonly string SolutionRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
[Fact]
public async Task Gate_AdMobWaterfallController_ShowInterstitial_DelegatesToAdapter()
{
var directMgr = new MobileAdManager();
var successAdapter = new MockAdMobAdapter(shouldSucceed: true);
var controller = new AdMobWaterfallController(successAdapter, directMgr);
var result = await controller.ShowInterstitialAsync();
Assert.True(result);
var failureAdapter = new MockAdMobAdapter(shouldSucceed: false);
var failController = new AdMobWaterfallController(failureAdapter, directMgr);
var failResult = await failController.ShowInterstitialAsync();
Assert.False(failResult);
}
[Fact]
public async Task Gate_AdMobWaterfallController_ShowRewarded_DelegatesToAdapter()
{
var directMgr = new MobileAdManager();
var successAdapter = new MockAdMobAdapter(shouldSucceed: true);
var controller = new AdMobWaterfallController(successAdapter, directMgr);
var result = await controller.ShowRewardedAsync();
Assert.True(result);
var failureAdapter = new MockAdMobAdapter(shouldSucceed: false);
var failController = new AdMobWaterfallController(failureAdapter, directMgr);
var failResult = await failController.ShowRewardedAsync();
Assert.False(failResult);
}
[Fact]
public async Task Gate_AdMobRewardCoordinator_AutomaticallyGrantsBoost_OnRewardEarned()
{
var tokenManager = new RewardedBoostTokenManager();
Assert.False(tokenManager.IsBoostActive);
var mockAdapter = new MockAdMobAdapter(shouldSucceed: true);
var coordinator = new AdMobRewardCoordinator(mockAdapter, tokenManager);
var shown = await coordinator.RequestAndShowRewardAdAsync();
Assert.True(shown);
Assert.True(tokenManager.IsBoostActive);
Assert.True(tokenManager.RemainingBoostTime.TotalMinutes > 50);
}
[Fact]
public void Gate_AndroidAdMobAdapter_SourceAudit_EnsuresRealRewardEventAndInvocation()
{
var adapterPath = Path.Combine(SolutionRoot, "src", "Paca.Mobile", "Platforms", "Android", "AndroidAdMobAdapter.cs");
Assert.True(File.Exists(adapterPath), $"AndroidAdMobAdapter.cs must exist at {adapterPath}");
var code = File.ReadAllText(adapterPath);
Assert.Contains("public event Action<string, double>? OnUserEarnedReward;", code);
Assert.Contains("OnUserEarnedReward?.Invoke", code);
}
}

View file

@ -0,0 +1,236 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Paca.Mobile.Core;
using Paca.Tests.Infrastructure;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// 모바일 AdMob 설정, 워터폴 폴백 및 보상형 광고 라이프사이클 TDD 게이트 테스트 (Gate 194 ~ Gate 196)
/// </summary>
public class MobileAdMobWaterfallAndRewardGateTests
{
private static string MobileCoreDir => Directory.Exists(Path.Combine(SourceTree.Root, "src", "Paca.Mobile.Core"))
? Path.Combine(SourceTree.Root, "src", "Paca.Mobile.Core")
: Path.Combine(SourceTree.Root, "Paca.Mobile.Core");
private static string MobileProjectDir => Directory.Exists(Path.Combine(SourceTree.Root, "src", "Paca.Mobile"))
? Path.Combine(SourceTree.Root, "src", "Paca.Mobile")
: Path.Combine(SourceTree.Root, "Paca.Mobile");
[Fact]
public void Gate194_AdMobConfig_GoogleOfficialTestIds_And_Validation_Audit()
{
// 1. Google 공식 Android AdMob Test IDs 기본값 검증
var config = AdMobConfig.DefaultTestConfig;
Assert.Equal("ca-app-pub-3940256099942544~3347511713", config.AppId);
Assert.Equal("ca-app-pub-3940256099942544/6300978111", config.BannerAdUnitId);
Assert.Equal("ca-app-pub-3940256099942544/1033173712", config.InterstitialAdUnitId);
Assert.Equal("ca-app-pub-3940256099942544/5224354917", config.RewardedAdUnitId);
Assert.True(config.IsTestMode);
// 2. 커스텀 프로덕션 ID 주입 및 유효성 검증
var prodConfig = new AdMobConfig(
appId: "ca-app-pub-1234567890123456~1234567890",
bannerAdUnitId: "ca-app-pub-1234567890123456/1111111111",
interstitialAdUnitId: "ca-app-pub-1234567890123456/2222222222",
rewardedAdUnitId: "ca-app-pub-1234567890123456/3333333333",
isTestMode: false
);
Assert.False(prodConfig.IsTestMode);
Assert.True(prodConfig.IsValid());
// 3. 잘못된 형식(유효하지 않은 접두사) 검증 방어
var invalidConfig = new AdMobConfig(
appId: "invalid-app-id",
bannerAdUnitId: "",
interstitialAdUnitId: "",
rewardedAdUnitId: "",
isTestMode: false
);
Assert.False(invalidConfig.IsValid());
}
[Fact]
public async Task Gate195_AdMobWaterfall_SuccessAndFallbackLifecycle_Audit()
{
var localAdManager = new MobileAdManager();
var fallbackAd = localAdManager.GetAdForPlacement(MobileAdPlacement.NewTabPageSponsoredCard);
Assert.NotNull(fallbackAd);
// 1. 성공 시나리오 Mock 어댑터
var successAdapter = new MockAdMobAdapter(shouldSucceed: true, adUnitId: "test-banner-unit");
var controller = new AdMobWaterfallController(successAdapter, localAdManager);
var result = await controller.RequestBannerAdAsync(MobileAdPlacement.NewTabPageSponsoredCard);
Assert.NotNull(result);
Assert.Equal(AdSourceType.GoogleAdMob, result.SourceType);
Assert.Equal("test-banner-unit", result.AdMobUnitId);
Assert.True(result.IsAdMobActive);
// 인상 추적 확인
controller.TrackImpression(result);
Assert.Equal(1, successAdapter.ImpressionCount);
// 2. 실패 시나리오 (No Fill / Offline) -> 자체 스폰서 배너로 자동 Waterfall Fallback
var failureAdapter = new MockAdMobAdapter(shouldSucceed: false, adUnitId: "failing-unit");
var fallbackController = new AdMobWaterfallController(failureAdapter, localAdManager);
var fallbackResult = await fallbackController.RequestBannerAdAsync(MobileAdPlacement.NewTabPageSponsoredCard);
Assert.NotNull(fallbackResult);
Assert.Equal(AdSourceType.DirectSponsorFallback, fallbackResult.SourceType);
Assert.False(fallbackResult.IsAdMobActive);
Assert.NotNull(fallbackResult.FallbackDirectAd);
Assert.Equal(fallbackAd.Id, fallbackResult.FallbackDirectAd.Id);
// Fallback 인상 추적 확인
fallbackController.TrackImpression(fallbackResult);
Assert.Equal(0, failureAdapter.ImpressionCount);
Assert.True(localAdManager.GetImpressionCount(fallbackAd.Id) > 0);
}
[Fact]
public async Task Gate196_AdMobRewardedAd_BoostToken_Lifecycle_Audit()
{
var rewardManager = new RewardedBoostTokenManager();
Assert.False(rewardManager.IsBoostActive);
var mockAdapter = new MockAdMobAdapter(shouldSucceed: true, adUnitId: "rewarded-unit");
// 1. 보상형 광고 시청 완료 콜백 시뮬레이션
bool rewardTriggered = false;
mockAdapter.OnUserEarnedReward += (type, amount) =>
{
rewardTriggered = true;
rewardManager.GrantBoost(TimeSpan.FromHours(1));
};
var showResult = await mockAdapter.ShowRewardedAdAsync();
Assert.True(showResult);
Assert.True(rewardTriggered);
// 2. 부스트 토큰 활성화 확인
Assert.True(rewardManager.IsBoostActive);
Assert.True(rewardManager.RemainingBoostTime.TotalMinutes > 50);
// 3. 만료 시뮬레이션
rewardManager.RevokeBoost();
Assert.False(rewardManager.IsBoostActive);
}
[Fact]
public void Gate197_AdMobConfig_PacaProduction_RealIds_And_ResolveByBuild_Audit()
{
// 1. PACA 프로덕션 설정 실제 ID 검증
var prod = AdMobConfig.PacaProduction;
Assert.Equal("ca-app-pub-1039714767792854~5680462775", prod.AppId);
Assert.Equal("ca-app-pub-1039714767792854/3110491400", prod.BannerAdUnitId);
Assert.False(prod.IsTestMode);
Assert.True(prod.IsValid());
// 2. 환경변수 미설정 시 빌드 구성에 따른 해석 (ADMOB_APP_ID / ADMOB_BANNER_ID 백업 후 제거)
var backupAppId = Environment.GetEnvironmentVariable("ADMOB_APP_ID");
var backupBannerId = Environment.GetEnvironmentVariable("ADMOB_BANNER_ID");
try
{
Environment.SetEnvironmentVariable("ADMOB_APP_ID", null);
Environment.SetEnvironmentVariable("ADMOB_BANNER_ID", null);
var debugResolved = AdMobConfig.Resolve(isDebugBuild: true);
Assert.Equal(AdMobConfig.DefaultTestConfig.AppId, debugResolved.AppId);
Assert.Equal(AdMobConfig.DefaultTestConfig.BannerAdUnitId, debugResolved.BannerAdUnitId);
Assert.True(debugResolved.IsTestMode);
var releaseResolved = AdMobConfig.Resolve(isDebugBuild: false);
Assert.Equal(AdMobConfig.PacaProduction.AppId, releaseResolved.AppId);
Assert.Equal(AdMobConfig.PacaProduction.BannerAdUnitId, releaseResolved.BannerAdUnitId);
Assert.False(releaseResolved.IsTestMode);
// 3. 환경변수가 설정되어 있으면 빌드 구성과 무관하게 환경변수 값을 우선한다
Environment.SetEnvironmentVariable("ADMOB_APP_ID", "ca-app-pub-9999999999999999~1111111111");
Environment.SetEnvironmentVariable("ADMOB_BANNER_ID", "ca-app-pub-9999999999999999/2222222222");
var envDebugResolved = AdMobConfig.Resolve(isDebugBuild: true);
Assert.Equal("ca-app-pub-9999999999999999~1111111111", envDebugResolved.AppId);
Assert.Equal("ca-app-pub-9999999999999999/2222222222", envDebugResolved.BannerAdUnitId);
var envReleaseResolved = AdMobConfig.Resolve(isDebugBuild: false);
Assert.Equal("ca-app-pub-9999999999999999~1111111111", envReleaseResolved.AppId);
Assert.Equal("ca-app-pub-9999999999999999/2222222222", envReleaseResolved.BannerAdUnitId);
}
finally
{
Environment.SetEnvironmentVariable("ADMOB_APP_ID", backupAppId);
Environment.SetEnvironmentVariable("ADMOB_BANNER_ID", backupBannerId);
}
}
[Fact]
public void Gate198_AndroidManifest_Uses_PacaProduction_AdMobAppId_Audit()
{
var manifestPath = Path.Combine(MobileProjectDir, "Platforms", "Android", "AndroidManifest.xml");
var manifestXml = File.ReadAllText(manifestPath);
Assert.Contains(
$"android:value=\"{AdMobConfig.PacaProduction.AppId}\"",
manifestXml);
Assert.DoesNotContain("ca-app-pub-3940256099942544~3347511713", manifestXml);
}
[Fact]
public void Gate199_PacaMobile_Csproj_References_GooglePlayServicesAds_Audit()
{
var csprojPath = Path.Combine(MobileProjectDir, "Paca.Mobile.csproj");
var csprojXml = File.ReadAllText(csprojPath);
Assert.Contains("Xamarin.GooglePlayServices.Ads", csprojXml);
}
[Fact]
public void Gate200_AdMobConfig_ForceRealTestAds_WhenUnderReview_Audit()
{
// 1. 기본 테스트 설정 및 프로덕션 설정에서 EffectiveBannerAdUnitId 검증
var testConfig = AdMobConfig.DefaultTestConfig;
Assert.Equal("ca-app-pub-3940256099942544/6300978111", testConfig.EffectiveBannerAdUnitId);
Assert.True(testConfig.IsTestMode);
// 2. 프로덕션 설정이라도 forceTestAds 가 활성화되면 실제 Google 공식 테스트 배너 ID 반환
var prodUnderReview = AdMobConfig.PacaProduction with { ForceTestAds = true };
Assert.True(prodUnderReview.ForceTestAds);
Assert.Equal("ca-app-pub-3940256099942544/6300978111", prodUnderReview.EffectiveBannerAdUnitId);
// 3. Resolve 메서드에서 검토 필요 상태를 위한 forceTestAds 기본 동작 검증
var backupForce = Environment.GetEnvironmentVariable("ADMOB_FORCE_TEST_ADS");
try
{
Environment.SetEnvironmentVariable("ADMOB_FORCE_TEST_ADS", "true");
var resolvedWithForce = AdMobConfig.Resolve(isDebugBuild: false);
Assert.Equal("ca-app-pub-3940256099942544/6300978111", resolvedWithForce.EffectiveBannerAdUnitId);
Assert.True(resolvedWithForce.ForceTestAds);
}
finally
{
Environment.SetEnvironmentVariable("ADMOB_FORCE_TEST_ADS", backupForce);
}
}
[Fact]
public void Gate201_AndroidAdMobAdapter_RequestConfiguration_And_TestDevice_Audit()
{
var adapterPath = Path.Combine(MobileProjectDir, "Platforms", "Android", "AndroidAdMobAdapter.cs");
var adapterCode = File.ReadAllText(adapterPath);
// Google Mobile Ads SDK 의 RequestConfiguration 설정 및 테스트 디바이스 등록 보증
Assert.Contains("RequestConfiguration", adapterCode);
Assert.Contains("SetTestDeviceIds", adapterCode);
Assert.Contains("DeviceIdEmulator", adapterCode);
Assert.Contains("EffectiveBannerAdUnitId", adapterCode);
}
}

View file

@ -0,0 +1,67 @@
using System;
using System.IO;
using Paca.Mobile.Core;
using Paca.Tests.Infrastructure;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// Gate 157 ~ 159: 모바일 적응형 스트리밍 트랜스코딩 & 데이터 세이버 오디오 추출 기계적 검증
/// </summary>
public class MobileAdaptiveTranscodingGateTests
{
[Fact]
public void Gate157_AdaptiveStreamingProfile_Bandwidth_Estimation()
{
// 1시간 4K 영상 (약 15GB, ~33.3 Mbps)
long originalBytes = 15L * 1024 * 1024 * 1024;
var duration = TimeSpan.FromHours(1);
var originalProfile = AdaptiveStreamingProfile.OriginalDirect;
var cellularProfile = AdaptiveStreamingProfile.CellularDataSaver_720p;
var audioProfile = AdaptiveStreamingProfile.AudioPodcast_128k;
// 원본: 절감률 0%
Assert.Equal(0.0, originalProfile.CalculateDataSavingsPercent(originalBytes, duration));
// 720p 셀룰러: 약 675MB 소모 (1.5Mbps * 3600s / 8) -> 95%+ 절감
var cellularSavings = cellularProfile.CalculateDataSavingsPercent(originalBytes, duration);
Assert.InRange(cellularSavings, 90.0, 98.0);
// 오디오 전용: 약 57MB 소모 (128kbps * 3600s / 8) -> 99%+ 절감
var audioSavings = audioProfile.CalculateDataSavingsPercent(originalBytes, duration);
Assert.InRange(audioSavings, 98.0, 99.9);
}
[Fact]
public void Gate158_StreamingQualityManager_AutoSelection()
{
var manager = new StreamingQualityManager();
// 1. Wi-Fi 무제한 환경 -> 원본 직접 스트리밍
var wifiProfile = manager.SelectOptimalProfile(NetworkProfileType.WiFi, isDataSaverActive: false);
Assert.Equal(AdaptiveStreamingProfile.OriginalDirect, wifiProfile);
// 2. 3G/4G/5G 셀룰러 환경 -> 720p 데이터 세이버 자동 전환
var cellularProfile = manager.SelectOptimalProfile(NetworkProfileType.Cellular5G, isDataSaverActive: false);
Assert.Equal(AdaptiveStreamingProfile.CellularDataSaver_720p, cellularProfile);
// 3. 사용자 데이터 절약 모드 켬 -> 오디오 전용 모드 자동 추천
var saverProfile = manager.SelectOptimalProfile(NetworkProfileType.CellularLTE, isDataSaverActive: true);
Assert.Equal(AdaptiveStreamingProfile.AudioPodcast_128k, saverProfile);
}
[Fact]
public void Gate159_WebCompanion_AudioExtraction_Endpoint_Header_Audit()
{
var serverHostPath = Path.Combine(SourceTree.Root, "src", "Paca.Server", "ServerHost.cs");
Assert.True(File.Exists(serverHostPath), "ServerHost.cs must exist");
var code = File.ReadAllText(serverHostPath);
// 오디오 전용 스트리밍 엔드포인트 /api/audio/ 검증
Assert.Contains("/api/audio/{id}", code);
Assert.Contains("audio/mp4", code);
}
}

View file

@ -0,0 +1,115 @@
using System;
using System.IO;
using System.Linq;
using System.Xml.Linq;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// 모바일 안드로이드 최신 버전 지원(minSdk 28+, targetSdk 36) 및
/// Obsolete API(DisplayAlert, Device.StartTimer, Frame 등) 퇴출 기계적 감사 게이트
/// </summary>
public class MobileAndroidModernizationTests
{
private static string MobileDir => Path.Combine(SourceTree.Root, "src", "Paca.Mobile");
private static string AndroidManifestPath => Path.Combine(MobileDir, "Platforms", "Android", "AndroidManifest.xml");
private static string MobileCsprojPath => Path.Combine(MobileDir, "Paca.Mobile.csproj");
[Fact]
public void Gate_Android_MinSdkVersion_Is_At_Least_28_And_Target_Is_36()
{
// 1. AndroidManifest.xml 검증
Assert.True(File.Exists(AndroidManifestPath), "AndroidManifest.xml 이 존재해야 합니다.");
var manifestDoc = XDocument.Load(AndroidManifestPath);
var usesSdk = manifestDoc.Descendants("uses-sdk").FirstOrDefault();
Assert.NotNull(usesSdk);
XNamespace androidNs = "http://schemas.android.com/apk/res/android";
var minSdk = (int?)usesSdk.Attribute(androidNs + "minSdkVersion");
var targetSdk = (int?)usesSdk.Attribute(androidNs + "targetSdkVersion");
Assert.NotNull(minSdk);
Assert.True(minSdk.Value >= 28, $"minSdkVersion은 최소 28(Android 9.0) 이상이어야 합니다. 현재: {minSdk.Value}");
Assert.NotNull(targetSdk);
Assert.True(targetSdk.Value >= 36, $"targetSdkVersion은 최신 36(Android 16) 이상이어야 합니다. 현재: {targetSdk.Value}");
// 2. Paca.Mobile.csproj의 SupportedOSPlatformVersion 검증
Assert.True(File.Exists(MobileCsprojPath), "Paca.Mobile.csproj 가 존재해야 합니다.");
var csprojContent = File.ReadAllText(MobileCsprojPath);
Assert.DoesNotContain("<SupportedOSPlatformVersion Condition=\"$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'\">24.0</SupportedOSPlatformVersion>", csprojContent);
Assert.Contains("<SupportedOSPlatformVersion Condition=\"$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'\">28.0</SupportedOSPlatformVersion>", csprojContent);
}
[Fact]
public void Gate_Mobile_Packages_Are_Modernized()
{
var csprojContent = File.ReadAllText(MobileCsprojPath);
// 1. ZXing.Net.Maui.Controls 최신화 및 넷6 구형 패키지(0.4.0) 퇴출
Assert.DoesNotContain("Include=\"ZXing.Net.Maui.Controls\" Version=\"0.4.0\"", csprojContent);
Assert.Contains("Include=\"ZXing.Net.Maui.Controls\" Version=\"0.10.4\"", csprojContent);
// 2. CommunityToolkit.Maui.MediaElement 최신화(.NET 10 대응 10.0.0) 및 4.0.0 퇴출
Assert.DoesNotContain("Include=\"CommunityToolkit.Maui.MediaElement\" Version=\"4.0.0\"", csprojContent);
Assert.Contains("Include=\"CommunityToolkit.Maui.MediaElement\" Version=\"10.0.0\"", csprojContent);
// 3. Plugin.LocalNotification 최신화
Assert.DoesNotContain("Include=\"Plugin.LocalNotification\" Version=\"11.1.0\"", csprojContent);
Assert.Contains("Include=\"Plugin.LocalNotification\" Version=\"14.1.1\"", csprojContent);
}
[Fact]
public void Gate_Mobile_No_Obsolete_Maui_Apis_Audit()
{
var csFiles = Directory.GetFiles(MobileDir, "*.cs", SearchOption.AllDirectories)
.Where(f => !f.Contains("\\obj\\") && !f.Contains("/obj/") && !f.Contains("\\bin\\") && !f.Contains("/bin/"))
.ToList();
foreach (var file in csFiles)
{
var fileName = Path.GetFileName(file);
var lines = File.ReadAllLines(file);
for (int i = 0; i < lines.Length; i++)
{
var line = lines[i];
var trimmed = line.Trim();
// 주석 라인은 제외
if (trimmed.StartsWith("//") || trimmed.StartsWith("/*") || trimmed.StartsWith("*"))
continue;
// 1. DisplayAlert( -> DisplayAlertAsync 사용 필수
if (line.Contains("DisplayAlert(") && !line.Contains("DisplayAlertAsync"))
{
Assert.Fail($"[{fileName}:{i + 1}] Obsolete API 'DisplayAlert' 가 발견되었습니다. 'DisplayAlertAsync'를 사용하세요: {trimmed}");
}
// 2. DisplayActionSheet( -> DisplayActionSheetAsync 사용 필수
if (line.Contains("DisplayActionSheet(") && !line.Contains("DisplayActionSheetAsync"))
{
Assert.Fail($"[{fileName}:{i + 1}] Obsolete API 'DisplayActionSheet' 가 발견되었습니다. 'DisplayActionSheetAsync'를 사용하세요: {trimmed}");
}
// 3. Device.StartTimer 퇴출
if (line.Contains("Device.StartTimer"))
{
Assert.Fail($"[{fileName}:{i + 1}] Obsolete API 'Device.StartTimer' 가 발견되었습니다. 'Dispatcher.StartTimer'를 사용하세요: {trimmed}");
}
// 4. new Frame 퇴출 (Android FrameLayout 레이아웃 제외 MAUI Frame 컨트롤)
if (line.Contains("new Frame") && !line.Contains("FrameLayout"))
{
Assert.Fail($"[{fileName}:{i + 1}] Obsolete API 'Frame' 이 발견되었습니다. 'Border'를 사용하세요: {trimmed}");
}
// 5. MainPage 직접 탐색 퇴출 (Application.Current.MainPage)
if (line.Contains(".MainPage"))
{
Assert.Fail($"[{fileName}:{i + 1}] Deprecated API 'MainPage' 가 발견되었습니다. Windows[0].Page 를 사용하세요: {trimmed}");
}
}
}
}
}

View file

@ -0,0 +1,146 @@
using System;
using System.IO;
using Paca.Mobile.Core;
using Paca.Tests.Infrastructure;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// 모바일 안드로이드 하단 바 조화, 동적 세이프 에어리어 인셋 및 광고(AD) 슬롯 TDD 게이트 테스트 (Gate 191 ~ Gate 193)
/// </summary>
public class MobileAndroidSystemBarHarmonyAndAdGateTests
{
private static string MobileProjectDir => Directory.Exists(Path.Combine(SourceTree.Root, "src", "Paca.Mobile"))
? Path.Combine(SourceTree.Root, "src", "Paca.Mobile")
: Path.Combine(SourceTree.Root, "Paca.Mobile");
private static string MobileCoreDir => Directory.Exists(Path.Combine(SourceTree.Root, "src", "Paca.Mobile.Core"))
? Path.Combine(SourceTree.Root, "src", "Paca.Mobile.Core")
: Path.Combine(SourceTree.Root, "Paca.Mobile.Core");
[Fact]
public void Gate191_MobileSafeAreaCoordinator_NavigationBarsInset_And_PaddingCalculation_Audit()
{
var coordinator = new MobileSafeAreaCoordinator();
// 1. 기본 상태 검증 (인셋 0, 기본 여백)
Assert.Equal(0.0, coordinator.BottomInsetDp);
Assert.Equal(0.0, coordinator.TopInsetDp);
Assert.Equal(16.0, coordinator.CalculateBottomPadding(16.0, 6.0));
// 2. 이벤트 발생 여부 추적
bool eventFired = false;
coordinator.InsetsChanged += () => eventFired = true;
// 3. 제스처 바 인셋 (~24dp) 수신 시 동적 하단 패딩 계산
coordinator.UpdateInsets(bottomDp: 24.0, topDp: 36.0);
Assert.True(eventFired);
Assert.Equal(24.0, coordinator.BottomInsetDp);
Assert.Equal(36.0, coordinator.TopInsetDp);
// basePadding: 16.0, extraClearance: 6.0 -> Math.Max(16.0, 24.0 + 6.0) = 30.0
Assert.Equal(30.0, coordinator.CalculateBottomPadding(16.0, 6.0));
Assert.Equal(28.0, coordinator.CalculateBottomPadding(12.0, 4.0));
// 4. 3-버튼 내비게이션 바 인셋 (~48dp) 수신 시 하단 겹침 방지 여백 계산
eventFired = false;
coordinator.UpdateInsets(bottomDp: 48.0, topDp: 36.0);
Assert.True(eventFired);
Assert.Equal(48.0, coordinator.BottomInsetDp);
// basePadding: 16.0, extraClearance: 6.0 -> Math.Max(16.0, 48.0 + 6.0) = 54.0
Assert.Equal(54.0, coordinator.CalculateBottomPadding(16.0, 6.0));
// 5. 전체화면 또는 인셋 0 복귀 시 최소 기본 여백 보장
coordinator.UpdateInsets(bottomDp: 0.0, topDp: 0.0);
Assert.Equal(16.0, coordinator.CalculateBottomPadding(16.0, 6.0));
// 6. 내비게이션 바 테마 동기화 상태
coordinator.SetNavigationBarTheme("#10111A", isLight: false);
Assert.Equal("#10111A", coordinator.NavigationBarColorHex);
Assert.False(coordinator.IsNavigationBarLight);
coordinator.SetNavigationBarTheme("#FFFFFF", isLight: true);
Assert.Equal("#FFFFFF", coordinator.NavigationBarColorHex);
Assert.True(coordinator.IsNavigationBarLight);
}
[Fact]
public void Gate192_MobileAdSlotSystem_Models_Dismissal_And_ImpressionTracking_Audit()
{
var adManager = new MobileAdManager();
// 1. 새 탭 페이지(NTP) 스폰서 카드 슬롯 조회
var ntpAd = adManager.GetAdForPlacement(MobileAdPlacement.NewTabPageSponsoredCard);
Assert.NotNull(ntpAd);
Assert.Equal("AD", ntpAd.Badge);
Assert.False(string.IsNullOrWhiteSpace(ntpAd.Title));
Assert.False(string.IsNullOrWhiteSpace(ntpAd.Advertiser));
Assert.False(string.IsNullOrWhiteSpace(ntpAd.CtaText));
Assert.False(string.IsNullOrWhiteSpace(ntpAd.TargetUrl));
Assert.True(ntpAd.IsVisible);
// 2. 인상(Impression) 및 클릭(Click) 추적
int initialImpressions = adManager.GetImpressionCount(ntpAd.Id);
int initialClicks = adManager.GetClickCount(ntpAd.Id);
adManager.TrackImpression(ntpAd.Id);
Assert.Equal(initialImpressions + 1, adManager.GetImpressionCount(ntpAd.Id));
adManager.TrackClick(ntpAd.Id);
Assert.Equal(initialClicks + 1, adManager.GetClickCount(ntpAd.Id));
// 3. 닫기(Dismiss) 기능 검증 및 이벤트 통지
bool dismissedEventFired = false;
adManager.AdDismissed += adId =>
{
if (adId == ntpAd.Id) dismissedEventFired = true;
};
adManager.DismissAd(ntpAd.Id);
Assert.True(dismissedEventFired);
Assert.False(ntpAd.IsVisible);
// 닫힌 후에는 해당 placement 조회 시 null 또는 대체 슬롯 반환
var dismissedAdCheck = adManager.GetAdForPlacement(MobileAdPlacement.NewTabPageSponsoredCard);
Assert.Null(dismissedAdCheck);
// 4. 다운로드 페이지 인라인 슬롯 조회 검증
var dlAd = adManager.GetAdForPlacement(MobileAdPlacement.DownloadsInlineBanner);
Assert.NotNull(dlAd);
Assert.Equal("AD", dlAd.Badge);
}
[Fact]
public void Gate193_DesignpacaTokens_And_AndroidPlatformContracts_Audit()
{
// 1. MobileThemeSystem 내 광고 디자인 토큰 선언 검증
var themeCs = File.ReadAllText(Path.Combine(MobileCoreDir, "MobileThemeSystem.cs"));
Assert.Contains("AdBadgeBackground", themeCs);
Assert.Contains("AdBadgeText", themeCs);
Assert.Contains("AdSurfaceBackground", themeCs);
Assert.Contains("AdBorderHairline", themeCs);
// 2. Android 플랫폼 내비게이션 바 테마 및 인셋 리스너 계약 검증
var statusBarCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Platforms", "Android", "PacaAndroidStatusBarHelper.cs"));
Assert.Contains("SetNavigationBarTheme", statusBarCs);
Assert.Contains("AppearanceLightNavigationBars", statusBarCs);
Assert.Contains("AttachWindowInsetsListener", statusBarCs);
Assert.Contains("NavigationBars", statusBarCs);
var mainActivityCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Platforms", "Android", "MainActivity.cs"));
Assert.Contains("AttachWindowInsetsListener", mainActivityCs);
// 3. BrowserPage 및 DownloadsPage의 MobileAdBannerView 탑재 및 세이프 에어리어 연동 감사
var browserCs = File.ReadAllText(Path.Combine(MobileProjectDir, "BrowserPage.cs"));
Assert.Contains("MobileSafeAreaCoordinator", browserCs);
Assert.Contains("MobileAdBannerView", browserCs);
var downloadsCs = File.ReadAllText(Path.Combine(MobileProjectDir, "DownloadsPage.cs"));
Assert.Contains("MobileSafeAreaCoordinator", downloadsCs);
Assert.Contains("MobileAdBannerView", downloadsCs);
// 4. Obsolete API 전수 방어 검증 (DisplayAlert, Device.StartTimer, new Frame 미사용)
Assert.DoesNotContain("DisplayAlert(", browserCs.Replace("DisplayAlertAsync", ""));
Assert.DoesNotContain("Device.StartTimer", browserCs);
}
}

View file

@ -0,0 +1,76 @@
using System;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// Gate 195 ~ 198: 모바일 배터리 발열 가드 & 네이티브 햅틱 피드백 기계적 검증
/// </summary>
public class MobileBatteryGuardAndHapticGateTests
{
private class MockHapticService : IHapticFeedbackService
{
public int SuccessCount { get; private set; }
public int WarningCount { get; private set; }
public int ClickCount { get; private set; }
public void PerformClick() => ClickCount++;
public void PerformSuccess() => SuccessCount++;
public void PerformWarning() => WarningCount++;
}
[Fact]
public void Gate195_BatteryHealthGuard_PausesOnLowBattery_WhenDischarging()
{
// 배터리 15% (0.15), 방전 중 (충전기 미연결)
var shouldPause = BatteryHealthGuard.ShouldPauseDownload(
batteryLevel: 0.15,
isCharging: false,
lowBatteryThreshold: 0.20
);
Assert.True(shouldPause);
}
[Fact]
public void Gate196_BatteryHealthGuard_Allows_WhenCharging()
{
// 배터리 10% (0.10), 충전기 연결됨
var shouldPause = BatteryHealthGuard.ShouldPauseDownload(
batteryLevel: 0.10,
isCharging: true,
lowBatteryThreshold: 0.20
);
Assert.False(shouldPause);
}
[Fact]
public void Gate197_HapticFeedbackService_Invocation_Audit()
{
var haptic = new MockHapticService();
haptic.PerformClick();
haptic.PerformSuccess();
haptic.PerformSuccess();
haptic.PerformWarning();
Assert.Equal(1, haptic.ClickCount);
Assert.Equal(2, haptic.SuccessCount);
Assert.Equal(1, haptic.WarningCount);
}
[Fact]
public void Gate198_BatteryStatusReport_Message_Formatting()
{
var message = BatteryHealthGuard.GetStatusMessage(
batteryLevel: 0.12,
isCharging: false
);
Assert.Contains("12%", message);
Assert.Contains("일시 정지", message);
Assert.Contains("충전기", message);
}
}

View file

@ -234,4 +234,60 @@ public class MobileBrowserCoreTests : IDisposable
Assert.Equal(145.5, video.DurationSeconds);
Assert.Equal("https://video.example.com/thumb.jpg", video.ThumbnailUrl);
}
[Fact]
public void TabManager_FindTabByUrlOrHost_MatchesExactAndSubdomains()
{
var manager = new MobileTabManager();
var tab1 = manager.AddTab("https://m.youtube.com");
var tab2 = manager.AddTab("https://chzzk.naver.com");
// 1. Exact match
var foundExact = manager.FindTabByUrlOrHost("https://m.youtube.com");
Assert.NotNull(foundExact);
Assert.Equal(tab1.Id, foundExact!.Id);
// 2. Host match with different subdomain (www vs m)
var foundSub = manager.FindTabByUrlOrHost("https://www.youtube.com/watch?v=abc");
Assert.NotNull(foundSub);
Assert.Equal(tab1.Id, foundSub!.Id);
// 3. Unrelated domain returns null
var foundNone = manager.FindTabByUrlOrHost("https://m.sooplive.co.kr");
Assert.Null(foundNone);
// 4. Special internal schemes like paca://newtab do not match arbitrary external URLs
var foundNtp = manager.FindTabByUrlOrHost("paca://newtab");
Assert.NotNull(foundNtp);
Assert.True(foundNtp!.IsNewTabPage);
}
[Fact]
public void TabManager_OpenOrSwitchTab_ReusesExistingDomainTabOrNtp()
{
var manager = new MobileTabManager();
// Initial tab is paca://newtab
Assert.Equal(1, manager.Count);
Assert.True(manager.ActiveTab!.IsNewTabPage);
// Opening first URL replaces the empty initial NTP
var (opened1, isExisting1) = manager.OpenOrSwitchTab("https://m.youtube.com");
Assert.Equal(1, manager.Count);
Assert.False(isExisting1);
Assert.Equal("https://m.youtube.com", manager.ActiveTab?.Url);
// Opening a different domain creates a 2nd tab
var (opened2, isExisting2) = manager.OpenOrSwitchTab("https://m.naver.com");
Assert.Equal(2, manager.Count);
Assert.False(isExisting2);
Assert.Equal("https://m.naver.com", manager.ActiveTab?.Url);
// Re-opening youtube domain should NOT create a 3rd tab; it switches to the existing tab
var (switched, isExisting3) = manager.OpenOrSwitchTab("https://www.youtube.com/watch?v=xyz");
Assert.Equal(2, manager.Count);
Assert.True(isExisting3);
Assert.Equal(opened1.Id, switched.Id);
Assert.Equal(opened1.Id, manager.ActiveTab?.Id);
}
}

View file

@ -139,4 +139,75 @@ public class MobileCoreFeaturesGateTests
// 네트워크 단절로 멈췄던 항목이 자동으로 재개되어야 함
Assert.Equal(MobileQueueStatus.Downloading, item.Status);
}
[Fact]
public void Gate169_MobileDiscoveryEngine_ExpandedPlatforms_Audit()
{
// 모바일 8대 스피드 다이얼 및 주요 스트리밍 플랫폼 확장 감지 감사
var chzzk = MobileDiscoveryEngine.DetectPlatform("https://chzzk.naver.com/live/12345");
Assert.Equal("CHZZK", chzzk.PlatformName);
Assert.True(chzzk.IsSupported);
var soop = MobileDiscoveryEngine.DetectPlatform("https://m.sooplive.co.kr/play/abcdef");
Assert.Equal("SOOP", soop.PlatformName);
Assert.True(soop.IsSupported);
var afreeca = MobileDiscoveryEngine.DetectPlatform("https://play.afreecatv.com/streamer/999");
Assert.Equal("SOOP", afreeca.PlatformName);
Assert.True(afreeca.IsSupported);
var twitch = MobileDiscoveryEngine.DetectPlatform("https://m.twitch.tv/streamer");
Assert.Equal("Twitch", twitch.PlatformName);
Assert.True(twitch.IsSupported);
var instagram = MobileDiscoveryEngine.DetectPlatform("https://www.instagram.com/reel/C12345/");
Assert.Equal("Instagram", instagram.PlatformName);
Assert.True(instagram.IsSupported);
var tiktok = MobileDiscoveryEngine.DetectPlatform("https://www.tiktok.com/@user/video/1234567");
Assert.Equal("TikTok", tiktok.PlatformName);
Assert.True(tiktok.IsSupported);
var tiktokShort = MobileDiscoveryEngine.DetectPlatform("https://vt.tiktok.com/ZS12345/");
Assert.Equal("TikTok", tiktokShort.PlatformName);
Assert.True(tiktokShort.IsSupported);
var reddit = MobileDiscoveryEngine.DetectPlatform("https://www.reddit.com/r/funny/comments/xyz/clip/");
Assert.Equal("Reddit", reddit.PlatformName);
Assert.True(reddit.IsSupported);
var vreddit = MobileDiscoveryEngine.DetectPlatform("https://v.redd.it/abcdef123");
Assert.Equal("Reddit", vreddit.PlatformName);
Assert.True(vreddit.IsSupported);
var naverTv = MobileDiscoveryEngine.DetectPlatform("https://tv.naver.com/v/123456");
Assert.Equal("Naver TV", naverTv.PlatformName);
Assert.True(naverTv.IsSupported);
}
[Fact]
public void Gate170_MobileDiscoveryEngine_DirectMedia_QueryString_And_Extensions_Audit()
{
// 쿼리 스트링, 프래그먼트, 추가 확장자(.mkv 등) 포함 미디어 감지 감사
var mp4WithQuery = MobileDiscoveryEngine.DetectPlatform("https://cdn.example.com/stream.mp4?token=abc123&expire=9999");
Assert.Equal("Direct Media", mp4WithQuery.PlatformName);
Assert.True(mp4WithQuery.IsSupported);
var webmWithFragment = MobileDiscoveryEngine.DetectPlatform("https://cdn.example.com/clip.webm?key=val#t=10");
Assert.Equal("Direct Media", webmWithFragment.PlatformName);
Assert.True(webmWithFragment.IsSupported);
var mkvMedia = MobileDiscoveryEngine.DetectPlatform("https://cdn.example.com/movie.mkv");
Assert.Equal("Direct Media", mkvMedia.PlatformName);
Assert.True(mkvMedia.IsSupported);
var hlsWithAuth = MobileDiscoveryEngine.DetectPlatform("https://cdn.example.com/playlist.m3u8?auth=token123");
Assert.Equal("HLS Stream", hlsWithAuth.PlatformName);
Assert.True(hlsWithAuth.IsSupported);
var dashWithSession = MobileDiscoveryEngine.DetectPlatform("https://cdn.example.com/manifest.mpd?session=xyz");
Assert.Equal("DASH Stream", dashWithSession.PlatformName);
Assert.True(dashWithSession.IsSupported);
}
}

View file

@ -0,0 +1,39 @@
using System.IO;
using Paca.Mobile.Core;
using Paca.Tests.Infrastructure;
using Xunit;
namespace Paca.Tests.Mobile;
public class MobileDeepLinkTargetGateTests
{
[Fact]
public void Gate221_MobileDeepLinkTarget_DeliversAndConsumes_Action()
{
// 1. Consume empty
_ = MobileDeepLinkTarget.Consume();
// 2. Deliver valid paca URI
MobileDeepLinkTarget.Deliver("paca://download?url=https%3A%2F%2Ftest.com%2Fvideo.mp4");
// 3. Consume returns action
var action = MobileDeepLinkTarget.Consume();
Assert.NotNull(action);
var dl = Assert.IsType<PacaDownloadDeepLinkAction>(action);
Assert.Equal("https://test.com/video.mp4", dl.TargetUrl);
// 4. Second consume is null
Assert.Null(MobileDeepLinkTarget.Consume());
}
[Fact]
public void Gate222_MobileMainActivity_HasPacaSchemeIntentFilter()
{
var mainActivityPath = Path.Combine(SourceTree.Root, "src", "Paca.Mobile", "Platforms", "Android", "MainActivity.cs");
Assert.True(File.Exists(mainActivityPath), "MainActivity.cs must exist");
var code = File.ReadAllText(mainActivityPath);
Assert.Contains(@"DataScheme = ""paca""", code);
Assert.Contains(@"Intent.ActionView", code);
}
}

View file

@ -115,4 +115,18 @@ public class MobileDesignSystemGateTests
Assert.True(new FileInfo(splashPath).Length > 10000, "스플래시 파일 크기가 정상이어야 합니다.");
Assert.True(new FileInfo(emptyPath).Length > 10000, "빈 큐 일러스트 파일 크기가 정상이어야 합니다.");
}
[Fact]
public void Gate180_MobileThemeSystem_UltraCompact_And_DoubleBezel_Tokens_Audit()
{
// 스위스 미니멀리즘 초고밀도 컴팩트 디자인 토큰 및 더블 베젤 색상 규격 검증
Assert.Equal(34.0, MobileThemeSystem.CompactItemHeightDp);
Assert.Equal(56.0, MobileThemeSystem.CompactRowHeightDp);
Assert.Equal("#10111A", MobileThemeSystem.SurfaceShell);
Assert.Equal("#151624", MobileThemeSystem.SurfaceCore);
Assert.Equal("#23253A", MobileThemeSystem.BorderHairline);
Assert.Equal("#08080C", MobileThemeSystem.OLEDBackground);
Assert.Equal("#8B7CFF", MobileThemeSystem.AccentViolet);
Assert.Equal("#10B981", MobileThemeSystem.AccentEmerald);
}
}

View file

@ -0,0 +1,270 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// 데스크톱 Paca와 1:1 대응하는 모바일 히스토리, 즐겨찾기, 탭 관리자, 통합 허브 게이트 (Gate 161 ~ Gate 168)
/// </summary>
public class MobileDesktopParityBrowserGateTests
{
private static readonly string MobileProjectDir = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "src", "Paca.Mobile"));
[Fact]
public void Gate161_DesktopParitySideNavHub_Navigation_Audit()
{
var hub = new DesktopParitySideNavHub();
Assert.Equal(DesktopSideNavTab.Downloads, hub.ActiveTab);
hub.SelectTab(DesktopSideNavTab.History);
Assert.Equal(DesktopSideNavTab.History, hub.ActiveTab);
hub.SelectTab(DesktopSideNavTab.Bookmarks);
Assert.Equal(DesktopSideNavTab.Bookmarks, hub.ActiveTab);
}
[Fact]
public void Gate162_MobileBrowserBottomBarParity_Buttons_Audit()
{
var browserPageCs = File.ReadAllText(Path.Combine(MobileProjectDir, "BrowserPage.cs"));
// 하단 바 7대 핵심 버튼 검증 (뒤로, 앞으로, 홈, 북마크, 히스토리, 다운로드, 탭창)
Assert.Contains("ToolbarBookmarkBtn", browserPageCs);
Assert.Contains("ToolbarHistoryBtn", browserPageCs);
Assert.Contains("ToolbarDownloadsBtn", browserPageCs);
Assert.Contains("ToolbarTabsBtn", browserPageCs);
}
[Fact]
public void Gate163_MobileBookmarkManager_Parity_Audit()
{
var manager = new DesktopParityBookmarkManager();
var url = "https://github.com/videodownloader";
var title = "Paca Video Downloader";
Assert.False(manager.IsBookmarked(url));
// 북마크 토글 (추가)
bool added = manager.ToggleBookmark(url, title);
Assert.True(added);
Assert.True(manager.IsBookmarked(url));
Assert.Single(manager.Bookmarks);
Assert.Equal(title, manager.Bookmarks[0].Title);
// 북마크 토글 (제거)
bool removed = manager.ToggleBookmark(url, title);
Assert.False(removed);
Assert.False(manager.IsBookmarked(url));
Assert.Empty(manager.Bookmarks);
}
[Fact]
public void Gate164_MobileHistoryManager_Parity_Audit()
{
var history = new DesktopParityHistoryManager();
history.RecordVisit("https://youtube.com", "YouTube - Home");
history.RecordVisit("https://naver.com", "NAVER");
history.RecordVisit("https://chzzk.naver.com", "CHZZK Live");
Assert.Equal(3, history.Entries.Count);
// 검색
var searchResult = history.Search("naver");
Assert.Equal(2, searchResult.Count);
// 특정 항목 삭제
history.DeleteEntry("https://naver.com");
Assert.Equal(2, history.Entries.Count);
// 전체 삭제
history.ClearAll();
Assert.Empty(history.Entries);
}
[Fact]
public void Gate165_MobileTabManager_Parity_Audit()
{
var tabManager = new DesktopParityTabManager();
Assert.Single(tabManager.Tabs); // 기본 1개 탭 존재
Assert.NotNull(tabManager.ActiveTab);
// 새 탭 생성
var tab2 = tabManager.CreateTab("https://chzzk.naver.com", "CHZZK");
Assert.Equal(2, tabManager.Tabs.Count);
Assert.Equal(tab2.Id, tabManager.ActiveTab.Id);
// 탭 전환
tabManager.SwitchTab(tabManager.Tabs[0].Id);
Assert.Equal(tabManager.Tabs[0].Id, tabManager.ActiveTab.Id);
// 탭 닫기
tabManager.CloseTab(tab2.Id);
Assert.Single(tabManager.Tabs);
Assert.Equal(tabManager.Tabs[0].Id, tabManager.ActiveTab.Id);
}
[Fact]
public void Gate166_DownloadsPage_DesktopParitySideTabs_Audit()
{
var downloadsPageCs = File.ReadAllText(Path.Combine(MobileProjectDir, "DownloadsPage.cs"));
// 데스크톱과 동일한 사이드 세그먼트 탭(SideTabDownloads, SideTabHistory, SideTabBookmarks) 존재 검증
Assert.Contains("SideTabDownloads", downloadsPageCs);
Assert.Contains("SideTabHistory", downloadsPageCs);
Assert.Contains("SideTabBookmarks", downloadsPageCs);
}
[Fact]
public void Gate167_HistoryBookmarksPage_TabSwitching_Audit()
{
var historyPageCs = File.ReadAllText(Path.Combine(MobileProjectDir, "HistoryBookmarksPage.cs"));
// 방문 기록 & 즐겨찾기 스위처 버튼 검증
Assert.Contains("_tabHistoryBtn", historyPageCs);
Assert.Contains("_tabBookmarkBtn", historyPageCs);
}
[Fact]
public void Gate168_EndToEndDesktopParityFullCycle_Audit()
{
var bookmarkMgr = new DesktopParityBookmarkManager();
var historyMgr = new DesktopParityHistoryManager();
var tabMgr = new DesktopParityTabManager();
var hub = new DesktopParitySideNavHub();
// 1. 첫 탭에서 유튜브 접속 및 방문 기록 추가
tabMgr.ActiveTab.Navigate("https://youtube.com/watch?v=123", "Amazing 4K Video");
historyMgr.RecordVisit(tabMgr.ActiveTab.Url, tabMgr.ActiveTab.Title);
// 2. 즐겨찾기 등록
bookmarkMgr.ToggleBookmark(tabMgr.ActiveTab.Url, tabMgr.ActiveTab.Title);
Assert.True(bookmarkMgr.IsBookmarked(tabMgr.ActiveTab.Url));
// 3. 새 탭 열기
var tab2 = tabMgr.CreateTab("https://naver.com", "NAVER");
historyMgr.RecordVisit(tab2.Url, tab2.Title);
Assert.Equal(2, tabMgr.Tabs.Count);
// 4. 통합 허브에서 방문 기록과 북마크 확인
hub.SelectTab(DesktopSideNavTab.History);
Assert.Equal(DesktopSideNavTab.History, hub.ActiveTab);
Assert.Equal(2, historyMgr.Entries.Count);
hub.SelectTab(DesktopSideNavTab.Bookmarks);
Assert.Equal(DesktopSideNavTab.Bookmarks, hub.ActiveTab);
Assert.Single(bookmarkMgr.Bookmarks);
Assert.Equal("Amazing 4K Video", bookmarkMgr.Bookmarks[0].Title);
}
[Fact]
public void Gate177_MobileBrowserNavigationAndBackKeyLifecycle_Audit()
{
var browserPageCs = File.ReadAllText(Path.Combine(MobileProjectDir, "BrowserPage.cs"));
// 1. Android 하드웨어 Back 키 누름 시 앱 강제 종료 방지 및 웹뷰 뒤로가기/홈 복귀 생명주기 오버라이드 검증
Assert.Contains("protected override bool OnBackButtonPressed()", browserPageCs);
Assert.Contains("CanGoBack", browserPageCs);
Assert.Contains("GoBack()", browserPageCs);
// 2. 툴바 뒤로가기 버튼(‹)에 홈 복귀 폴백이 구현되어 있는지 검증
Assert.Contains("paca://newtab", browserPageCs);
// 3. 독립된 원터치 데스크톱/모바일 모드 토글 버튼 검증
Assert.Contains("_isDesktopMode", browserPageCs);
Assert.Contains("SetDesktopMode", browserPageCs);
}
[Fact]
public void Gate178_MobileWebFullscreenVideo_CustomView_Audit()
{
var customizerCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Platforms", "Android", "PacaAndroidWebViewCustomizer.cs"));
// HTML5 전체화면 비디오 재생 시 뷰 얹기/제거 및 가로모드 전환 생명주기 검증
Assert.Contains("OnShowCustomView", customizerCs);
Assert.Contains("OnHideCustomView", customizerCs);
Assert.Contains("DecorView", customizerCs);
Assert.Contains("RequestedOrientation", customizerCs);
}
[Fact]
public void Gate179_DesignpacaMinimalistUi_Audit()
{
var mobileProjCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Paca.Mobile.csproj"));
var splashSvg = File.ReadAllText(Path.Combine(MobileProjectDir, "Resources", "Splash", "splash.svg"));
var browserPageCs = File.ReadAllText(Path.Combine(MobileProjectDir, "BrowserPage.cs"));
// 1. .NET 기본 스플래시 및 파란색(#2563eb) 완전 제거, 딥 옵시디언 다크(#0A0A0E) 및 Paca 브랜드 스플래시 적용 검증
Assert.DoesNotContain("Color=\"#2563eb\"", mobileProjCs);
Assert.Contains("Color=\"#0A0A0E\"", mobileProjCs);
Assert.DoesNotContain("280.08446 H 195.99044", splashSvg); // .NET 글자 패스 삭제 확인
Assert.Contains("paca-splash-mark", splashSvg);
// 2. 툴바 버튼에서 42x42 네모 상자 Border 컨테이너 제거 및 보더리스 플랫 터치 타깃 검증
Assert.DoesNotContain("BackgroundColor = Color.FromArgb(\"#141420\"),", browserPageCs);
Assert.DoesNotContain("StrokeThickness = 0.5,", browserPageCs);
// 3. 네온 헤어라인 보더(Stroke) 남발 제거 검증
Assert.DoesNotContain("Stroke = Color.FromArgb(\"#8B7CFF\"),", browserPageCs); // 네온 바이올렛 스트로크
Assert.DoesNotContain("Stroke = Color.FromArgb(\"#34D399\"),", browserPageCs); // 네온 그린 스트로크
Assert.DoesNotContain("더블 베젤 앰비언트 글로우", browserPageCs);
// 4. 스피드 다이얼에서 억지 94px 카드 박스 컨테이너 및 내부 이중 박스 제거, 보더리스 플랫 레이아웃 검증
Assert.DoesNotContain("AddLuxurySpeedDialTile", browserPageCs);
Assert.Contains("AddMinimalSpeedDialItem", browserPageCs);
}
[Fact]
public void Gate180_NoEmoji_LucideIconSystem_Audit()
{
var appDir = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "src", "Paca.App"));
var mobileDir = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "src", "Paca.Mobile"));
var mobileCoreDir = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "src", "Paca.Mobile.Core"));
// 1. Lucide 폰트 에셋 존재 및 정상 용량 검증
var mobileFont = Path.Combine(mobileDir, "Resources", "Fonts", "lucide.ttf");
var appFont = Path.Combine(appDir, "Assets", "Fonts", "lucide.ttf");
Assert.True(File.Exists(mobileFont), "Paca.Mobile에 lucide.ttf가 존재해야 합니다.");
Assert.True(File.Exists(appFont), "Paca.App에 lucide.ttf가 존재해야 합니다.");
Assert.True(new FileInfo(mobileFont).Length > 500_000, "lucide.ttf는 500KB 이상의 온전한 폰트 파일이어야 합니다.");
Assert.True(new FileInfo(appFont).Length > 500_000, "lucide.ttf는 500KB 이상의 온전한 폰트 파일이어야 합니다.");
// 2. 모바일 및 모바일 코어 프로덕션 코드 이모지 0건 전면 척결 검증
var emojiRegex = new System.Text.RegularExpressions.Regex(@"[\uD83C-\uDBFF\uDC00-\uDFFF]");
var mobileFiles = Directory.GetFiles(mobileDir, "*.cs", SearchOption.AllDirectories)
.Concat(Directory.GetFiles(mobileDir, "*.xaml", SearchOption.AllDirectories))
.Concat(Directory.GetFiles(mobileCoreDir, "*.cs", SearchOption.AllDirectories))
.Where(f => !f.Contains("\\obj\\") && !f.Contains("\\bin\\"));
foreach (var file in mobileFiles)
{
var text = File.ReadAllText(file);
var match = emojiRegex.Match(text);
Assert.False(match.Success, $"모바일 프로덕션 파일({Path.GetFileName(file)})에 이모지가 잔류해선 안 됩니다: '{match.Value}'");
}
// 3. 데스크톱 앱 프로덕션 코드 이모지 0건 전면 척결 검증
var appFiles = Directory.GetFiles(appDir, "*.cs", SearchOption.AllDirectories)
.Concat(Directory.GetFiles(appDir, "*.xaml", SearchOption.AllDirectories))
.Where(f => !f.Contains("\\obj\\") && !f.Contains("\\bin\\") && !f.Contains("min.js"));
foreach (var file in appFiles)
{
var text = File.ReadAllText(file);
var match = emojiRegex.Match(text);
Assert.False(match.Success, $"데스크톱 프로덕션 파일({Path.GetFileName(file)})에 이모지가 잔류해선 안 됩니다: '{match.Value}'");
}
// 4. 공식 마스코트 스플래시 화면 무결성 검증
var splashSvg = File.ReadAllText(Path.Combine(mobileDir, "Resources", "Splash", "splash.svg"));
Assert.Contains("paca-splash-mark", splashSvg);
Assert.Contains("image/png;base64", splashSvg);
// 5. MobileIconSystem의 Lucide 폰트 패밀리 지정 검증
Assert.Equal("Lucide", MobileIconSystem.FontFamily);
}
}

View file

@ -0,0 +1,194 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// 데스크톱 Paca와 1:1 대응하는 모바일 다운로드 관리자, 필터링, 정렬, 정리 게이트 (Gate 153 ~ Gate 160)
/// </summary>
public class MobileDesktopParityDownloadGateTests
{
private static readonly string MobileProjectDir = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "src", "Paca.Mobile"));
[Fact]
public void Gate153_StatusAndMediaFilter_Parity_Audit()
{
var engine = new MobileDesktopParityFilterEngine();
var items = new List<MobileParityDownloadItem>
{
new("1", "Video Active", "https://ex.com/1.mp4", MobileParityStatus.Active, MobileParityMediaKind.Video, "MP4", 1024 * 1024 * 10, 0.5),
new("2", "Video Done", "https://ex.com/2.mp4", MobileParityStatus.Done, MobileParityMediaKind.Video, "MP4", 1024 * 1024 * 20, 1.0),
new("3", "Audio Done", "https://ex.com/3.mp3", MobileParityStatus.Done, MobileParityMediaKind.Audio, "MP3", 1024 * 1024 * 3, 1.0),
new("4", "Audio Failed", "https://ex.com/4.m4a", MobileParityStatus.Failed, MobileParityMediaKind.Audio, "M4A", 1024 * 500, 0.2)
};
// 1. 상태 필터: Done만
var doneOnly = engine.Filter(items, status: MobileParityStatusFilter.Done, mediaKind: MobileParityMediaFilter.All, format: "모든 포맷", hideSmall: false, query: "");
Assert.Equal(2, doneOnly.Count);
// 2. 미디어 필터: Audio만
var audioOnly = engine.Filter(items, status: MobileParityStatusFilter.All, mediaKind: MobileParityMediaFilter.Audio, format: "모든 포맷", hideSmall: false, query: "");
Assert.Equal(2, audioOnly.Count);
}
[Fact]
public void Gate154_FormatFilter_Parity_Audit()
{
var engine = new MobileDesktopParityFilterEngine();
var items = new List<MobileParityDownloadItem>
{
new("1", "HLS Live", "https://ex.com/live.m3u8", MobileParityStatus.Active, MobileParityMediaKind.Video, "HLS", 1024 * 1024 * 5, 0.3),
new("2", "DASH VoD", "https://ex.com/manifest.mpd", MobileParityStatus.Done, MobileParityMediaKind.Video, "DASH", 1024 * 1024 * 50, 1.0),
new("3", "WebM Clip", "https://ex.com/clip.webm", MobileParityStatus.Done, MobileParityMediaKind.Video, "WEBM", 1024 * 1024 * 15, 1.0)
};
var hlsItems = engine.Filter(items, MobileParityStatusFilter.All, MobileParityMediaFilter.All, format: "HLS", hideSmall: false, query: "");
Assert.Single(hlsItems);
Assert.Equal("HLS Live", hlsItems[0].Title);
var webmItems = engine.Filter(items, MobileParityStatusFilter.All, MobileParityMediaFilter.All, format: "WEBM", hideSmall: false, query: "");
Assert.Single(webmItems);
Assert.Equal("WebM Clip", webmItems[0].Title);
}
[Fact]
public void Gate155_SortEngine_Parity_Audit()
{
var engine = new MobileDesktopParitySortEngine();
var items = new List<MobileParityDownloadItem>
{
new("1", "Bravo", "url1", MobileParityStatus.Done, MobileParityMediaKind.Video, "MP4", 100, 0.2, DateTime.UtcNow.AddMinutes(-10)),
new("2", "Alpha", "url2", MobileParityStatus.Done, MobileParityMediaKind.Video, "MP4", 500, 0.9, DateTime.UtcNow.AddMinutes(-5)),
new("3", "Charlie", "url3", MobileParityStatus.Done, MobileParityMediaKind.Video, "MP4", 300, 0.5, DateTime.UtcNow)
};
// 최신순 (Newest)
var byNewest = engine.Sort(items, MobileParitySortCriteria.Newest);
Assert.Equal("Charlie", byNewest[0].Title);
// 크기순 (Largest)
var byLargest = engine.Sort(items, MobileParitySortCriteria.Largest);
Assert.Equal("Alpha", byLargest[0].Title); // 500
// 진행률순 (Progress)
var byProgress = engine.Sort(items, MobileParitySortCriteria.Progress);
Assert.Equal("Alpha", byProgress[0].Title); // 0.9
// 제목순 (Title)
var byTitle = engine.Sort(items, MobileParitySortCriteria.Title);
Assert.Equal("Alpha", byTitle[0].Title);
Assert.Equal("Charlie", byTitle[2].Title);
}
[Fact]
public void Gate156_SmallMediaFilter_Parity_Audit()
{
var engine = new MobileDesktopParityFilterEngine();
var items = new List<MobileParityDownloadItem>
{
new("1", "Small Preview", "url1", MobileParityStatus.Done, MobileParityMediaKind.Video, "MP4", 500 * 1024, 1.0), // 500KB
new("2", "Full Movie", "url2", MobileParityStatus.Done, MobileParityMediaKind.Video, "MP4", 10 * 1024 * 1024, 1.0) // 10MB
};
// 1MB 미만 숨김 활성화 시 10MB만 남아야 함
var filtered = engine.Filter(items, MobileParityStatusFilter.All, MobileParityMediaFilter.All, format: "모든 포맷", hideSmall: true, query: "");
Assert.Single(filtered);
Assert.Equal("Full Movie", filtered[0].Title);
}
[Fact]
public void Gate157_BatchActionManager_Parity_Audit()
{
var manager = new MobileParityBatchManager();
var items = new List<MobileParityDownloadItem>
{
new("1", "Active Item", "url1", MobileParityStatus.Active, MobileParityMediaKind.Video, "MP4", 100, 0.5),
new("2", "Done Item 1", "url2", MobileParityStatus.Done, MobileParityMediaKind.Video, "MP4", 100, 1.0),
new("3", "Done Item 2", "url3", MobileParityStatus.Done, MobileParityMediaKind.Video, "MP4", 100, 1.0),
new("4", "Failed Item", "url4", MobileParityStatus.Failed, MobileParityMediaKind.Video, "MP4", 100, 0.1)
};
// 1. 완료 정리 -> Done 항목만 제거
var afterClearDone = manager.ClearFinishedDownloads(items);
Assert.Equal(2, afterClearDone.Count);
Assert.DoesNotContain(afterClearDone, i => i.Status == MobileParityStatus.Done);
// 2. 전체 비우기 -> Active 제외하고 모두 제거
var afterClearAll = manager.ClearAllDownloads(items);
Assert.Single(afterClearAll);
Assert.Equal("Active Item", afterClearAll[0].Title);
}
[Fact]
public void Gate158_MobileDownloadCardActions_Parity_Audit()
{
var card = new MobileParityDownloadItem("1", "Test Video", "https://ex.com/video.mp4", MobileParityStatus.Done, MobileParityMediaKind.Video, "MP4", 1024, 1.0);
Assert.False(card.IsPinned);
// 상단 고정 토글
card.IsPinned = true;
Assert.True(card.IsPinned);
// 우선순위 조절
Assert.Equal(0, card.Priority);
card.PriorityUp();
Assert.Equal(1, card.Priority);
card.PriorityDown();
Assert.Equal(0, card.Priority);
}
[Fact]
public void Gate159_DownloadsPage_DesktopParity_UiElements_Audit()
{
var downloadsPageCs = File.ReadAllText(Path.Combine(MobileProjectDir, "DownloadsPage.cs"));
// 데스크톱 1:1 대응 UI 요소 검증
Assert.Contains("DownloadSearchBox", downloadsPageCs);
Assert.Contains("FilterAll", downloadsPageCs);
Assert.Contains("FilterActive", downloadsPageCs);
Assert.Contains("FilterDone", downloadsPageCs);
Assert.Contains("FilterFailed", downloadsPageCs);
Assert.Contains("FilterMediaAll", downloadsPageCs);
Assert.Contains("FilterMediaVideo", downloadsPageCs);
Assert.Contains("FilterMediaAudio", downloadsPageCs);
Assert.Contains("FormatFilterPicker", downloadsPageCs);
Assert.Contains("DownloadSortPicker", downloadsPageCs);
Assert.Contains("FilterHideSmallMedia", downloadsPageCs);
Assert.Contains("ClearFinishedDownloadsBtn", downloadsPageCs);
Assert.Contains("ClearAllDownloadsBtn", downloadsPageCs);
}
[Fact]
public void Gate160_EndToEndDesktopParityJourney_Audit()
{
var filter = new MobileDesktopParityFilterEngine();
var sorter = new MobileDesktopParitySortEngine();
var batch = new MobileParityBatchManager();
var list = new List<MobileParityDownloadItem>
{
new("1", "Nature 4K", "https://ex.com/n.mp4", MobileParityStatus.Done, MobileParityMediaKind.Video, "MP4", 50 * 1024 * 1024, 1.0, DateTime.UtcNow.AddHours(-1)),
new("2", "Nature 720p", "https://ex.com/n2.mp4", MobileParityStatus.Active, MobileParityMediaKind.Video, "MP4", 10 * 1024 * 1024, 0.4, DateTime.UtcNow),
new("3", "Background Music", "https://ex.com/bgm.mp3", MobileParityStatus.Done, MobileParityMediaKind.Audio, "MP3", 3 * 1024 * 1024, 1.0, DateTime.UtcNow.AddHours(-2)),
new("4", "Tiny Icon", "https://ex.com/icon.png", MobileParityStatus.Failed, MobileParityMediaKind.Other, "PNG", 50 * 1024, 0.1, DateTime.UtcNow)
};
// 1. 검색어로 필터링 ("Nature")
var searched = filter.Filter(list, MobileParityStatusFilter.All, MobileParityMediaFilter.All, "모든 포맷", false, "Nature");
Assert.Equal(2, searched.Count);
// 2. 크기순 정렬
var sorted = sorter.Sort(searched, MobileParitySortCriteria.Largest);
Assert.Equal("Nature 4K", sorted[0].Title);
// 3. 완료 정리 수행
var remaining = batch.ClearFinishedDownloads(list);
Assert.Equal(2, remaining.Count);
Assert.Contains(remaining, i => i.Title == "Nature 720p");
Assert.Contains(remaining, i => i.Title == "Tiny Icon");
}
}

View file

@ -0,0 +1,105 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Threading.Tasks;
using Paca.Server;
using Paca.Server.Gateway;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// Gate 139 ~ 143: 백그라운드 게이트웨이 상주 서비스, 신뢰 기기 원클릭 페어링 핸드셰이크, Windows 시작 등록 규격 검증
/// </summary>
public class MobileGatewayDaemonAndHandshakeGateTests
{
[Fact]
public void Gate139_TrustedDevicesStore_Add_And_Authorize()
{
var store = new TrustedDevicesStore();
Assert.Empty(store.GetTrustedDevices());
var device = new TrustedDevice(
DeviceId: "device-uuid-99",
DeviceName: "Galaxy-S24-Ultra",
Fingerprint: "FP-GALAXY-99",
IsApproved: true,
RegisteredAt: DateTimeOffset.UtcNow,
LastSeenAt: DateTimeOffset.UtcNow
);
store.RegisterDevice(device);
Assert.Single(store.GetTrustedDevices());
Assert.True(store.IsDeviceTrusted("device-uuid-99"));
Assert.False(store.IsDeviceTrusted("unknown-device"));
}
[Fact]
public void Gate140_OneClickPairing_Handshake_Resolves_Token()
{
var store = new TrustedDevicesStore();
var handshakeEngine = new OneClickPairingEngine(store, serverToken: "MASTER-PACA-TOKEN-SECRET");
// 1. 신규 기기 핸드셰이크 요청 (페어링 허용 모드)
var req = new PairingHandshakeRequest(
ClientDeviceId: "client-phone-01",
ClientDeviceName: "iPhone 16 Pro",
ClientFingerprint: "FINGERPRINT-IPHONE",
IsOneClickRequest: true
);
var resp = handshakeEngine.ProcessHandshake(req, autoApproveNewLanClients: true);
Assert.NotNull(resp);
Assert.True(resp.Success);
Assert.Equal("MASTER-PACA-TOKEN-SECRET", resp.IssuedToken);
Assert.True(store.IsDeviceTrusted("client-phone-01"));
// 2. 신규 기기 등록 불허 모드에서는 거절
var req2 = new PairingHandshakeRequest(
ClientDeviceId: "untrusted-stranger",
ClientDeviceName: "Unknown-Device",
ClientFingerprint: "FP-UNKNOWN",
IsOneClickRequest: true
);
var resp2 = handshakeEngine.ProcessHandshake(req2, autoApproveNewLanClients: false);
Assert.NotNull(resp2);
Assert.False(resp2.Success);
Assert.Null(resp2.IssuedToken);
}
[Fact]
public void Gate141_GatewayStartupConfig_Command_Arguments()
{
var config = new GatewayStartupConfig(
RunOnWindowsStartup: true,
StartMinimizedToTray: true,
AutoPortForwarding: true,
HttpPort: 52100
);
var args = config.ToCommandLineArgs();
Assert.Contains("--gateway-daemon", args);
Assert.Contains("--tray", args);
Assert.Contains("--port 52100", args);
var parsed = GatewayStartupConfig.ParseArgs(args);
Assert.True(parsed.StartMinimizedToTray);
Assert.Equal(52100, parsed.HttpPort);
}
[Fact]
public void Gate142_GatewayDaemon_Heartbeat_And_Status()
{
var daemon = new PacaGatewayDaemonState(
DeviceName: "Encep-Studio-PC",
HttpPort: 52100,
LanBeaconActive: true,
WanGatewayActive: true,
ConnectCode: "PACA-982143"
);
Assert.True(daemon.IsOperational);
Assert.Equal("PACA-982143", daemon.ConnectCode);
Assert.Equal("http://localhost:52100", daemon.LocalEndpoint);
}
}

View file

@ -0,0 +1,151 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// 직관적 다운로드 큐, 다운로드 파일 브라우징, 필터링, 정렬 및 일괄 정리 게이트 (Gate 146 ~ Gate 152)
/// </summary>
public class MobileIntuitiveUxGateTests
{
[Fact]
public void Gate146_MobileLibraryFileManager_Scan_Audit()
{
var manager = new MobileLibraryFileManager();
var fakeFiles = new List<LocalDownloadedFile>
{
new("v1.mp4", "/storage/emulated/0/Download/v1.mp4", 1024 * 1024 * 50, DateTime.UtcNow.AddMinutes(-10), LocalMediaType.Video),
new("a1.mp3", "/storage/emulated/0/Download/a1.mp3", 1024 * 1024 * 5, DateTime.UtcNow.AddMinutes(-5), LocalMediaType.Audio),
new("doc.pdf", "/storage/emulated/0/Download/doc.pdf", 1024 * 100, DateTime.UtcNow, LocalMediaType.Other)
};
manager.SetFiles(fakeFiles);
Assert.Equal(3, manager.Files.Count);
Assert.Equal("50.0 MB", fakeFiles[0].FormattedSize);
Assert.Equal("5.0 MB", fakeFiles[1].FormattedSize);
}
[Fact]
public void Gate147_MobileLibraryFilterEngine_Category_Audit()
{
var filter = new MobileLibraryFilterEngine();
var files = new List<LocalDownloadedFile>
{
new("vid1.mp4", "/path/vid1.mp4", 500, DateTime.UtcNow, LocalMediaType.Video),
new("vid2.webm", "/path/vid2.webm", 300, DateTime.UtcNow, LocalMediaType.Video),
new("song.mp3", "/path/song.mp3", 100, DateTime.UtcNow, LocalMediaType.Audio),
new("podcast.m4a", "/path/podcast.m4a", 150, DateTime.UtcNow, LocalMediaType.Audio)
};
var videos = filter.FilterByCategory(files, LibraryCategoryFilter.Video);
Assert.Equal(2, videos.Count);
Assert.All(videos, f => Assert.Equal(LocalMediaType.Video, f.Type));
var audios = filter.FilterByCategory(files, LibraryCategoryFilter.Audio);
Assert.Equal(2, audios.Count);
Assert.All(audios, f => Assert.Equal(LocalMediaType.Audio, f.Type));
var all = filter.FilterByCategory(files, LibraryCategoryFilter.All);
Assert.Equal(4, all.Count);
}
[Fact]
public void Gate148_MobileLibrarySortEngine_MultiCriteria_Audit()
{
var sorter = new MobileLibrarySortEngine();
var files = new List<LocalDownloadedFile>
{
new("Banana.mp4", "/path/b", 100, DateTime.UtcNow.AddHours(-2), LocalMediaType.Video),
new("Apple.mp4", "/path/a", 500, DateTime.UtcNow.AddHours(-1), LocalMediaType.Video),
new("Cherry.mp4", "/path/c", 200, DateTime.UtcNow, LocalMediaType.Video)
};
// 최신순 (DateDesc)
var byDate = sorter.Sort(files, LibrarySortCriteria.DateDesc);
Assert.Equal("Cherry.mp4", byDate[0].FileName);
Assert.Equal("Banana.mp4", byDate[2].FileName);
// 이름순 (NameAsc)
var byName = sorter.Sort(files, LibrarySortCriteria.NameAsc);
Assert.Equal("Apple.mp4", byName[0].FileName);
Assert.Equal("Cherry.mp4", byName[2].FileName);
// 크기순 (SizeDesc)
var bySize = sorter.Sort(files, LibrarySortCriteria.SizeDesc);
Assert.Equal("Apple.mp4", bySize[0].FileName); // 500
Assert.Equal("Banana.mp4", bySize[2].FileName); // 100
}
[Fact]
public void Gate149_MobileLibraryBatchManager_Cleanup_Audit()
{
var batch = new MobileLibraryBatchManager();
var files = new List<LocalDownloadedFile>
{
new("corrupt.part", "/path/corrupt.part", 0, DateTime.UtcNow, LocalMediaType.Other),
new("normal.mp4", "/path/normal.mp4", 1024, DateTime.UtcNow, LocalMediaType.Video),
new("empty.tmp", "/path/empty.tmp", 0, DateTime.UtcNow, LocalMediaType.Other)
};
var cleaned = batch.PurgeEmptyFiles(files);
Assert.Single(cleaned);
Assert.Equal("normal.mp4", cleaned[0].FileName);
}
[Fact]
public void Gate150_MobileQuickDownloadBar_ClipboardDetection_Audit()
{
var quickBar = new MobileQuickDownloadBar();
Assert.False(quickBar.TryDetectDownloadableMedia("안녕하세요 일반 텍스트입니다.", out _));
Assert.True(quickBar.TryDetectDownloadableMedia("이 영상 봐봐 https://youtu.be/dQw4w9WgXcQ 짱이야", out var extractedUrl));
Assert.Equal("https://youtu.be/dQw4w9WgXcQ", extractedUrl);
}
[Fact]
public void Gate151_DownloadsPage_HubModel_Audit()
{
// 큐 관리와 파일 보관함을 한 곳에서 탭으로 쉽게 전환하는 통합 허브 모델
var hub = new DownloadsHubModel();
Assert.Equal(DownloadsHubTab.Queue, hub.CurrentTab);
hub.SwitchTab(DownloadsHubTab.CompletedLibrary);
Assert.Equal(DownloadsHubTab.CompletedLibrary, hub.CurrentTab);
}
[Fact]
public void Gate152_EndToEndDownloadsAndLibraryJourneyAudit_Audit()
{
var qm = new MobileQueueManager();
var library = new MobileLibraryFileManager();
var filter = new MobileLibraryFilterEngine();
var sorter = new MobileLibrarySortEngine();
// 1. 큐에 비디오 및 오디오 다운로드 등록
var item1 = qm.Enqueue("https://example.com/v1.mp4", "Epic Nature 4K");
var item2 = qm.Enqueue("https://example.com/a1.m4a", "Chill Beats");
// 2. 다운로드 완료 시 보관함에 자동 등록
item1.Status = MobileQueueStatus.Completed;
item2.Status = MobileQueueStatus.Completed;
library.SetFiles(new List<LocalDownloadedFile>
{
new(item1.Title + ".mp4", "/storage/v1.mp4", 1024 * 1024 * 80, DateTime.UtcNow.AddMinutes(-2), LocalMediaType.Video),
new(item2.Title + ".m4a", "/storage/a1.m4a", 1024 * 1024 * 10, DateTime.UtcNow, LocalMediaType.Audio)
});
// 3. 비디오만 필터링
var videosOnly = filter.FilterByCategory(library.Files, LibraryCategoryFilter.Video);
Assert.Single(videosOnly);
Assert.Equal("Epic Nature 4K.mp4", videosOnly[0].FileName);
// 4. 최신순 정렬
var sorted = sorter.Sort(library.Files, LibrarySortCriteria.DateDesc);
Assert.Equal("Chill Beats.m4a", sorted[0].FileName);
}
}

View file

@ -0,0 +1,197 @@
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// Gate 120 ~ 125: 원클릭 LAN 자동 발견 및 비콘 핸드셰이크 기계적 검증
/// </summary>
public class MobileLanAutoDiscoveryGateTests
{
[Fact]
public void Gate120_BeaconMessage_Serialization_And_Parsing()
{
var msg = LanDiscoveryMessage.CreatePong(
deviceName: "Desktop-Encep",
endpoint: "192.168.0.10:52100",
pairingMode: true,
fingerprint: "PACA-TEST-FP"
);
var encoded = msg.ToPayload();
Assert.StartsWith("PACA_DISCOVERY|PONG|", encoded);
var parsed = LanDiscoveryMessage.Parse(encoded);
Assert.NotNull(parsed);
Assert.Equal(LanDiscoveryMessageType.Pong, parsed.Type);
Assert.Equal("Desktop-Encep", parsed.DeviceName);
Assert.Equal("192.168.0.10:52100", parsed.Endpoint);
Assert.True(parsed.IsPairingAllowed);
Assert.Equal("PACA-TEST-FP", parsed.Fingerprint);
}
[Fact]
public void Gate121_PingMessage_Creation()
{
var ping = LanDiscoveryMessage.CreatePing(clientDeviceName: "Galaxy-S24");
var payload = ping.ToPayload();
Assert.Equal("PACA_DISCOVERY|PING|Galaxy-S24", payload);
var parsed = LanDiscoveryMessage.Parse(payload);
Assert.NotNull(parsed);
Assert.Equal(LanDiscoveryMessageType.Ping, parsed.Type);
Assert.Equal("Galaxy-S24", parsed.DeviceName);
}
[Fact]
public void Gate122_Invalid_Or_Malformed_Beacon_Handled_Safely()
{
Assert.Null(LanDiscoveryMessage.Parse(null!));
Assert.Null(LanDiscoveryMessage.Parse(""));
Assert.Null(LanDiscoveryMessage.Parse("RANDOM_PACKET|TEST"));
Assert.Null(LanDiscoveryMessage.Parse("PACA_DISCOVERY|UNKNOWN"));
}
[Fact]
public void Gate123_LanDiscoveredDevice_Formatting()
{
var device = new DiscoveredLanDevice(
DeviceName: "My-Studio-PC",
Endpoint: "192.168.1.50:52100",
IsPairingAllowed: true,
Fingerprint: "FP123",
LastSeen: DateTimeOffset.UtcNow
);
Assert.Equal("http://192.168.1.50:52100", device.HttpBaseUrl);
Assert.True(device.IsAlive(TimeSpan.FromSeconds(5)));
}
[Fact]
public async Task Gate124_Mock_Prober_Discovers_Responding_Host()
{
var prober = new LanDiscoveryProber();
var fakePong = LanDiscoveryMessage.CreatePong("Encep-PC", "192.168.0.2:52100", true, "FP-001");
// 시뮬레이션 패킷 수신 처리
prober.HandleReceivedPayload(fakePong.ToPayload(), "192.168.0.2");
var devices = prober.GetActiveDevices();
Assert.Single(devices);
Assert.Equal("Encep-PC", devices[0].DeviceName);
Assert.Equal("192.168.0.2:52100", devices[0].Endpoint);
Assert.True(devices[0].IsPairingAllowed);
}
[Fact]
public void Gate125_Prober_Removes_Stale_Devices()
{
var prober = new LanDiscoveryProber();
var pong = LanDiscoveryMessage.CreatePong("Old-PC", "192.168.0.99:52100", false, "FP-OLD");
// 10초 전 시점으로 등록
prober.AddOrUpdate(new DiscoveredLanDevice(
DeviceName: pong.DeviceName,
Endpoint: pong.Endpoint,
IsPairingAllowed: pong.IsPairingAllowed,
Fingerprint: pong.Fingerprint,
LastSeen: DateTimeOffset.UtcNow.AddSeconds(-15)
));
prober.PruneStale(maxAge: TimeSpan.FromSeconds(10));
var devices = prober.GetActiveDevices();
Assert.Empty(devices);
}
[Fact]
public async Task Gate126_LanBeaconResponder_Responds_To_Ping_Correctly()
{
using var responder = new Paca.Server.Discovery.LanBeaconResponder(
deviceName: "Desktop-Workstation",
httpPort: 52100,
pairingAllowed: true,
fingerprint: "DESK-FP-999"
);
// 로컬 비콘 프로필 검증
var pong = responder.BuildPongMessage("192.168.1.20");
Assert.NotNull(pong);
Assert.Equal("Desktop-Workstation", pong.DeviceName);
Assert.Equal("192.168.1.20:52100", pong.Endpoint);
Assert.True(pong.IsPairingAllowed);
Assert.Equal("DESK-FP-999", pong.Fingerprint);
// PING 패킷 처리 검증
var pingPayload = LanDiscoveryMessage.CreatePing("Pixel-9").ToPayload();
var replyPayload = responder.ProcessIncomingPayload(pingPayload, "192.168.1.20");
Assert.NotNull(replyPayload);
var parsed = LanDiscoveryMessage.Parse(replyPayload);
Assert.NotNull(parsed);
Assert.Equal(LanDiscoveryMessageType.Pong, parsed.Type);
Assert.Equal("Desktop-Workstation", parsed.DeviceName);
}
[Fact]
public void Gate127_LanBeaconResponder_Does_Not_Echo_SenderIp_As_Server_Endpoint_Audit()
{
using var responder = new Paca.Server.Discovery.LanBeaconResponder(
deviceName: "Desktop-Studio",
httpPort: 5000,
pairingAllowed: true,
fingerprint: "DESK-FP-1"
);
// 모바일 클라이언트 IP가 192.168.1.88인 경우
var pingPayload = LanDiscoveryMessage.CreatePing("Galaxy-Phone").ToPayload();
var replyPayload = responder.ProcessIncomingPayload(pingPayload, "192.168.1.88");
Assert.NotNull(replyPayload);
var parsed = LanDiscoveryMessage.Parse(replyPayload);
Assert.NotNull(parsed);
// PONG 엔드포인트는 송신자(모바일)의 IP(192.168.1.88)가 아니라 0.0.0.0 또는 서버 바인딩이어야 함
Assert.NotEqual("192.168.1.88:5000", parsed.Endpoint);
Assert.Equal("0.0.0.0:5000", parsed.Endpoint);
// 모바일 프로버가 수신했을 때, 패킷을 보낸 데스크톱 IP(예: 192.168.1.10)로 올바르게 복원되는지 검증
var prober = new LanDiscoveryProber();
prober.HandleReceivedPayload(replyPayload, "192.168.1.10");
var devices = prober.GetActiveDevices();
Assert.Single(devices);
Assert.Equal("192.168.1.10:5000", devices[0].Endpoint);
Assert.Equal("http://192.168.1.10:5000", devices[0].HttpBaseUrl);
}
[Fact]
public void Gate128_LanDiscoveryProber_Resolves_0_0_0_0_And_ColonPort_To_RemoteIp_Audit()
{
var prober = new LanDiscoveryProber();
// 1) 0.0.0.0:5000 형태
var pongZero = LanDiscoveryMessage.CreatePong("PC-A", "0.0.0.0:5000", true, "FP-A");
prober.HandleReceivedPayload(pongZero.ToPayload(), "10.0.0.5");
// 2) :5000 형태 (IP 생략 콜론 포트)
var pongColon = LanDiscoveryMessage.CreatePong("PC-B", ":52100", true, "FP-B");
prober.HandleReceivedPayload(pongColon.ToPayload(), "10.0.0.6");
var devices = prober.GetActiveDevices();
Assert.Equal(2, devices.Count);
var devA = devices.First(d => d.DeviceName == "PC-A");
Assert.Equal("10.0.0.5:5000", devA.Endpoint);
var devB = devices.First(d => d.DeviceName == "PC-B");
Assert.Equal("10.0.0.6:52100", devB.Endpoint);
}
}

View file

@ -0,0 +1,82 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// 모바일 미디어 라이브러리 고속 무결성 검증 및 포맷 헤더 분석 게이트 (Gate 191 ~ Gate 194)
/// </summary>
public class MobileMediaIntegrityGateTests
{
[Fact]
public void Gate191_ValidateMediaHeader_Mp4_FtypBox_Audit()
{
// MP4 컨테이너: 0~3 offset box size, 4~7 offset 'ftyp'
var mp4ValidHeader = new byte[] { 0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6F, 0x6D };
Assert.True(MediaIntegrityVerifier.ValidateMediaHeader(mp4ValidHeader, ".mp4"));
Assert.True(MediaIntegrityVerifier.ValidateMediaHeader(mp4ValidHeader, "video.m4a"));
var invalidHeader = new byte[] { 0x00, 0x00, 0x00, 0x00, 0x41, 0x42, 0x43, 0x44 };
Assert.False(MediaIntegrityVerifier.ValidateMediaHeader(invalidHeader, ".mp4"));
}
[Fact]
public void Gate192_ValidateMediaHeader_Webm_Ebml_Audit()
{
// WebM / MKV EBML Header: 0x1A, 0x45, 0xDF, 0xA3
var webmHeader = new byte[] { 0x1A, 0x45, 0xDF, 0xA3, 0x9F, 0x42, 0x86, 0x81 };
Assert.True(MediaIntegrityVerifier.ValidateMediaHeader(webmHeader, ".webm"));
Assert.True(MediaIntegrityVerifier.ValidateMediaHeader(webmHeader, "sample.mkv"));
var corruptedHeader = new byte[] { 0x1A, 0x45, 0x00, 0x00 };
Assert.False(MediaIntegrityVerifier.ValidateMediaHeader(corruptedHeader, ".webm"));
}
[Fact]
public void Gate193_ValidateMediaHeader_Mp3_Id3_And_FrameSync_Audit()
{
// MP3 ID3v2: 'ID3' (0x49, 0x44, 0x33)
var id3Header = new byte[] { 0x49, 0x44, 0x33, 0x04, 0x00, 0x00, 0x00, 0x00 };
Assert.True(MediaIntegrityVerifier.ValidateMediaHeader(id3Header, ".mp3"));
// MP3 Sync Word: 0xFF, 0xFB
var syncHeader = new byte[] { 0xFF, 0xFB, 0x90, 0x64 };
Assert.True(MediaIntegrityVerifier.ValidateMediaHeader(syncHeader, ".mp3"));
var badMp3 = new byte[] { 0x00, 0x11, 0x22, 0x33 };
Assert.False(MediaIntegrityVerifier.ValidateMediaHeader(badMp3, ".mp3"));
}
[Fact]
public void Gate194_InspectIntegrity_FullPayload_Audit()
{
// 유효한 mp4 가짜 데이터 생성
var mp4Data = new byte[64];
mp4Data[4] = 0x66; mp4Data[5] = 0x74; mp4Data[6] = 0x79; mp4Data[7] = 0x70; // ftyp
var actualHash = Convert.ToHexString(SHA256.HashData(mp4Data)).ToLowerInvariant();
// 1. 체크섬 일치 및 헤더 유효
var okResult = MediaIntegrityVerifier.InspectIntegrity(mp4Data, "sample.mp4", actualHash);
Assert.True(okResult.IsValid);
Assert.True(okResult.ChecksumMatch);
Assert.Equal("mp4", okResult.Format);
Assert.Null(okResult.ErrorReason);
// 2. 체크섬 불일치
var mismatchResult = MediaIntegrityVerifier.InspectIntegrity(mp4Data, "sample.mp4", "0000000000000000000000000000000000000000000000000000000000000000");
Assert.False(mismatchResult.IsValid);
Assert.False(mismatchResult.ChecksumMatch);
Assert.Contains("체크섬", mismatchResult.ErrorReason);
// 3. 헤더 손상
var corruptedData = new byte[64]; // all zeros
var corruptResult = MediaIntegrityVerifier.InspectIntegrity(corruptedData, "sample.mp4");
Assert.False(corruptResult.IsValid);
Assert.Contains("헤더", corruptResult.ErrorReason);
}
}

View file

@ -0,0 +1,118 @@
using System.Linq;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Mobile;
public sealed class MobileMenuContractGateTests
{
[Fact]
public void MenuCoordinator_DomainContext_NormalizesHttpAndHttpsUrls()
{
// Assert domain extraction and security scheme
var domain1 = MobileMenuCoordinator.FormatDomainContext("https://www.youtube.com/watch?v=12345");
Assert.Equal("youtube.com", domain1);
Assert.True(MobileMenuCoordinator.IsSecureScheme("https://www.youtube.com/watch?v=12345"));
var domain2 = MobileMenuCoordinator.FormatDomainContext("paca://newtab");
Assert.Equal("PACA 새 탭", domain2);
Assert.False(MobileMenuCoordinator.IsSecureScheme("paca://newtab"));
}
[Fact]
public void MenuCoordinator_QuickControls_EnforcesFourPillarsAndActiveStates()
{
var context = new MobileMenuContext(
CurrentUrl: "https://example.com/article",
CurrentTitle: "Sample Article",
IsDesktopMode: true,
ThemeMode: MobileThemeMode.Dark,
IsBookmarked: true,
SniffedVideoCount: 3,
IsP2pConnected: true
);
var items = MobileMenuCoordinator.BuildMenuItems(context);
var quickItems = items.Where(i => i.Category == MobileMenuCategory.QuickControls).ToList();
// Miller's / Hick-Hyman Law: Exact 4 quick controls
Assert.Equal(4, quickItems.Count);
Assert.Contains(quickItems, i => i.ActionId == MobileMenuActionId.ToggleDesktopMode && i.IsActive);
Assert.Contains(quickItems, i => i.ActionId == MobileMenuActionId.ToggleThemeMode);
Assert.Contains(quickItems, i => i.ActionId == MobileMenuActionId.OpenInPrivateTab);
Assert.Contains(quickItems, i => i.ActionId == MobileMenuActionId.ShareAndCopyLink);
}
[Fact]
public void MenuCoordinator_BrowsingAndMedia_ContainsCoreActionsWithDynamicStatus()
{
var context = new MobileMenuContext(
CurrentUrl: "https://example.com/video",
CurrentTitle: "Sample Video",
IsDesktopMode: false,
ThemeMode: MobileThemeMode.Light,
IsBookmarked: true,
SniffedVideoCount: 2,
IsP2pConnected: false
);
var items = MobileMenuCoordinator.BuildMenuItems(context);
var browsingItems = items.Where(i => i.Category == MobileMenuCategory.BrowsingAndMedia).ToList();
Assert.Equal(4, browsingItems.Count);
var bookmarkItem = browsingItems.First(i => i.ActionId == MobileMenuActionId.ToggleBookmark);
Assert.True(bookmarkItem.IsActive);
Assert.Equal("북마크 해제", bookmarkItem.Title);
var p2pItem = browsingItems.First(i => i.ActionId == MobileMenuActionId.SendTabToDesktopP2P);
Assert.False(p2pItem.IsActive);
Assert.Contains("오프라인", p2pItem.Subtitle);
}
[Fact]
public void MenuCoordinator_IntelligenceAndSettings_ShowsSniffedBadgeAndSync()
{
var context = new MobileMenuContext(
CurrentUrl: "https://example.com/stream",
CurrentTitle: "Stream Page",
IsDesktopMode: false,
ThemeMode: MobileThemeMode.Dark,
IsBookmarked: false,
SniffedVideoCount: 5,
IsP2pConnected: true
);
var items = MobileMenuCoordinator.BuildMenuItems(context);
var intelItems = items.Where(i => i.Category == MobileMenuCategory.IntelligenceAndSettings).ToList();
var snifferItem = intelItems.FirstOrDefault(i => i.ActionId == MobileMenuActionId.OpenVideoSnifferList);
Assert.NotNull(snifferItem);
Assert.Equal("5", snifferItem.BadgeText);
var syncItem = intelItems.FirstOrDefault(i => i.ActionId == MobileMenuActionId.OpenSyncSettings);
Assert.NotNull(syncItem);
Assert.Contains("연결됨", syncItem.Subtitle);
}
[Fact]
public void MenuCoordinator_AllItems_HaveNonEmptyAutomationNamesForAccessibility()
{
var context = new MobileMenuContext(
CurrentUrl: "https://example.com",
CurrentTitle: "Example",
IsDesktopMode: false,
ThemeMode: MobileThemeMode.Dark,
IsBookmarked: false,
SniffedVideoCount: 0,
IsP2pConnected: true
);
var items = MobileMenuCoordinator.BuildMenuItems(context);
foreach (var item in items)
{
Assert.False(string.IsNullOrWhiteSpace(item.AutomationName), $"Item {item.ActionId} must have an AutomationName");
Assert.False(string.IsNullOrWhiteSpace(item.IconGlyph), $"Item {item.ActionId} must have an IconGlyph");
}
}
}

View file

@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using System.IO;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// Gate 147 ~ 150: 모바일 오프라인 미디어 라이브러리 & 스토리지 대시보드 기계적 검증
/// </summary>
public class MobileOfflineLibraryDashboardGateTests
{
[Fact]
public void Gate147_OfflineMediaItem_DisplayFormatting()
{
var item1 = new OfflineMediaItem(
Id: "media-1",
FileName: "RickRoll_4K.mp4",
SizeBytes: 104_857_600L, // 100 MB
LocalFilePath: "/data/user/0/com.paca.mobile/files/RickRoll_4K.mp4",
DownloadedAt: DateTimeOffset.UtcNow,
IsIntegrityVerified: true
);
Assert.Equal("100.0 MB", item1.DisplaySize);
Assert.True(item1.IsIntegrityVerified);
var item2 = new OfflineMediaItem(
Id: "media-2",
FileName: "LongVideo_HDR.mkv",
SizeBytes: 1_610_612_736L, // 1.5 GB
LocalFilePath: "/data/user/0/com.paca.mobile/files/LongVideo_HDR.mkv",
DownloadedAt: DateTimeOffset.UtcNow,
IsIntegrityVerified: true
);
Assert.Equal("1.5 GB", item2.DisplaySize);
}
[Fact]
public void Gate148_LocalStorageDashboard_Calculates_Usage_Percentages()
{
// 총 128 GB (137,438,953,472 B), 남은 용량 64 GB, Paca 사용 6.4 GB (5%)
long totalStorage = 128L * 1024 * 1024 * 1024;
long freeStorage = 64L * 1024 * 1024 * 1024;
long pacaStorage = (long)(totalStorage * 0.05); // 5%
var dashboard = LocalStorageDashboard.Calculate(totalStorage, freeStorage, pacaStorage);
Assert.Equal(5.0, Math.Round(dashboard.PacaUsagePercent, 1));
Assert.Equal(50.0, Math.Round(dashboard.FreePercent, 1));
Assert.Contains("Paca", dashboard.StorageSummaryText);
Assert.Contains("여유", dashboard.StorageSummaryText);
}
[Fact]
public void Gate149_OfflineLibraryManager_Filtering_And_Sorting()
{
var manager = new OfflineLibraryManager();
manager.RegisterMedia(new OfflineMediaItem("id-1", "Alpha.mp4", 50_000_000, "/path/1", DateTimeOffset.UtcNow.AddHours(-2), true));
manager.RegisterMedia(new OfflineMediaItem("id-2", "Beta.mp3", 10_000_000, "/path/2", DateTimeOffset.UtcNow.AddHours(-1), true));
manager.RegisterMedia(new OfflineMediaItem("id-3", "Gamma.mp4", 200_000_000, "/path/3", DateTimeOffset.UtcNow, true));
// 1. 최신순 정렬 검증 (Gamma -> Beta -> Alpha)
var recentList = manager.GetItems(MediaFilter.All, MediaSortOrder.NewestFirst);
Assert.Equal(3, recentList.Count);
Assert.Equal("Gamma.mp4", recentList[0].FileName);
Assert.Equal("Beta.mp3", recentList[1].FileName);
Assert.Equal("Alpha.mp4", recentList[2].FileName);
// 2. 용량 큰 순 정렬 검증 (Gamma(200MB) -> Alpha(50MB) -> Beta(10MB))
var sizeList = manager.GetItems(MediaFilter.All, MediaSortOrder.LargestFirst);
Assert.Equal("Gamma.mp4", sizeList[0].FileName);
Assert.Equal("Alpha.mp4", sizeList[1].FileName);
Assert.Equal("Beta.mp3", sizeList[2].FileName);
// 3. 오디오만 필터링 검증
var audioList = manager.GetItems(MediaFilter.AudioOnly, MediaSortOrder.NewestFirst);
Assert.Single(audioList);
Assert.Equal("Beta.mp3", audioList[0].FileName);
// 4. 비디오만 필터링 검증
var videoList = manager.GetItems(MediaFilter.VideoOnly, MediaSortOrder.NewestFirst);
Assert.Equal(2, videoList.Count);
Assert.DoesNotContain(videoList, x => x.FileName.EndsWith(".mp3"));
}
[Fact]
public void Gate150_OfflineLibraryManager_Deletion_Updates_Quota()
{
var manager = new OfflineLibraryManager();
manager.RegisterMedia(new OfflineMediaItem("id-1", "Video1.mp4", 100_000_000, "/path/1", DateTimeOffset.UtcNow, true));
manager.RegisterMedia(new OfflineMediaItem("id-2", "Video2.mp4", 50_000_000, "/path/2", DateTimeOffset.UtcNow, true));
Assert.Equal(150_000_000L, manager.TotalPacaBytes);
// id-1 삭제
var removed = manager.RemoveMedia("id-1");
Assert.True(removed);
Assert.Equal(50_000_000L, manager.TotalPacaBytes);
Assert.Single(manager.GetItems(MediaFilter.All, MediaSortOrder.NewestFirst));
}
}

View file

@ -0,0 +1,201 @@
using System;
using System.IO;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Mobile;
public class MobileOrganicSafeAreaAndScrollGateTests
{
private static readonly string RepoRoot = FindRepoRoot();
private static readonly string MobileCoreDir = Path.Combine(RepoRoot, "src", "Paca.Mobile.Core");
private static readonly string MobileDir = Path.Combine(RepoRoot, "src", "Paca.Mobile");
private static string FindRepoRoot()
{
var dir = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(dir))
{
if (File.Exists(Path.Combine(dir, "Paca.slnx")) || Directory.Exists(Path.Combine(dir, "src", "Paca.Mobile.Core")))
return dir;
var parent = Directory.GetParent(dir);
if (parent == null) break;
dir = parent.FullName;
}
return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
}
[Fact]
public void Gate186_ThemeColorParser_HexRgbNamedAndLuminance_Audit()
{
// 1. Hex parsing
Assert.True(MobileThemeColorParser.TryParseColor("#FF0000", out var red));
Assert.Equal(255, red.R);
Assert.Equal(0, red.G);
Assert.Equal(0, red.B);
Assert.True(MobileThemeColorParser.TryParseColor("#03C75A", out var naverGreen));
Assert.Equal(3, naverGreen.R);
Assert.Equal(199, naverGreen.G);
Assert.Equal(90, naverGreen.B);
// 2. Short hex #RGB
Assert.True(MobileThemeColorParser.TryParseColor("#0F0", out var shortGreen));
Assert.Equal(0, shortGreen.R);
Assert.Equal(255, shortGreen.G);
Assert.Equal(0, shortGreen.B);
// 3. RGB & RGBA formats
Assert.True(MobileThemeColorParser.TryParseColor("rgb(36, 41, 47)", out var githubDark));
Assert.Equal(36, githubDark.R);
Assert.Equal(41, githubDark.G);
Assert.Equal(47, githubDark.B);
Assert.True(MobileThemeColorParser.TryParseColor("rgba(255, 255, 255, 0.9)", out var whiteRgba));
Assert.Equal(255, whiteRgba.R);
Assert.Equal(255, whiteRgba.G);
Assert.Equal(255, whiteRgba.B);
// 4. Named colors
Assert.True(MobileThemeColorParser.TryParseColor("black", out var black));
Assert.Equal(0, black.R);
Assert.Equal(0, black.G);
Assert.Equal(0, black.B);
// 5. Invalid string handling (fallback)
Assert.False(MobileThemeColorParser.TryParseColor("invalid-not-a-color", out _));
Assert.False(MobileThemeColorParser.TryParseColor("", out _));
Assert.False(MobileThemeColorParser.TryParseColor(null, out _));
// 6. Luminance & isDark calculation
Assert.True(MobileThemeColorParser.IsDarkColor(black));
Assert.False(MobileThemeColorParser.IsDarkColor(whiteRgba));
Assert.True(MobileThemeColorParser.IsDarkColor(githubDark));
// 7. Adaptive UI palette generation
var palette = MobileThemeColorParser.CreateAdaptivePalette(naverGreen);
Assert.NotNull(palette);
Assert.False(palette.IsDarkBackground);
Assert.False(string.IsNullOrWhiteSpace(palette.StatusBarHex));
Assert.False(string.IsNullOrWhiteSpace(palette.CapsuleBackgroundHex));
Assert.False(string.IsNullOrWhiteSpace(palette.TextPrimaryHex));
}
[Fact]
public void Gate187_MobileScrollCoordinator_AutoHideAndRestoreLogic_Audit()
{
var coordinator = new MobileScrollCoordinator();
// 초기 상태는 항상 표시(Visible)
Assert.True(coordinator.IsTopBarVisible);
// 새 탭(NTP) 화면에서는 스크롤해도 항상 표시 유지
var ntpAction = coordinator.ProcessScroll(scrollY: 300, deltaY: 50, isNewTabPage: true, isInputFocused: false);
Assert.Equal(TopBarScrollAction.KeepVisible, ntpAction);
Assert.True(coordinator.IsTopBarVisible);
// URL 입력창에 포커스 중일 때도 항상 표시 유지
var focusAction = coordinator.ProcessScroll(scrollY: 300, deltaY: 50, isNewTabPage: false, isInputFocused: true);
Assert.Equal(TopBarScrollAction.KeepVisible, focusAction);
Assert.True(coordinator.IsTopBarVisible);
// 미세 스크롤 노이즈(이동량 < 15px) 무시
var noiseAction = coordinator.ProcessScroll(scrollY: 100, deltaY: 8, isNewTabPage: false, isInputFocused: false);
Assert.Equal(TopBarScrollAction.None, noiseAction);
Assert.True(coordinator.IsTopBarVisible);
// 아래로 유의미하게 스크롤 (deltaY > 20, scrollY > 80) -> 숨김(Hide) 트리거
var hideAction = coordinator.ProcessScroll(scrollY: 200, deltaY: 35, isNewTabPage: false, isInputFocused: false);
Assert.Equal(TopBarScrollAction.Hide, hideAction);
Assert.False(coordinator.IsTopBarVisible);
// 숨겨진 상태에서 계속 아래로 스크롤하면 중복 Hide 방지 (None)
var keepHideAction = coordinator.ProcessScroll(scrollY: 350, deltaY: 40, isNewTabPage: false, isInputFocused: false);
Assert.Equal(TopBarScrollAction.None, keepHideAction);
Assert.False(coordinator.IsTopBarVisible);
// 위로 스크롤 (deltaY < -15) -> 즉각 표시(Show) 트리거
var showAction = coordinator.ProcessScroll(scrollY: 300, deltaY: -25, isNewTabPage: false, isInputFocused: false);
Assert.Equal(TopBarScrollAction.Show, showAction);
Assert.True(coordinator.IsTopBarVisible);
// 숨겨진 상태에서 최상단 복귀 (scrollY <= 20) -> 강제 표시
coordinator.ProcessScroll(scrollY: 500, deltaY: 50, isNewTabPage: false, isInputFocused: false);
Assert.False(coordinator.IsTopBarVisible);
var topAction = coordinator.ProcessScroll(scrollY: 10, deltaY: -5, isNewTabPage: false, isInputFocused: false);
Assert.Equal(TopBarScrollAction.Show, topAction);
Assert.True(coordinator.IsTopBarVisible);
}
[Fact]
public void Gate188_OrganicSafeArea_ThemeSync_And_PlatformContracts_Audit()
{
var browserCs = File.ReadAllText(Path.Combine(MobileDir, "BrowserPage.cs"));
var customizerCs = File.ReadAllText(Path.Combine(MobileDir, "Platforms", "Android", "PacaAndroidWebViewCustomizer.cs"));
var statusBarHelperExists = File.Exists(Path.Combine(MobileDir, "Platforms", "Android", "PacaAndroidStatusBarHelper.cs"));
Assert.True(statusBarHelperExists, "PacaAndroidStatusBarHelper.cs 가 존재해야 합니다.");
// 1. DOM theme-color 메타 태그 추출 스크립트 계약 감사
Assert.Contains("meta[name=\\\"theme-color\\\"]", browserCs);
// 2. Android 상태바 색상 및 인셋 동기화 호출 감사
Assert.Contains("PacaAndroidStatusBarHelper.SetStatusBarTheme", browserCs);
// 3. Webview 네이티브 스크롤 리스너 연동 감사
Assert.Contains("IOnScrollChangeListener", customizerCs);
Assert.Contains("OnScrollChange", customizerCs);
Assert.Contains("MobileScrollCoordinator", browserCs);
}
[Fact]
public void Gate189_WindowInsets_Performance_And_NoLayoutLoop_Audit()
{
var statusBarHelperCs = File.ReadAllText(Path.Combine(MobileDir, "Platforms", "Android", "PacaAndroidStatusBarHelper.cs"));
// ANR 방지 헌법: OnGlobalLayoutListener 사용 엄격 금지 (뷰 레이아웃 트래버설마다 호출되어 UI 루퍼 스타베이션 및 10001ms ANR 유발)
Assert.DoesNotContain("IOnGlobalLayoutListener", statusBarHelperCs);
Assert.DoesNotContain("AddOnGlobalLayoutListener", statusBarHelperCs);
// 정답 아키텍처: AndroidX SetOnApplyWindowInsetsListener 사용 필수 (시스템 바/키보드 인셋 변동 시에만 배타적 트리거)
Assert.Contains("SetOnApplyWindowInsetsListener", statusBarHelperCs);
Assert.Contains("IOnApplyWindowInsetsListener", statusBarHelperCs);
}
[Fact]
public void Gate190_FloatingOverlayLayout_And_NoWebViewVerticalJolt_Audit()
{
var browserCs = File.ReadAllText(Path.Combine(MobileDir, "BrowserPage.cs"));
// 1. Full-Bleed 플로팅 오버레이 아키텍처: 상단 바 컨테이너(_topContainer)와 하단 툴바(_bottomContainer) 분리
Assert.Contains("_topContainer", browserCs);
Assert.Contains("LayoutOptions.Start", browserCs);
Assert.Contains("LayoutOptions.End", browserCs);
// 2. 웹뷰는 컨테이너 이동(Jolt) 금지: webViewNative를 위로 밀어올려 하단에 검은 틈을 만드는 버그 원천 차단
Assert.DoesNotContain("webViewNative.Animate().TranslationY(-height)", browserCs);
Assert.DoesNotContain("webViewNative?.Animate()?.TranslationY(-height)", browserCs);
Assert.Contains("webViewNative.TranslationY = 0f", browserCs);
// 3. 하단 GNB 툴바 동기화 애니메이션: 스크롤 다운 시 하단으로 슬라이드 아웃, 스크롤 업 시 0으로 복귀
Assert.Contains("bottomNative?.Animate()", browserCs);
Assert.Contains("TranslationY(bottomHeight)", browserCs);
}
[Fact]
public void Gate191_GnbFloatingPill_OverWebview_ZeroBlackGap_And_EncodingSanitization_Audit()
{
var browserCs = File.ReadAllText(Path.Combine(MobileDir, "BrowserPage.cs"));
var parserCs = File.ReadAllText(Path.Combine(MobileCoreDir, "VideoSnifferParser.cs"));
// 1. 하단 GNB는 웹페이지 위에 둥둥 떠 있는 투명 컨테이너 구조여야 함 (뒤의 검은 공간 완전 제거)
Assert.Contains("_bottomContainer.Children.Add(_bottomBar)", browserCs);
Assert.Contains("BackgroundColor = Colors.Transparent", browserCs);
// 2. 문자열 인코딩 및 타이틀/URL 새니타이저 연동 감사
Assert.Contains("MobileTextEncodingSanitizer.CleanDecodedString", browserCs);
Assert.Contains("MobileTextEncodingSanitizer.FormatUrlForDisplay", browserCs);
Assert.Contains("MobileTextEncodingSanitizer.CleanDecodedString", parserCs);
}
}

View file

@ -0,0 +1,81 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// RED TEAM ATTACK SUITE: P2P QR 페어링 및 미디어 무결성 적대적 공격 테스트
/// 1. 조작된 체크섬/토큰 주입 시 디코딩 거부
/// 2. 잘못된 프로토콜 프리픽스 및 악의적 URI 주입 방어
/// 3. 헤더 매직 바이트 위조(앞 4바이트만 맞고 바디가 절단된 Truncated 공격) 방어
/// </summary>
public class MobileP2pAndMediaAdversarialTests
{
[Fact]
public void RedTeam_QrPayload_ChecksumTampering_MustReject()
{
var payload = new P2pQrPayload
{
Host = "192.168.1.100",
Port = 52100,
Token = "token-xyz-123",
DeviceName = "Desktop-PC"
};
var encoded = P2pQrPayloadCodec.Encode(payload);
// 페이로드 중간의 토큰 1글자 변조
var tampered = encoded.Replace("token-xyz-123", "token-EVIL-666");
var success = P2pQrPayloadCodec.TryDecode(tampered, out var decoded);
Assert.False(success, "[Red Team] 체크섬과 본문 내용이 불일치하는 변조된 QR 페이로드는 거부되어야 합니다.");
Assert.Null(decoded);
}
[Fact]
public void RedTeam_QrPayload_InvalidPrefix_Or_CorruptedUri_MustReject()
{
Assert.False(P2pQrPayloadCodec.TryDecode("http://attacker.com/fake-pair", out _));
Assert.False(P2pQrPayloadCodec.TryDecode("paca://invalid?h=1&p=bad&sig=000", out _));
Assert.False(P2pQrPayloadCodec.TryDecode("", out _));
Assert.False(P2pQrPayloadCodec.TryDecode(null!, out _));
}
[Fact]
public void RedTeam_MediaIntegrity_TruncatedPayload_MustDetectCorruption()
{
// MP4 매직 'ftyp'는 존재하지만 크기가 6바이트로 잘린 비정상 스트림
var truncatedMp4 = new byte[] { 0x00, 0x00, 0x00, 0x20, 0x66, 0x74 };
Assert.False(MediaIntegrityVerifier.ValidateMediaHeader(truncatedMp4, ".mp4"));
// 0바이트 파일
var zeroByteResult = MediaIntegrityVerifier.InspectIntegrity(Array.Empty<byte>(), "video.mp4");
Assert.False(zeroByteResult.IsValid);
Assert.Contains("0바이트", zeroByteResult.ErrorReason);
// 확장자와 내용물 불일치 공격 (WebM 헤더를 가진 파일을 .mp4로 위장)
var webmHeader = new byte[] { 0x1A, 0x45, 0xDF, 0xA3, 0x00, 0x00, 0x00, 0x00 };
var spoofResult = MediaIntegrityVerifier.InspectIntegrity(webmHeader, "fake_video.mp4");
Assert.False(spoofResult.IsValid, "[Red Team] 확장자(.mp4)와 실제 컨테이너(WebM) 헤더가 불일치하면 무결성 검증이 실패해야 합니다.");
}
[Fact]
public void RedTeam_MediaIntegrity_Sha256Tampering_MustDetect()
{
var validMp4 = new byte[64];
validMp4[4] = 0x66; validMp4[5] = 0x74; validMp4[6] = 0x79; validMp4[7] = 0x70; // ftyp
var correctHash = Convert.ToHexString(SHA256.HashData(validMp4)).ToLowerInvariant();
var forgedHash = correctHash.Substring(0, 60) + "dead"; // 끝 4자리 위조
var result = MediaIntegrityVerifier.InspectIntegrity(validMp4, "test.mp4", forgedHash);
Assert.False(result.IsValid);
Assert.False(result.ChecksumMatch);
Assert.Contains("불일치", result.ErrorReason);
}
}

View file

@ -0,0 +1,127 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// Gate 167 ~ 170: PacaDrop 다중 미디어 배치 번들링 & 자동 스트리밍 언패커 기계적 검증
/// </summary>
public class MobilePacaDropBatchBundleGateTests
{
[Fact]
public void Gate167_BatchBundleManifest_Integrity()
{
var file1Data = Encoding.UTF8.GetBytes("Media Content 1 - 4K Video Stream");
var file2Data = Encoding.UTF8.GetBytes("Media Content 2 - 128k Audio Track");
var entries = new List<BundleEntryMetadata>
{
new("video1.mp4", file1Data.Length, Convert.ToHexString(SHA256.HashData(file1Data))),
new("audio1.mp3", file2Data.Length, Convert.ToHexString(SHA256.HashData(file2Data)))
};
var manifest = new BatchBundleManifest("bundle-001", entries, DateTimeOffset.UtcNow);
Assert.Equal("bundle-001", manifest.BundleId);
Assert.Equal(2, manifest.Entries.Count);
Assert.Equal(file1Data.Length + file2Data.Length, manifest.TotalBytes);
Assert.True(manifest.VerifyEntry("video1.mp4", file1Data));
Assert.False(manifest.VerifyEntry("video1.mp4", Encoding.UTF8.GetBytes("Corrupted")));
}
[Fact]
public async Task Gate168_BatchBundle_Pack_And_Unpack_Streaming()
{
var file1Data = Encoding.UTF8.GetBytes("Payload for Track 1");
var file2Data = Encoding.UTF8.GetBytes("Payload for Track 2");
using var bundleStream = new MemoryStream();
// 1. 번들 생성 (PC 측 동작 시뮬레이션)
await PacaBatchBundlePacker.PackAsync(bundleStream, new Dictionary<string, byte[]>
{
["intro.mp4"] = file1Data,
["outro.mp3"] = file2Data
});
bundleStream.Position = 0;
// 2. 모바일 언패커로 추출 (모바일 수신 시뮬레이션)
var tempExtractDir = Path.Combine(Path.GetTempPath(), "Paca_Test_Bundle_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempExtractDir);
try
{
var extractedFiles = await PacaBatchBundleExtractor.ExtractAsync(bundleStream, tempExtractDir);
Assert.Equal(2, extractedFiles.Count);
Assert.True(File.Exists(Path.Combine(tempExtractDir, "intro.mp4")));
Assert.True(File.Exists(Path.Combine(tempExtractDir, "outro.mp3")));
var read1 = await File.ReadAllBytesAsync(Path.Combine(tempExtractDir, "intro.mp4"));
var read2 = await File.ReadAllBytesAsync(Path.Combine(tempExtractDir, "outro.mp3"));
Assert.Equal(file1Data, read1);
Assert.Equal(file2Data, read2);
}
finally
{
if (Directory.Exists(tempExtractDir))
{
Directory.Delete(tempExtractDir, recursive: true);
}
}
}
[Fact]
public async Task Gate169_BatchBundle_CorruptedEntry_FailsSafely()
{
using var garbageStream = new MemoryStream(Encoding.UTF8.GetBytes("Not A Valid Paca Bundle Data"));
var tempExtractDir = Path.Combine(Path.GetTempPath(), "Paca_Corrupt_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempExtractDir);
try
{
await Assert.ThrowsAnyAsync<Exception>(async () =>
{
await PacaBatchBundleExtractor.ExtractAsync(garbageStream, tempExtractDir);
});
}
finally
{
if (Directory.Exists(tempExtractDir)) Directory.Delete(tempExtractDir, recursive: true);
}
}
[Fact]
public void Gate170_AutoIngestToLibraryManager()
{
var manager = new OfflineLibraryManager();
var extractedEntries = new List<BundleExtractedFile>
{
new("item-1", "video.mp4", 50_000_000, "/temp/video.mp4", true),
new("item-2", "song.mp3", 5_000_000, "/temp/song.mp3", true)
};
foreach (var entry in extractedEntries)
{
manager.RegisterMedia(new OfflineMediaItem(
entry.Id,
entry.FileName,
entry.SizeBytes,
entry.FilePath,
DateTimeOffset.UtcNow,
entry.IsChecksumVerified
));
}
Assert.Equal(55_000_000L, manager.TotalPacaBytes);
Assert.Equal(2, manager.GetItems(MediaFilter.All, MediaSortOrder.NewestFirst).Count);
}
}

View file

@ -156,7 +156,10 @@ public class MobilePipelineTests
[Fact]
public void ApiProtocol_MobileClientContract_MatchesServerEndpoints()
{
var apiClientCs = File.ReadAllText(Path.Combine(MobileProjectDir, "ApiClient.cs"));
var targetFile = File.Exists(Path.Combine(MobileCoreProjectDir, "ApiClient.cs"))
? Path.Combine(MobileCoreProjectDir, "ApiClient.cs")
: Path.Combine(MobileProjectDir, "ApiClient.cs");
var apiClientCs = File.ReadAllText(targetFile);
Assert.Contains("/api/queue", apiClientCs);
Assert.Contains("/api/pair", apiClientCs);
Assert.Contains("/api/library", apiClientCs);

View file

@ -0,0 +1,76 @@
using System;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// Gate 191 ~ 194: 모바일 플레이어 제스처 컨트롤 및 백그라운드 PIP 재생 세션 기계적 검증
/// </summary>
public class MobilePlayerGestureAndPipGateTests
{
[Theory]
[InlineData(100.0, 600, 1000, 60)] // 1000px 화면에서 오른쪽으로 100px 스와이프 -> 10분(600초) 영상 중 60초 앞으로 탐색
[InlineData(-50.0, 600, 1000, -30)] // 왼쪽으로 50px 스와이프 -> 30초 뒤로 탐색
[InlineData(0.0, 600, 1000, 0)] // 이동 없음
public void Gate191_PlayerGesture_Horizontal_Seek_Calculation(double deltaX, double totalDurationSec, double screenWidth, double expectedDeltaSec)
{
var total = TimeSpan.FromSeconds(totalDurationSec);
var seekDelta = PlayerGestureController.CalculateSeekDelta(deltaX, total, screenWidth);
Assert.Equal(expectedDeltaSec, Math.Round(seekDelta.TotalSeconds));
}
[Fact]
public void Gate192_PlayerGesture_Vertical_Brightness_And_Volume_Separation()
{
// 1. 좌측 영역 (X < 500 in 1000px 화면) -> 밝기 조절
var isLeftZone = PlayerGestureController.IsLeftZone(xPosition: 200, screenWidth: 1000);
Assert.True(isLeftZone);
// 위로 100px 스와이프 (screenHeight: 1000) -> 밝기 +0.1
var newBrightness = PlayerGestureController.CalculateLevelDelta(currentLevel: 0.5, deltaY: -100, screenHeight: 1000);
Assert.Equal(0.6, Math.Round(newBrightness, 2));
// 2. 우측 영역 (X > 500) -> 볼륨 조절
var isRightZone = !PlayerGestureController.IsLeftZone(xPosition: 800, screenWidth: 1000);
Assert.True(isRightZone);
// 최대치 1.0 초과 방지 클램프 검증
var clampedMax = PlayerGestureController.CalculateLevelDelta(currentLevel: 0.95, deltaY: -200, screenHeight: 1000);
Assert.Equal(1.0, clampedMax);
// 최소치 0.0 미만 방지 클램프 검증
var clampedMin = PlayerGestureController.CalculateLevelDelta(currentLevel: 0.05, deltaY: 200, screenHeight: 1000);
Assert.Equal(0.0, clampedMin);
}
[Fact]
public void Gate193_BackgroundPlaybackSession_Metadata_And_State()
{
var session = new BackgroundPlaybackSession(
TrackTitle: "Lo-Fi Coding Chill Beats",
ArtistName: "Paca Ambient Radio",
CurrentPosition: TimeSpan.FromMinutes(12),
TotalDuration: TimeSpan.FromMinutes(45),
IsPlaying: true
);
Assert.Equal("Lo-Fi Coding Chill Beats", session.TrackTitle);
Assert.True(session.IsPlaying);
// 일시정지 상태 전환
var paused = session.TogglePlayPause();
Assert.False(paused.IsPlaying);
}
[Theory]
[InlineData(33, true)] // Android 13 -> PIP 지원
[InlineData(26, true)] // Android 8.0 -> PIP 지원
[InlineData(25, false)] // Android 7.1 -> PIP 미지원
public void Gate194_PipCapability_Evaluation(int androidApiLevel, bool expectedSupported)
{
var isSupported = PipCapabilityInspector.IsPipSupported(androidApiLevel);
Assert.Equal(expectedSupported, isSupported);
}
}

View file

@ -0,0 +1,165 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Paca.Mobile;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// PACA Mobile 사생활 보호(InPrivate / Incognito) 세션 격리 및 수명주기 무결성 검증 게이트
/// </summary>
public class MobilePrivateBrowsingGateTests : IDisposable
{
private readonly string _testTempDir;
public MobilePrivateBrowsingGateTests()
{
_testTempDir = Path.Combine(Path.GetTempPath(), "paca_mobile_priv_tests_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_testTempDir);
}
public void Dispose()
{
try
{
if (Directory.Exists(_testTempDir))
Directory.Delete(_testTempDir, recursive: true);
}
catch { }
}
[Fact]
public void Gate1_MobilePrivateBrowsingPolicy_사생활보호_정책_단정_검증()
{
var privTab = new BrowserTab
{
Id = "priv-1",
Url = "https://confidential-portal.org",
IsPrivate = true
};
var normTab = new BrowserTab
{
Id = "norm-1",
Url = "https://public-news.org",
IsPrivate = false
};
// 1. 방문 기록 저장 차단 여부
Assert.False(MobilePrivateBrowsingPolicy.ShouldPersistHistory(privTab));
Assert.True(MobilePrivateBrowsingPolicy.ShouldPersistHistory(normTab));
Assert.False(MobilePrivateBrowsingPolicy.ShouldPersistHistory(null));
// 2. 디스크 쿠키 Flush 차단 여부 (RAM에만 유지)
Assert.False(MobilePrivateBrowsingPolicy.ShouldFlushCookies(privTab));
Assert.True(MobilePrivateBrowsingPolicy.ShouldFlushCookies(normTab));
Assert.False(MobilePrivateBrowsingPolicy.ShouldFlushCookies(null));
// 3. 디스크 캐시 및 양식 자동저장 차단 여부
Assert.False(MobilePrivateBrowsingPolicy.ShouldEnableDiskCache(privTab));
Assert.True(MobilePrivateBrowsingPolicy.ShouldEnableDiskCache(normTab));
Assert.False(MobilePrivateBrowsingPolicy.ShouldSaveFormData(privTab));
Assert.True(MobilePrivateBrowsingPolicy.ShouldSaveFormData(normTab));
// 4. 최근 앱 섬네일 스크린샷 방어(FLAG_SECURE) 활성화 여부
Assert.True(MobilePrivateBrowsingPolicy.ShouldEnableSecureWindow(privTab));
Assert.False(MobilePrivateBrowsingPolicy.ShouldEnableSecureWindow(normTab));
Assert.False(MobilePrivateBrowsingPolicy.ShouldEnableSecureWindow(null));
}
[Fact]
public void Gate2_MobileTabManager_시크릿탭_카운트_및_CloseAllPrivateTabs_검증()
{
var manager = new MobileTabManager();
// manager starts with 1 normal tab
Assert.Equal(1, manager.NormalTabCount);
Assert.Equal(0, manager.PrivateTabCount);
Assert.False(manager.HasPrivateTabs);
var priv1 = manager.AddTab("https://secret1.com", isPrivate: true);
var priv2 = manager.AddTab("https://secret2.com", isPrivate: true);
var norm2 = manager.AddTab("https://public2.com", isPrivate: false);
Assert.Equal(4, manager.Count);
Assert.Equal(2, manager.NormalTabCount);
Assert.Equal(2, manager.PrivateTabCount);
Assert.True(manager.HasPrivateTabs);
bool allPrivateClosedFired = false;
manager.AllPrivateTabsClosed += () => allPrivateClosedFired = true;
// 모든 시크릿 탭 일괄 닫기
manager.CloseAllPrivateTabs();
Assert.True(allPrivateClosedFired, "AllPrivateTabsClosed 이벤트가 발생해야 합니다.");
Assert.Equal(2, manager.Count);
Assert.Equal(2, manager.NormalTabCount);
Assert.Equal(0, manager.PrivateTabCount);
Assert.False(manager.HasPrivateTabs);
Assert.All(manager.Tabs, t => Assert.False(t.IsPrivate));
}
[Fact]
public void Gate3_마지막_시크릿탭_개별_종료_시_AllPrivateTabsClosed_이벤트_발생_검증()
{
var manager = new MobileTabManager();
var priv1 = manager.AddTab("https://secret-only.com", isPrivate: true);
BrowserTab? closedTab = null;
manager.TabClosed += t => closedTab = t;
bool allPrivateClosedFired = false;
manager.AllPrivateTabsClosed += () => allPrivateClosedFired = true;
manager.CloseTab(priv1.Id);
Assert.NotNull(closedTab);
Assert.Equal(priv1.Id, closedTab!.Id);
Assert.True(allPrivateClosedFired, "마지막 시크릿 탭이 닫히면 AllPrivateTabsClosed가 발행되어야 합니다.");
Assert.False(manager.HasPrivateTabs);
}
[Fact]
public void Gate4_MobilePrivateSessionManager_세션_종료_시_플랫폼_정화_훅_연동_검증()
{
bool cacheCleared = false;
bool cookiesCleared = false;
bool formDataCleared = false;
bool webStorageCleared = false;
var sessionManager = new MobilePrivateSessionManager(
clearCache: () => cacheCleared = true,
clearCookies: () => cookiesCleared = true,
clearFormData: () => formDataCleared = true,
clearWebStorage: () => webStorageCleared = true
);
sessionManager.PurgePrivateSessionData();
Assert.True(cacheCleared, "캐시 정화 훅이 실행되어야 합니다.");
Assert.True(cookiesCleared, "쿠키 정화 훅이 실행되어야 합니다.");
Assert.True(formDataCleared, "폼 데이터 정화 훅이 실행되어야 합니다.");
Assert.True(webStorageCleared, "웹 스토리지 정화 훅이 실행되어야 합니다.");
}
[Fact]
public async Task Gate5_MobileTabManager_세션_영속화_시_시크릿탭_누출_원천_차단()
{
var manager = new MobileTabManager();
manager.AddTab("https://public-portal.com", isPrivate: false);
manager.AddTab("https://confidential-bank-account.org/dashboard", isPrivate: true);
var sessionFile = Path.Combine(_testTempDir, "session_test.json");
await manager.SaveSessionAsync(sessionFile);
Assert.True(File.Exists(sessionFile));
var rawJson = await File.ReadAllTextAsync(sessionFile);
// 시크릿 URL이 디스크 파일에 일체 기록되지 않아야 함
Assert.DoesNotContain("confidential-bank-account", rawJson);
Assert.Contains("public-portal.com", rawJson);
}
}

View file

@ -0,0 +1,102 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// Gate 133 ~ 138: PacaDrop 초고속 미디어 전송 및 Send-to-PC 프로덕션 경험 기계적 검증
/// </summary>
public class MobileProductionFeaturesGateTests
{
[Fact]
public void Gate133_PacaDrop_TransferManifest_Integrity()
{
var rawData = Encoding.UTF8.GetBytes("PacaDrop-Binary-Content-Payload-Stream-Test-Data");
var sha256 = Convert.ToHexString(SHA256.HashData(rawData));
var manifest = new PacaDropManifest(
FileId: "media-file-99",
FileName: "BigBuckBunny_4K.mp4",
TotalBytes: rawData.Length,
Sha256Checksum: sha256,
MimeType: "video/mp4",
CreatedAt: DateTimeOffset.UtcNow
);
Assert.Equal("BigBuckBunny_4K.mp4", manifest.FileName);
Assert.Equal(rawData.Length, manifest.TotalBytes);
Assert.True(manifest.VerifyChecksum(rawData));
// 위조되거나 잘린 데이터 검증 실패 확인
var corruptedData = Encoding.UTF8.GetBytes("Corrupted-Data");
Assert.False(manifest.VerifyChecksum(corruptedData));
}
[Fact]
public void Gate134_PacaDropTransfer_Progress_And_Speed_Tracking()
{
var tracker = new PacaDropProgressTracker(totalBytes: 100_000_000L); // 100MB
Assert.Equal(0, tracker.Percent);
Assert.False(tracker.IsCompleted);
// 50MB 수신 보고
tracker.UpdateProgress(transferredBytes: 50_000_000L);
Assert.Equal(50, (int)tracker.Percent);
Assert.False(tracker.IsCompleted);
// 100MB 완료 보고
tracker.UpdateProgress(transferredBytes: 100_000_000L);
Assert.Equal(100, (int)tracker.Percent);
Assert.True(tracker.IsCompleted);
}
[Fact]
public void Gate135_SendToPc_SmartDispatcher_Extracts_And_Enriches()
{
var dispatcher = new SendToPcDispatcher();
var rawShareText = "Check this amazing video out! https://youtu.be/dQw4w9WgXcQ?si=abcdef [Sent from Mobile]";
var request = dispatcher.PrepareRequest(rawShareText, preferredQuality: "4K");
Assert.NotNull(request);
Assert.Equal("https://youtu.be/dQw4w9WgXcQ?si=abcdef", request.NormalizedUrl);
Assert.Equal("YouTube", request.DetectedPlatform);
Assert.Equal("4K", request.PreferredQuality);
Assert.True(request.IsReadyForEnqueue);
}
[Fact]
public void Gate136_SendToPc_Rejects_Empty_Or_Non_Url()
{
var dispatcher = new SendToPcDispatcher();
Assert.Null(dispatcher.PrepareRequest("Just random chat message without any link"));
Assert.Null(dispatcher.PrepareRequest(""));
Assert.Null(dispatcher.PrepareRequest(null!));
}
[Fact]
public void Gate137_PositionSyncStore_Persists_And_Resolves_Accurately()
{
var store = new MobilePlaybackPositionStore();
store.RecordPosition("item-101", 145.5); // 2분 25.5초 지점
Assert.Equal(145.5, store.GetPosition("item-101"));
Assert.Equal(0.0, store.GetPosition("non-existent-item"));
// 유효하지 않은 음수 위치 방어
store.RecordPosition("item-101", -5.0);
Assert.Equal(0.0, store.GetPosition("item-101"));
}
[Fact]
public void Gate138_OfflineStorageQuotaGuard_Evaluates_Available_Space()
{
var guard = new OfflineStorageQuotaGuard(maxQuotaBytes: 1_000_000_000L); // 1GB 한도
Assert.True(guard.CanStoreItem(itemSizeBytes: 200_000_000L, currentUsageBytes: 300_000_000L)); // 500MB < 1GB
Assert.False(guard.CanStoreItem(itemSizeBytes: 800_000_000L, currentUsageBytes: 300_000_000L)); // 1.1GB > 1GB
}
}

View file

@ -0,0 +1,55 @@
using System.IO;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Mobile;
public class MobileQrPairingUiGateTests
{
private static readonly string SolutionRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
[Fact]
public void Gate_QrPayloadCodec_Roundtrip_EnsuresIntegrity()
{
var original = new P2pQrPayload
{
Host = "192.168.1.100",
Port = 52100,
Token = "secret-token-xyz-12345",
DeviceName = "MyDesktop-PC",
Fingerprint = "fp-abc"
};
var encoded = P2pQrPayloadCodec.Encode(original);
Assert.NotNull(encoded);
Assert.StartsWith("paca://pair?", encoded);
var decoded = P2pQrPayloadCodec.Decode(encoded);
Assert.NotNull(decoded);
Assert.Equal(original.Host, decoded.Host);
Assert.Equal(original.Port, decoded.Port);
Assert.Equal(original.Token, decoded.Token);
Assert.Equal(original.DeviceName, decoded.DeviceName);
}
[Fact]
public void Gate_QrPayloadCodec_RejectsCorruptOrInvalidQr()
{
Assert.Throws<ArgumentException>(() => P2pQrPayloadCodec.Decode(""));
Assert.Throws<ArgumentException>(() => P2pQrPayloadCodec.Decode("INVALID_QR_FORMAT"));
Assert.Throws<InvalidOperationException>(() => P2pQrPayloadCodec.Decode("paca://pair?h=127.0.0.1&p=5000&t=tok&sig=wrong"));
}
[Fact]
public void Gate_SyncSettingsPage_Contains_QrScanButton_And_CodecWiring()
{
var syncPagePath = Path.Combine(SolutionRoot, "src", "Paca.Mobile", "SyncSettingsPage.cs");
Assert.True(File.Exists(syncPagePath), $"SyncSettingsPage.cs not found at {syncPagePath}");
var code = File.ReadAllText(syncPagePath);
Assert.True(code.Contains("P2pQrPayloadCodec.Decode") || code.Contains("P2pQrPayloadCodec"),
"SyncSettingsPage.cs must wire P2pQrPayloadCodec to decode QR scan results");
Assert.True(code.Contains("QR") || code.Contains("Qr"),
"SyncSettingsPage.cs must provide a QR Scan pairing interaction element");
}
}

View file

@ -0,0 +1,672 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Paca.Mobile;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Mobile;
public class MobileResponsivenessAndAnrPreventionGateTests
{
private static readonly string RepoRoot = FindRepoRoot();
private static readonly string MobileCoreDir = Path.Combine(RepoRoot, "src", "Paca.Mobile.Core");
private static readonly string MobileDir = Path.Combine(RepoRoot, "src", "Paca.Mobile");
private static string FindRepoRoot()
{
var dir = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(dir))
{
if (File.Exists(Path.Combine(dir, "Paca.slnx")) || Directory.Exists(Path.Combine(dir, "src", "Paca.Mobile.Core")))
return dir;
var parent = Directory.GetParent(dir);
if (parent == null) break;
dir = parent.FullName;
}
return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
}
[Fact]
public void Gate190_InteractionDebouncer_PreventsRapidDoubleTap_AndAllowsAfterCooldown()
{
MobileInteractionDebouncer.Reset();
// First tap succeeds
Assert.True(MobileInteractionDebouncer.TryBeginInteraction(cooldownMs: 350));
// Immediate subsequent tap (within 350ms) is blocked to prevent concurrent modal pushes / ANR
Assert.False(MobileInteractionDebouncer.TryBeginInteraction(cooldownMs: 350));
// After cooldown, interaction is allowed again
Thread.Sleep(360);
Assert.True(MobileInteractionDebouncer.TryBeginInteraction(cooldownMs: 350));
}
[Fact]
public void Gate191_ScrollThrottler_EnforcesRateLimit_AndFiltersHighFrequencyNoise()
{
var throttler = new MobileScrollThrottler(minIntervalMs: 16, minDeltaThreshold: 10);
// 1. Initial scroll event passes
Assert.True(throttler.ShouldProcess(scrollY: 50, deltaY: 20));
// 2. High-frequency tick within 16ms window is throttled
Assert.False(throttler.ShouldProcess(scrollY: 52, deltaY: 2));
// 3. Tick with negligible delta (< minDeltaThreshold) is ignored
Thread.Sleep(20);
Assert.False(throttler.ShouldProcess(scrollY: 53, deltaY: 1));
// 4. Meaningful scroll after frame window passes
Thread.Sleep(20);
Assert.True(throttler.ShouldProcess(scrollY: 85, deltaY: 32));
// 5. Scroll to top (scrollY <= 20) always forces pass regardless of delta
Assert.True(throttler.ShouldProcess(scrollY: 5, deltaY: -5));
}
[Fact]
public void Gate192_SafeAreaCoordinator_DeadbandHysteresis_RejectsSubpixelJitter()
{
var coordinator = new MobileSafeAreaCoordinator();
int eventCount = 0;
coordinator.InsetsChanged += () => eventCount++;
// 1. Initial non-zero update fires event
coordinator.UpdateInsets(bottomDp: 48.0, topDp: 42.0, keyboardDp: 0.0);
Assert.Equal(1, eventCount);
Assert.Equal(48.0, coordinator.BottomInsetDp);
Assert.Equal(42.0, coordinator.TopInsetDp);
// 2. Subpixel jitter (< 1.0dp difference) must NOT fire event (prevents infinite layout loops)
coordinator.UpdateInsets(bottomDp: 48.3, topDp: 42.2, keyboardDp: 0.0);
Assert.Equal(1, eventCount); // Still 1! No new event fired
// 3. Significant inset change (>= 1.0dp) must fire event
coordinator.UpdateInsets(bottomDp: 0.0, topDp: 42.0, keyboardDp: 0.0);
Assert.Equal(2, eventCount);
Assert.Equal(0.0, coordinator.BottomInsetDp);
// 4. Keyboard open (IME insets) fires event
coordinator.UpdateInsets(bottomDp: 0.0, topDp: 42.0, keyboardDp: 280.0);
Assert.Equal(3, eventCount);
Assert.Equal(280.0, coordinator.KeyboardInsetDp);
}
[Fact]
public void Gate193_PlatformSourceAudit_ProhibitsHighFrequencyLoggingInScrollAndLayout()
{
var customizerCs = File.ReadAllText(Path.Combine(MobileDir, "Platforms", "Android", "PacaAndroidWebViewCustomizer.cs"));
var browserCs = File.ReadAllText(Path.Combine(MobileDir, "BrowserPage.cs"));
// Native scroll listener must NOT log verbose lines on every 120Hz tick
Assert.DoesNotContain("Log.Info(\"PacaTrace\", $\">>> NativeScroll", customizerCs);
// BrowserPage AnimateTopBar must protect against re-entrancy and layout oscillation
Assert.Contains("show == !_isTopBarHidden || _isTopBarAnimating", browserCs);
// Menu button and quick actions must employ interaction debouncing
Assert.Contains("MobileInteractionDebouncer.TryBeginInteraction", browserCs);
}
[Fact]
public async Task Gate194_StoreSaveConcurrency_SemaphoreSlimProtection_PreventsIoCollision()
{
var tempFile = Path.Combine(Path.GetTempPath(), $"paca_history_test_{Guid.NewGuid():N}.json");
try
{
var store = new MobileHistoryStore(tempFile);
// 30 concurrent rapid visit writes
var tasks = new Task[30];
for (int i = 0; i < 30; i++)
{
int index = i;
tasks[i] = Task.Run(async () =>
{
await store.AddVisitAsync($"https://example.com/item/{index}", $"Item {index}");
});
}
await Task.WhenAll(tasks);
var items = store.GetRecent(50);
Assert.Equal(30, items.Count);
}
finally
{
try { if (File.Exists(tempFile)) File.Delete(tempFile); } catch { }
try { if (File.Exists(tempFile + ".tmp")) File.Delete(tempFile + ".tmp"); } catch { }
}
}
[Fact]
public async Task Gate195_BookmarkStore_SaveConcurrency_SemaphoreSlimProtection()
{
var tempFile = Path.Combine(Path.GetTempPath(), $"paca_bookmark_test_{Guid.NewGuid():N}.json");
try
{
var store = new MobileBookmarkStore(tempFile);
// 30 concurrent bookmark additions
var tasks = new Task[30];
for (int i = 0; i < 30; i++)
{
int index = i;
tasks[i] = Task.Run(async () =>
{
await store.AddOrUpdateBookmarkAsync($"https://example.com/b/{index}", $"Bookmark {index}");
});
}
await Task.WhenAll(tasks);
var all = store.GetAll();
Assert.Equal(30, all.Count);
}
finally
{
try { if (File.Exists(tempFile)) File.Delete(tempFile); } catch { }
try { if (File.Exists(tempFile + ".tmp")) File.Delete(tempFile + ".tmp"); } catch { }
}
}
[Fact]
public void Gate196_AllToolbarButtons_AndActions_CoveredByDebouncer()
{
var browserCs = File.ReadAllText(Path.Combine(MobileDir, "BrowserPage.cs"));
var menuCs = File.ReadAllText(Path.Combine(MobileDir, "MobileMenuModal.cs"));
// Toolbar buttons must check debouncer
Assert.Contains("ToolbarTabsBtn", browserCs);
Assert.Contains("MobileInteractionDebouncer.TryBeginInteraction()", browserCs);
// Menu tiles and rows must check debouncer
Assert.Contains("MobileInteractionDebouncer.TryBeginInteraction()", menuCs);
}
[Fact]
public void Gate197_NoDoubleDebouncing_BetweenToolbarButtonAndMenuOpen()
{
var browserCs = File.ReadAllText(Path.Combine(MobileDir, "BrowserPage.cs"));
// OpenMoreMenuAsync must NOT call TryBeginInteraction because CreateToolbarButton already performs it!
// (Calling it twice in the same call stack causes immediate self-blocking within 0ms)
var methodIdx = browserCs.IndexOf("private async Task OpenMoreMenuAsync()");
Assert.True(methodIdx > 0);
var methodBody = browserCs.Substring(methodIdx, 300);
Assert.DoesNotContain("MobileInteractionDebouncer.TryBeginInteraction", methodBody);
}
[Fact]
public void Gate198_NavigationGuard_EnforcesSingleFlight_AndAutoRecoversOnTimeout()
{
MobileNavigationGuard.Reset();
// 1. Initial navigation begins successfully
Assert.True(MobileNavigationGuard.TryBeginNavigation(timeoutMs: 200));
// 2. Concurrent second navigation is rejected (prevents FragmentManager collision / ANR)
Assert.False(MobileNavigationGuard.TryBeginNavigation(timeoutMs: 200));
// 3. Normal completion frees lock immediately
MobileNavigationGuard.EndNavigation();
Assert.True(MobileNavigationGuard.TryBeginNavigation(timeoutMs: 200));
// 4. If navigation hangs or crashes without EndNavigation, auto-recovery frees lock after timeout
Thread.Sleep(250);
Assert.True(MobileNavigationGuard.TryBeginNavigation(timeoutMs: 200));
MobileNavigationGuard.EndNavigation();
}
[Fact]
public async Task Gate199_NavigationGuard_SafeModalToPageSequencing_CoordinatesTransitions()
{
int sequence = 0;
int dismissStep = 0;
int pushStep = 0;
await MobileNavigationGuard.CoordinateModalToPageTransitionAsync(
dismissModalAsync: async () =>
{
await Task.Delay(20);
dismissStep = ++sequence;
},
pushPageAsync: async () =>
{
await Task.Delay(10);
pushStep = ++sequence;
},
interTransitionDelayMs: 30
);
Assert.Equal(1, dismissStep);
Assert.Equal(2, pushStep);
Assert.True(pushStep > dismissStep);
}
[Fact]
public void Gate200_DownloadProgressThrottler_FiltersRapidFlooding_AndGuaranteesTerminalTransitions()
{
var throttler = new MobileDownloadProgressThrottler(minIntervalMs: 100, minDeltaPercent: 1.0);
const string taskId = "task-101";
// 1. Initial active download tick passes
Assert.True(throttler.ShouldDispatchProgress(taskId, 5.0, MobileDownloadStatus.Downloading));
// 2. Rapid ticks (within 100ms and < 1% delta) are throttled out
Assert.False(throttler.ShouldDispatchProgress(taskId, 5.2, MobileDownloadStatus.Downloading));
Assert.False(throttler.ShouldDispatchProgress(taskId, 5.5, MobileDownloadStatus.Downloading));
// 3. Significant delta (>= 1.0%) passes immediately even within interval
Assert.True(throttler.ShouldDispatchProgress(taskId, 6.6, MobileDownloadStatus.Downloading));
// 4. After interval passes, even minor delta passes
Thread.Sleep(120);
Assert.True(throttler.ShouldDispatchProgress(taskId, 6.8, MobileDownloadStatus.Downloading));
// 5. Terminal status (Completed/Failed/Cancelled) ALWAYS passes immediately
Assert.True(throttler.ShouldDispatchProgress(taskId, 100.0, MobileDownloadStatus.Completed));
}
[Fact]
public async Task Gate201_WebViewScriptGuard_TimesOutHungScript_AndReturnsFallbackWithoutCrash()
{
// 1. Fast script succeeds
var fastResult = await MobileWebViewScriptGuard.ExecuteWithTimeoutAsync(
async ct =>
{
await Task.Delay(10, ct);
return "{\"ready\":true}";
},
timeoutMs: 200,
fallback: "fallback"
);
Assert.Equal("{\"ready\":true}", fastResult);
// 2. Hung script (e.g. alert dialog or infinite loop in JS) times out and returns fallback safely
var hungResult = await MobileWebViewScriptGuard.ExecuteWithTimeoutAsync(
async ct =>
{
await Task.Delay(500, ct);
return "never";
},
timeoutMs: 100,
fallback: "fallback_timeout"
);
Assert.Equal("fallback_timeout", hungResult);
}
[Fact]
public async Task Gate202_AsyncShield_CatchesAndLogsExceptions_WithoutCrashingProcessOrFreezing()
{
Exception? caught = null;
await MobileAsyncShield.ExecuteSafeAsync(
async () =>
{
await Task.Yield();
throw new InvalidOperationException("Simulated navigation collision");
},
onError: ex => caught = ex,
tag: "TestGate202"
);
Assert.NotNull(caught);
Assert.IsType<InvalidOperationException>(caught);
Assert.Equal("Simulated navigation collision", caught.Message);
}
[Fact]
public void Gate203_CodebaseAudit_ToolbarAndNavigation_GuardedAgainstReentrancy()
{
var browserCs = File.ReadAllText(Path.Combine(MobileDir, "BrowserPage.cs"));
var menuCs = File.ReadAllText(Path.Combine(MobileDir, "MobileMenuModal.cs"));
// BrowserPage must reference MobileNavigationGuard
Assert.Contains("MobileNavigationGuard", browserCs);
// MobileMenuModal must reference MobileNavigationGuard
Assert.Contains("MobileNavigationGuard", menuCs);
}
[Fact]
public void Gate204_CodebaseAudit_DownloadsPage_UsesProgressThrottling()
{
var downloadsCs = File.ReadAllText(Path.Combine(MobileDir, "DownloadsPage.cs"));
// DownloadsPage must not blindly re-render on every raw progress tick without throttler
Assert.Contains("MobileDownloadProgressThrottler", downloadsCs);
}
[Fact]
public void Gate205_CodebaseAudit_AllToolbarActions_ProtectedByAsyncShield()
{
var browserCs = File.ReadAllText(Path.Combine(MobileDir, "BrowserPage.cs"));
// CreateToolbarButton must use MobileAsyncShield to prevent unobserved task crash
Assert.Contains("MobileAsyncShield.ExecuteSafeAsync", browserCs);
}
[Fact]
public void Gate206_ApiClient_Architecture_PureConstructor_And_LazyInitialization()
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var storage = new InMemoryKeyValueStorage();
var client = new ApiClient(storage: storage);
sw.Stop();
// Constructor must be O(1) pure, under 5ms, zero I/O
Assert.True(sw.ElapsedMilliseconds < 5, $"ApiClient constructor took too long: {sw.ElapsedMilliseconds}ms");
Assert.False(client.IsConfigured);
// Configure persists to storage
bool configured = client.Configure("http://192.168.1.100:52100/#t=test_token_123&cc=7777");
Assert.True(configured);
Assert.True(client.IsConfigured);
Assert.Equal("http://192.168.1.100:52100", client.BaseUrl);
Assert.Equal("test_token_123", client.Token);
Assert.Equal("7777", client.ConnectCode);
Assert.Equal("http://192.168.1.100:52100", storage.Get("vd_base", ""));
// New client restoring from storage
var restoredClient = new ApiClient(storage: storage);
Assert.False(restoredClient.IsConfigured); // Not configured until restored
restoredClient.Restore();
Assert.True(restoredClient.IsConfigured);
Assert.Equal("http://192.168.1.100:52100", restoredClient.BaseUrl);
}
[Fact]
public async Task Gate207_ApiClient_HighConcurrency_NoDeadlock_UnderMultiThreadAccess()
{
var storage = new InMemoryKeyValueStorage();
storage.Set("vd_base", "http://10.0.0.1:52100");
storage.Set("vd_token", "concurrent_token");
var client = new ApiClient(storage: storage);
client.Restore();
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
var tasks = new Task[50];
for (int i = 0; i < tasks.Length; i++)
{
tasks[i] = Task.Run(() =>
{
for (int j = 0; j < 1000; j++)
{
cts.Token.ThrowIfCancellationRequested();
var isConf = client.IsConfigured;
if (j % 100 == 0)
{
var url = client.BaseUrl;
}
}
}, cts.Token);
}
// All 50 threads must finish 50,000 accesses within 2 seconds with zero deadlock
await Task.WhenAll(tasks);
Assert.True(client.IsConfigured);
}
[Fact]
public async Task Gate208_ApiClient_NetworkResilience_TimeoutAndFailureHandling()
{
var client = new ApiClient(); // Unconfigured client
Assert.False(client.IsConfigured);
var queue = await client.GetQueueAsync();
Assert.Empty(queue);
var addResult = await client.AddAsync("https://example.com/video.mp4");
Assert.False(addResult);
var lib = await client.GetLibraryAsync();
Assert.Empty(lib);
}
[Fact]
public void Gate209_CodebaseAudit_ZeroSynchronousTaskBlockingOnMainThread()
{
// Forbid .Result, .Wait(), and .GetAwaiter().GetResult() across Mobile codebase to prevent ANR
var csFiles = Directory.GetFiles(MobileDir, "*.cs", SearchOption.AllDirectories);
var forbiddenPatterns = new[] { ".GetAwaiter().GetResult()", ".Wait()", ".Result" };
foreach (var file in csFiles)
{
var fileName = Path.GetFileName(file);
// Skip designer/generated/interop files if any
if (fileName.EndsWith(".g.cs") || fileName.Contains("Resource")) continue;
var content = File.ReadAllText(file);
foreach (var pattern in forbiddenPatterns)
{
// Ensure no synchronous task blocking in mobile UI code
if (content.Contains(pattern))
{
// Allow legitimate pattern matches if guarded or in comments
var lines = content.Split('\n');
for (int i = 0; i < lines.Length; i++)
{
var line = lines[i].Trim();
if (line.StartsWith("//") || line.StartsWith("/*")) continue;
if (line.Contains(pattern))
{
// If it's a Property named Result (like DialogResult, HttpResult), ignore
if (pattern == ".Result" && (line.Contains("DialogResult") || line.Contains("r.Result") == false && line.Contains("Task") == false && line.Contains("task.") == false))
continue;
Assert.Fail($"Forbidden synchronous blocking call '{pattern}' found in {fileName}:{i + 1} -> '{line}'. This causes Android ANR!");
}
}
}
}
}
}
[Fact]
public void Gate210_MobileMenuContext_SnapshotGeneration_NonBlockingAndInstant()
{
// Warm-up to avoid cold JIT measurement artifact
_ = new MobileMenuContext("https://warmup", "Title", false, MobileThemeMode.Dark, false, 0, false);
var sw = System.Diagnostics.Stopwatch.StartNew();
var context = new MobileMenuContext(
CurrentUrl: "https://youtube.com/watch?v=123",
CurrentTitle: "Awesome Video",
IsDesktopMode: false,
ThemeMode: MobileThemeMode.Dark,
IsBookmarked: true,
SniffedVideoCount: 3,
IsP2pConnected: false
);
sw.Stop();
Assert.True(sw.ElapsedMilliseconds < 10, "Menu context creation must be instant (< 10ms)");
Assert.True(context.IsBookmarked);
Assert.Equal(3, context.SniffedVideoCount);
Assert.False(context.IsP2pConnected);
}
[Fact]
public void Gate211_CodebaseAudit_ApiClient_PureConstructor_NoDirectPreferencesInConstructor()
{
var coreApiClient = Path.Combine(MobileCoreDir, "ApiClient.cs");
var mobileApiClient = Path.Combine(MobileDir, "ApiClient.cs");
var targetPath = File.Exists(coreApiClient) ? coreApiClient : mobileApiClient;
var content = File.ReadAllText(targetPath);
// Constructor must NOT contain Preferences.Get or direct I/O
var ctorIndex = content.IndexOf("public ApiClient(");
if (ctorIndex == -1) ctorIndex = content.IndexOf("ApiClient(");
Assert.True(ctorIndex >= 0, "ApiClient must declare a constructor");
// The constructor block must not call Preferences.Get or Restore()
var ctorBlock = content.Substring(ctorIndex, Math.Min(300, content.Length - ctorIndex));
Assert.DoesNotContain("Preferences.Get", ctorBlock);
Assert.DoesNotContain("Restore();", ctorBlock);
}
[Fact]
public void Gate212_CodebaseAudit_MobilePages_EventUnsubscriptionLifecycle()
{
var downloadsCs = File.ReadAllText(Path.Combine(MobileDir, "DownloadsPage.cs"));
// DownloadsPage must override OnDisappearing and unsubscribe from singleton events to prevent memory leaks and ghost refreshes
Assert.Contains("protected override void OnDisappearing()", downloadsCs);
Assert.Contains("TasksChanged -=", downloadsCs);
Assert.Contains("TaskProgressUpdated -=", downloadsCs);
Assert.Contains("InsetsChanged -=", downloadsCs);
}
[Fact]
public void Gate213_CodebaseAudit_AllBackButtons_And_Pops_GuardedAgainstReentrancy()
{
var pagesToCheck = new[] { "DownloadsPage.cs", "HistoryBookmarksPage.cs", "SyncSettingsPage.cs", "TabManagerPage.cs" };
foreach (var pageFile in pagesToCheck)
{
var content = File.ReadAllText(Path.Combine(MobileDir, pageFile));
// Must reference MobileNavigationGuard.SafePopAsync to prevent concurrent FragmentManager pops / ANR
Assert.Contains("MobileNavigationGuard.SafePopAsync", content);
}
}
[Fact]
public void Gate214_CodebaseAudit_DownloadsPage_SeparateTabRendering_ActiveDoesNotTouchOffline()
{
var downloadsCs = File.ReadAllText(Path.Combine(MobileDir, "DownloadsPage.cs"));
// Must have separate RenderActiveTasks and RenderOfflineLibrary methods to avoid scanning disk during active download progress
Assert.Contains("RenderActiveTasks()", downloadsCs);
Assert.Contains("RenderOfflineLibrary()", downloadsCs);
// TaskProgressUpdated must only re-render active tasks if active segment is selected
Assert.Contains("RenderActiveTasks", downloadsCs);
}
[Fact]
public async Task Gate215_OfflineStore_HighConcurrency_ThreadSafe_NoDeadlock()
{
var tempDir = Path.Combine(Path.GetTempPath(), $"paca_offline_store_{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
try
{
var store = new OfflineStore(tempDir);
Directory.CreateDirectory(store.Directory);
// 50 concurrent tasks performing Record, List, and Delete
var tasks = new Task[50];
for (int i = 0; i < 50; i++)
{
int index = i;
tasks[i] = Task.Run(() =>
{
var id = $"item_{index}";
var dummyFile = store.PathFor(id);
File.WriteAllText(dummyFile, "dummy data payload");
store.Record(id, $"video_{index}.mp4");
Assert.True(store.Exists(id));
var list = store.List();
Assert.NotEmpty(list);
if (index % 2 == 0)
{
store.Delete(id);
}
});
}
// Must finish within 3 seconds with zero deadlock or file access collision
var completed = await Task.WhenAny(Task.WhenAll(tasks), Task.Delay(3000));
Assert.NotEqual(completed, Task.Delay(3000)); // Did not timeout
await Task.WhenAll(tasks);
}
finally
{
try { Directory.Delete(tempDir, recursive: true); } catch { }
}
}
[Fact]
public async Task Gate216_MobileNavigationGuard_SafePopAsync_PreventsConcurrentPops()
{
MobileNavigationGuard.Reset();
int popCount = 0;
var tcs = new TaskCompletionSource<bool>();
// 1. Launch first pop that is suspended on tcs
var firstPopTask = MobileNavigationGuard.SafePopAsync(async () =>
{
popCount++;
await tcs.Task;
});
// 2. Second concurrent pop must be rejected immediately by guard
bool secondPopExecuted = false;
await MobileNavigationGuard.SafePopAsync(async () =>
{
secondPopExecuted = true;
await Task.Yield();
});
Assert.False(secondPopExecuted, "Concurrent pop must be safely rejected to protect FragmentManager");
Assert.Equal(1, popCount);
// 3. Complete first pop
tcs.SetResult(true);
await firstPopTask;
// 4. Subsequent pop after first completes is accepted
bool thirdPopExecuted = false;
await MobileNavigationGuard.SafePopAsync(async () =>
{
thirdPopExecuted = true;
await Task.Yield();
});
Assert.True(thirdPopExecuted);
}
[Fact]
public void Gate217_LanDiscoveryProber_TimeoutAndSafeCancellation()
{
using var prober = new LanDiscoveryProber();
// Feed sample payloads
prober.HandleReceivedPayload("PACA_DISCOVERY|PONG|Desktop-1|192.168.1.50:52100|1|fp123", "192.168.1.50");
prober.HandleReceivedPayload("PACA_DISCOVERY|PONG|Desktop-2|:52100|0|fp456", "192.168.1.60"); // Missing IP auto-corrected
var devices = prober.GetActiveDevices(TimeSpan.FromSeconds(5));
Assert.Equal(2, devices.Count);
Assert.Equal("http://192.168.1.50:52100", devices[0].HttpBaseUrl);
Assert.Equal("192.168.1.60:52100", devices[1].Endpoint);
// Safe stop without exceptions
prober.Stop();
}
[Fact]
public void Gate218_CodebaseAudit_NoRawFileIoInsideUiProgressLoops()
{
var downloadsCs = File.ReadAllText(Path.Combine(MobileDir, "DownloadsPage.cs"));
// Inside TaskProgressUpdated handler, there must not be any direct calls to AppOfflineStore.Default.List() or FileInfo
var progressHandlerIdx = downloadsCs.IndexOf("TaskProgressUpdated +=");
Assert.True(progressHandlerIdx > 0);
var progressHandlerBlock = downloadsCs.Substring(progressHandlerIdx, Math.Min(300, downloadsCs.Length - progressHandlerIdx));
Assert.DoesNotContain("AppOfflineStore.Default.List()", progressHandlerBlock);
Assert.DoesNotContain("FileInfo", progressHandlerBlock);
}
}

View file

@ -0,0 +1,44 @@
using System;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Mobile;
public class MobileShareTextExtractorGateTests
{
[Fact]
public void Gate253_MobileShareTextExtractor_ExtractsCleanUrl_FromMessyShareText()
{
var shared = "Watch \"Awesome Video\" on YouTube: https://youtu.be/dQw4w9WgXcQ?si=abcdef12345";
var extracted = MobileShareTextExtractor.ExtractMediaUrl(shared);
Assert.Equal("https://youtu.be/dQw4w9WgXcQ", extracted);
}
[Fact]
public void Gate254_MobileShareTextExtractor_HandlesMultipleUrls_PicksFirstValidMediaUrl()
{
var shared = "Read info at https://google.com and watch https://tiktok.com/@user/video/9876543210";
var extracted = MobileShareTextExtractor.ExtractMediaUrl(shared);
Assert.Equal("https://tiktok.com/@user/video/9876543210", extracted);
}
[Fact]
public void Gate255_MobileShareTextExtractor_ReturnsNull_WhenNoValidUrlPresent()
{
var shared = "Just plain message from a friend without any hyperlink";
var extracted = MobileShareTextExtractor.ExtractMediaUrl(shared);
Assert.Null(extracted);
}
[Fact]
public void Gate256_MobileShareTextExtractor_StripsTrackingQueryParameters_PreservesVideoId()
{
var url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ&utm_source=twitter&si=123&fbclid=xyz";
var extracted = MobileShareTextExtractor.ExtractMediaUrl(url);
Assert.Equal("https://www.youtube.com/watch?v=dQw4w9WgXcQ", extracted);
}
}

View file

@ -0,0 +1,124 @@
using System;
using System.Threading.Tasks;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// Gate 143 ~ 146: 모바일 스마트 클립보드 스니퍼 & 원클릭 Send-to-PC 플로팅 시트 기계적 검증
/// </summary>
public class MobileSmartClipboardSnifferGateTests
{
private class MockClipboardReader : IClipboardReader
{
private string? _clipboardText;
public void SetText(string? text) => _clipboardText = text;
public Task<string?> GetTextAsync() => Task.FromResult(_clipboardText);
}
[Theory]
[InlineData("https://youtu.be/dQw4w9WgXcQ", true, "YouTube")]
[InlineData("https://www.youtube.com/shorts/abcdef123", true, "YouTube Shorts")]
[InlineData("https://www.tiktok.com/@user/video/7123456789", true, "TikTok")]
[InlineData("https://x.com/user/status/1234567890?s=20", true, "X (Twitter)")]
[InlineData("https://twitter.com/user/status/1234567890", true, "X (Twitter)")]
[InlineData("https://www.instagram.com/reel/C123456/", true, "Instagram Reel")]
[InlineData("https://chzzk.naver.com/video/98765", true, "CHZZK")]
[InlineData("https://vod.sooplive.co.kr/player/112233", true, "SOOP")]
[InlineData("https://stream.example.com/live/playlist.m3u8", true, "Direct HLS Stream")]
[InlineData("https://cdn.example.com/videos/bunny.mp4?token=abc", true, "Direct Video File")]
[InlineData("안녕하세요 일반 텍스트 메모입니다.", false, "None")]
[InlineData("https://www.google.com/search?q=csharp", false, "None")]
[InlineData("", false, "None")]
[InlineData(null, false, "None")]
public void Gate143_SmartClipboard_Detects_Supported_Video_Platforms(string? inputUrl, bool expectedIsVideo, string expectedPlatform)
{
var result = SmartClipboardSniffer.AnalyzeUrl(inputUrl);
Assert.Equal(expectedIsVideo, result.IsVideoUrl);
Assert.Equal(expectedPlatform, result.DetectedPlatform);
if (expectedIsVideo)
{
Assert.False(string.IsNullOrWhiteSpace(result.NormalizedUrl));
}
}
[Fact]
public async Task Gate144_SmartClipboard_Deduplication_And_Cooldown()
{
var mockClipboard = new MockClipboardReader();
var sniffer = new SmartClipboardSniffer(mockClipboard, cacheTtl: TimeSpan.FromMinutes(5));
// 1. 첫 복사 시도 -> 감지 성공
mockClipboard.SetText("https://youtu.be/dQw4w9WgXcQ?si=tracking123");
var firstResult = await sniffer.CheckClipboardAsync();
Assert.NotNull(firstResult);
Assert.True(firstResult.IsVideoUrl);
// 2. 동일한 클립보드 상태로 즉시 재검사 -> 캐시/중복 방지로 null 반환 (불필요한 팝업 방지)
var secondResult = await sniffer.CheckClipboardAsync();
Assert.Null(secondResult);
// 3. 새로운 비디오 URL로 클립보드 변경 -> 감지 성공
mockClipboard.SetText("https://www.tiktok.com/@creator/video/998877");
var thirdResult = await sniffer.CheckClipboardAsync();
Assert.NotNull(thirdResult);
Assert.True(thirdResult.IsVideoUrl);
Assert.Equal("TikTok", thirdResult.DetectedPlatform);
// 4. 소비(Dismiss/Sent) 처리 후 재요청 시 다시 차단 유지 확인
sniffer.DismissCurrent();
var dismissedResult = await sniffer.CheckClipboardAsync();
Assert.Null(dismissedResult);
}
[Fact]
public void Gate145_SmartClipboard_Url_Normalization()
{
var dirtyUrl = "https://youtu.be/dQw4w9WgXcQ?si=junkTrackingToken&utm_source=app&fbclid=XYZ123";
var result = SmartClipboardSniffer.AnalyzeUrl(dirtyUrl);
Assert.True(result.IsVideoUrl);
// 트래킹 파라미터는 정규화되어 제거됨
Assert.DoesNotContain("si=junkTrackingToken", result.NormalizedUrl);
Assert.DoesNotContain("utm_source=app", result.NormalizedUrl);
Assert.DoesNotContain("fbclid=XYZ123", result.NormalizedUrl);
Assert.Equal("https://youtu.be/dQw4w9WgXcQ", result.NormalizedUrl);
}
[Fact]
public async Task Gate146_SmartClipboard_Banner_Action_Dispatches_To_Active_Connection()
{
var mockClipboard = new MockClipboardReader();
var sniffer = new SmartClipboardSniffer(mockClipboard);
mockClipboard.SetText("https://x.com/elonmusk/status/987654321");
var result = await sniffer.CheckClipboardAsync();
Assert.NotNull(result);
bool sentToPcCalled = false;
string? sentUrl = null;
string? sentTarget = null;
var bannerAction = new SmartClipboardBannerAction(
Result: result,
SendToPcAction: async (url, targetDevice) =>
{
sentToPcCalled = true;
sentUrl = url;
sentTarget = targetDevice;
await Task.Yield();
return true;
}
);
var success = await bannerAction.ExecuteSendToPcAsync("My-Desktop-PC");
Assert.True(success);
Assert.True(sentToPcCalled);
Assert.Equal(result.NormalizedUrl, sentUrl);
Assert.Equal("My-Desktop-PC", sentTarget);
}
}

View file

@ -0,0 +1,107 @@
using System;
using System.IO;
using Paca.Mobile.Core;
using Paca.Tests.Infrastructure;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// 모바일 환골탈태 스위스 미니멀리즘 초고밀도 디자인 시스템 감사 게이트 (Gate 184 ~ Gate 190)
/// </summary>
public class MobileSwissMinimalExcellenceGateTests
{
private static string MobileProjectDir => Directory.Exists(Path.Combine(SourceTree.Root, "src", "Paca.Mobile"))
? Path.Combine(SourceTree.Root, "src", "Paca.Mobile")
: Path.Combine(SourceTree.Root, "Paca.Mobile");
[Fact]
public void Gate184_TabbedRootPage_Modern_FiveTabs_ThumbZone_Audit()
{
var rootCs = File.ReadAllText(Path.Combine(MobileProjectDir, "TabbedRootPage.cs"));
// 1. 필수 5대 핵심 탭 등록 확인
Assert.Contains("Children.Add(queuePage)", rootCs);
Assert.Contains("Children.Add(new DiscoverPage())", rootCs);
Assert.Contains("Children.Add(new BrowserPage())", rootCs);
Assert.Contains("Children.Add(new FavoritesPage())", rootCs);
Assert.Contains("Children.Add(new SyncSettingsPage())", rootCs);
// 2. 화면을 찌그러뜨리던 8개 탭 과밀 플랫 나열(LibraryPage, DownloadsPage, ConnectPage의 Children.Add) 박멸 확인
Assert.DoesNotContain("Children.Add(new LibraryPage())", rootCs);
Assert.DoesNotContain("Children.Add(new DownloadsPage())", rootCs);
Assert.DoesNotContain("Children.Add(new ConnectPage())", rootCs);
}
[Fact]
public void Gate185_QueuePage_DoubleBezel_And_CompactIslandButtons_Audit()
{
var pagesCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Pages.cs"));
// 1. QueuePage 카드에서 레거시 Frame 박멸 및 최신 Border + RoundRectangle 적용
Assert.Contains("CompactQueueCard = new Border", pagesCs);
Assert.Contains("RoundRectangle", pagesCs);
// 2. 카드 내 액션 버튼(pause, resume, cancel) 48dp 비대 버튼 박멸 및 32dp 컴팩트 아일랜드 버튼 적용
Assert.Contains("HeightRequest = 32", pagesCs);
Assert.Contains("WidthRequest = 32", pagesCs);
Assert.DoesNotContain("HeightRequest = 48, CornerRadius = 8, BackgroundColor = Color.FromArgb(\"#161828\")", pagesCs);
}
[Fact]
public void Gate186_Mobile_ZeroEmoji_StrictLucideGlyph_Audit()
{
var pagesCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Pages.cs"));
// 1. 하드코딩된 '⚡' 이모지 완전 박멸 확인
Assert.DoesNotContain("\"⚡\"", pagesCs);
// 2. MobileIconSystem.Zap 및 Lucide 폰트 패밀리 적용 확인
Assert.Contains("MobileIconSystem.Zap", pagesCs);
}
[Fact]
public void Gate187_DiscoverPage_CompactRailChips_Audit()
{
var pagesCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Pages.cs"));
// 1. 28dp 수평 알약 칩 및 34dp 슬림 서치바 적용 확인
Assert.Contains("HeightRequest = 28", pagesCs);
Assert.Contains("CornerRadius = 14", pagesCs);
Assert.Contains("_searchEntry", pagesCs);
}
[Fact]
public void Gate188_SyncSettingsPage_ModularDoubleBezelCards_Audit()
{
var syncCs = File.ReadAllText(Path.Combine(MobileProjectDir, "SyncSettingsPage.cs"));
// 1. Double-Bezel 셸/코어 서피스 토큰 사용 확인
Assert.Contains("SurfaceShell", syncCs);
Assert.Contains("SurfaceCore", syncCs);
}
[Fact]
public void Gate189_DownloadsPage_CompactToolbar_And_SegmentPills_Audit()
{
var dlCs = File.ReadAllText(Path.Combine(MobileProjectDir, "DownloadsPage.cs"));
// 1. 단일 레일 알약 스크롤 뷰 및 28dp 필터 버튼
Assert.Contains("SingleRailPillsScrollView", dlCs);
Assert.Contains("HeightRequest = 28", dlCs);
Assert.Contains("HeightRequest = 34", dlCs);
}
[Fact]
public void Gate190_MobileThemeSystem_CompactTokens_Audit()
{
// 1. 초고밀도 컴팩트 디자인 토큰 규격 확인
Assert.Equal(32.0, MobileThemeSystem.CompactButtonSizeDp);
Assert.Equal(28.0, MobileThemeSystem.CompactPillHeightDp);
Assert.Equal(34.0, MobileThemeSystem.CompactItemHeightDp);
Assert.Equal(56.0, MobileThemeSystem.CompactRowHeightDp);
Assert.Equal("#10111A", MobileThemeSystem.SurfaceShell);
Assert.Equal("#151624", MobileThemeSystem.SurfaceCore);
Assert.Equal("#23253A", MobileThemeSystem.BorderHairline);
}
}

View file

@ -0,0 +1,49 @@
using System;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// 모바일 문자열 인코딩 정제 및 유니코드 복원 무결성 게이트 (Gate 511 ~ Gate 515)
/// - JS Evaluate 결과 JSON 유니코드 이스케이프(\uXXXX) 디코딩
/// - URL 퍼센트 인코딩(%ED%95%9C%EA%B8%80) 사용자 가독 디코딩
/// - HTML 엔티티(&amp;, &quot;, &#39;, &lt;, &gt;) 복원
/// </summary>
public class MobileTextEncodingSanitizerGateTests
{
[Theory]
[InlineData("\"\\uB124\\uC774\\uBC84\"", "네이버")]
[InlineData("\"\\uAD6C\\uAE00\"", "구글")]
[InlineData("\\uC720\\uD29C\\uBE0C", "유튜브")]
[InlineData("\"동영상 다운로더\"", "동영상 다운로더")]
[InlineData("null", "")]
[InlineData("", "")]
[InlineData(null, "")]
public void Gate511_JsStringDecoder_UnescapesUnicodeAndJsonQuotes(string? input, string expected)
{
var result = MobileTextEncodingSanitizer.CleanDecodedString(input);
Assert.Equal(expected, result);
}
[Theory]
[InlineData("https://www.google.com/search?q=%ED%85%8C%EC%8A%A4%ED%8A%B8", "https://www.google.com/search?q=테스트")]
[InlineData("https://m.naver.com/%ED%95%9C%EA%B8%80%EA%B2%80%EC%83%89", "https://m.naver.com/한글검색")]
[InlineData("paca://newtab", "paca://newtab")]
[InlineData("", "")]
[InlineData(null, "")]
public void Gate512_FormatUrlForDisplay_DecodesPercentEncodingForHumanReadability(string? input, string expected)
{
var result = MobileTextEncodingSanitizer.FormatUrlForDisplay(input);
Assert.Equal(expected, result);
}
[Theory]
[InlineData("&quot;안녕&quot; &amp; &lt;세상&gt;", "\"안녕\" & <세상>")]
[InlineData("&#39;PACA&#39; &#x26; 브라우저", "'PACA' & 브라우저")]
public void Gate513_HtmlEntityDecoding_RestoresEntities(string input, string expected)
{
var result = MobileTextEncodingSanitizer.CleanDecodedString(input);
Assert.Equal(expected, result);
}
}

View file

@ -15,6 +15,10 @@ public class MobileUiProductionGateTests
? Path.Combine(SourceTree.Root, "src", "Paca.Mobile")
: Path.Combine(SourceTree.Root, "Paca.Mobile");
private static string MobileCoreProjectDir => Directory.Exists(Path.Combine(SourceTree.Root, "src", "Paca.Mobile.Core"))
? Path.Combine(SourceTree.Root, "src", "Paca.Mobile.Core")
: Path.Combine(SourceTree.Root, "Paca.Mobile.Core");
[Fact]
public void Gate93_TabbedRootPage_FiveTabs_Completeness_Audit()
{
@ -90,4 +94,266 @@ public class MobileUiProductionGateTests
Assert.True(File.Exists(appIconPath), "paca_app_icon.png must exist");
Assert.True(File.Exists(splashPath), "paca_splash_screen.png must exist");
}
[Fact]
public void Gate114_ConnectPage_LanAutoDiscovery_And_WanConnectCode_Ui_Audit()
{
var pagesCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Pages.cs"));
Assert.Contains("주변 PC 자동 검색", pagesCs);
Assert.Contains("원클릭 연결", pagesCs);
Assert.Contains("ConnectCode", pagesCs);
Assert.Contains("3G / 4G / 5G", pagesCs);
}
[Fact]
public void Gate151_QueuePage_SmartClipboardBanner_Ui_Audit()
{
var pagesCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Pages.cs"));
Assert.Contains("_clipboardBanner", pagesCs);
Assert.Contains("클립보드 감지", pagesCs);
Assert.Contains("PC로 다운로드", pagesCs);
}
[Fact]
public void Gate152_LibraryPage_OfflineStorageDashboard_Ui_Audit()
{
var pagesCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Pages.cs"));
Assert.Contains("_storageProgressBar", pagesCs);
Assert.Contains("_storageSummaryLabel", pagesCs);
Assert.Contains("오프라인 보관함", pagesCs);
}
[Fact]
public void Gate160_ApiClient_AudioStreamUrl_Supported()
{
var targetFile = File.Exists(Path.Combine(MobileCoreProjectDir, "ApiClient.cs"))
? Path.Combine(MobileCoreProjectDir, "ApiClient.cs")
: Path.Combine(MobileProjectDir, "ApiClient.cs");
var apiClientCs = File.ReadAllText(targetFile);
Assert.Contains("AudioUrl", apiClientCs);
Assert.Contains("/api/audio/", apiClientCs);
}
[Fact]
public void Gate161_BrowserPage_BrandLogo_Asset_And_Source_Audit()
{
var logoAssetPath = Path.Combine(MobileProjectDir, "Resources", "Images", "paca_logo.png");
Assert.True(File.Exists(logoAssetPath), "paca_logo.png must exist in Resources/Images");
var browserCs = File.ReadAllText(Path.Combine(MobileProjectDir, "BrowserPage.cs"));
Assert.Contains("paca_logo.png", browserCs);
}
[Fact]
public void Gate162_BrowserPage_BottomScrollPadding_AvoidsClipping_Audit()
{
var browserCs = File.ReadAllText(Path.Combine(MobileProjectDir, "BrowserPage.cs"));
// CreateNewTabPageView rootLayout bottom padding must be >= 100 to avoid clipping with bottom floating bar
var match = Regex.Match(browserCs, @"rootLayout[\s\S]*?Padding\s*=\s*new\s*Thickness\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*(\d+)\s*\)");
Assert.True(match.Success, "Padding definition must exist on rootLayout");
int bottomPadding = int.Parse(match.Groups[1].Value);
Assert.True(bottomPadding >= 100, $"Bottom padding must be at least 100 to clear floating bottom bar, but was {bottomPadding}");
}
[Fact]
public void Gate163_MobileApp_NoUnderlineEntry_Handler_Audit()
{
var mauiProgramCs = File.ReadAllText(Path.Combine(MobileProjectDir, "MauiProgram.cs"));
Assert.Contains("EntryHandler", mauiProgramCs);
Assert.Contains("Background", mauiProgramCs);
}
[Fact]
public void Gate164_BrowserPage_SpeedDials_Modern_Lucide_Glyph_Audit()
{
var browserCs = File.ReadAllText(Path.Combine(MobileProjectDir, "BrowserPage.cs"));
Assert.Contains("MobileIconSystem.FontFamily", browserCs);
Assert.Contains("MobileIconSystem.Play", browserCs);
}
[Fact]
public void Gate165_AndroidManifest_WebView_PackageVisibility_Queries_Audit()
{
var manifestPath = Path.Combine(MobileProjectDir, "Platforms", "Android", "AndroidManifest.xml");
var manifestText = File.ReadAllText(manifestPath);
Assert.Contains("<queries>", manifestText);
Assert.Contains("com.google.android.webview", manifestText);
}
[Fact]
public void Gate166_BrowserPage_LazyWebView_FaultTolerant_Audit()
{
var browserCs = File.ReadAllText(Path.Combine(MobileProjectDir, "BrowserPage.cs"));
Assert.Contains("private bool EnsureWebView()", browserCs);
Assert.Contains("if (!EnsureWebView()", browserCs);
}
[Fact]
public void Gate167_BrowserPage_SnifferBadge_EmeraldColor_And_Glyph_Audit()
{
// DownloadsPage 안내문구: '웹 브라우징 중 상단 에메랄드 비디오 뱃지를 눌러 고화질 영상을 다운로드하세요.'
// 에메랄드 컬러(#059669 또는 #10B981) 적용 및 비디오 글리프/텍스트 매핑 검증
var browserCs = File.ReadAllText(Path.Combine(MobileProjectDir, "BrowserPage.cs"));
Assert.True(browserCs.Contains("#059669") || browserCs.Contains("#10B981"),
"BrowserPage _snifferBadge는 DownloadsPage 안내문구와 100% 일치하는 에메랄드(#059669 또는 #10B981) 배경색이어야 합니다.");
}
[Fact]
public void Gate168_BrowserPage_DesktopModeBtn_Decoupled_From_PcSync_Audit()
{
// 데스크톱 모드 전환 버튼과 PC 연결 상태 인디케이터의 변수/로직 분리 감사
var browserCs = File.ReadAllText(Path.Combine(MobileProjectDir, "BrowserPage.cs"));
Assert.Contains("_desktopModeBtn", browserCs);
Assert.DoesNotContain("_pcStatusIndicator.TextColor = Color.FromArgb(\"#34D399\")", browserCs);
}
[Fact]
public void Gate171_BrowserPage_Sniffer_Detects_Supported_Platforms_Audit()
{
// 플랫폼 비디오(유튜브/치지직/숲 등) 진입 시 DOM blob 여부와 무관하게 플랫폼 비디오가 스니퍼 뱃지에 반영되는지 감사
var browserCs = File.ReadAllText(Path.Combine(MobileProjectDir, "BrowserPage.cs"));
Assert.Contains("MobileDiscoveryEngine.DetectPlatform", browserCs);
}
[Fact]
public void Gate172_BrowserPage_ShareAction_Invokes_Native_Share_Audit()
{
// 더보기 메뉴의 '링크 복사 및 공유' 클릭 시 클립보드 복사뿐 아니라 OS 네이티브 공유 시트(Share.Default.RequestAsync)를 연동하는지 감사
var browserCs = File.ReadAllText(Path.Combine(MobileProjectDir, "BrowserPage.cs"));
Assert.Contains("Share.Default.RequestAsync", browserCs);
}
[Fact]
public void Gate173_BrowserPage_Sniffer_Extracts_True_Url_Not_Source_ToString_Audit()
{
// MAUI UrlWebViewSource.ToString()은 클래스명을 반환하므로 반드시 window.location.href 또는 UrlWebViewSource.Url/e.Url을 사용하여 실제 URL을 추출해야 함
var browserCs = File.ReadAllText(Path.Combine(MobileProjectDir, "BrowserPage.cs"));
Assert.DoesNotContain("_webView.Source?.ToString() ?? e.Url", browserCs);
Assert.Contains("window.location.href", browserCs);
}
[Fact]
public void Gate174_SyncSettingsPage_LanDiscovery_Ui_And_AutoConnect_Pills_Audit()
{
var syncCs = File.ReadAllText(Path.Combine(MobileProjectDir, "SyncSettingsPage.cs"));
// 원클릭 LAN 자동 발견 프로버 연동 및 UI 구성요소 감사
Assert.Contains("LanDiscoveryProber", syncCs);
Assert.Contains("로컬 네트워크 탐색", syncCs);
Assert.Contains("10.0.2.2:5000", syncCs); // 에뮬레이터 퀵 페어링 지원
Assert.Contains("OnDeviceFound", syncCs);
}
[Fact]
public void Gate175_ApiClient_DirectHost_And_OptionalToken_Support_Audit()
{
var targetFile = File.Exists(Path.Combine(MobileCoreProjectDir, "ApiClient.cs"))
? Path.Combine(MobileCoreProjectDir, "ApiClient.cs")
: Path.Combine(MobileProjectDir, "ApiClient.cs");
var apiCs = File.ReadAllText(targetFile);
// 토큰 없는 로컬 LAN 직접 연결(10.0.2.2:5000, 192.168.x.x) 및 URI 정규화 지원 감사
Assert.Contains("lan_direct", apiCs);
Assert.Contains("!host.StartsWith(\"http", apiCs);
}
[Fact]
public void Gate176_MobileTabManager_SpeedDial_Deduplication_And_DomainMatching_Audit()
{
var browserCs = File.ReadAllText(Path.Combine(MobileProjectDir, "BrowserPage.cs"));
// 바로가기(Speed Dial) 탭 시 중복 생성 방지 및 탭 전환 감사
Assert.Contains("FindTabByUrlOrHost", browserCs);
}
[Fact]
public void Gate177_MobileProject_16KbPageSize_And_AssemblyStore_Audit()
{
var csproj = File.ReadAllText(Path.Combine(MobileProjectDir, "Paca.Mobile.csproj"));
var manifest = File.ReadAllText(Path.Combine(MobileProjectDir, "Platforms", "Android", "AndroidManifest.xml"));
// 최신 안드로이드 15/16 16KB 페이지 사이즈 호환 및 어셈블리 스토어(.so 래핑 방지) 감사
Assert.True(csproj.Contains("<AndroidUseAssemblyStore") && csproj.Contains("true</AndroidUseAssemblyStore>"));
Assert.True(csproj.Contains("<AndroidEnable16KbPages") && csproj.Contains("true</AndroidEnable16KbPages>"));
Assert.Contains("android:targetSdkVersion=\"36\"", manifest);
}
[Fact]
public void Gate181_DownloadsPage_SingleRail_Compact_Filters_Audit()
{
var downloadsCs = File.ReadAllText(Path.Combine(MobileProjectDir, "DownloadsPage.cs"));
// 과밀한 4줄 버튼 대신 단일 레일 컴팩트 알약 스크롤 뷰 및 34dp 슬림 서치박스 감사
Assert.Contains("SingleRailPillsScrollView", downloadsCs);
Assert.Contains("FilterAll", downloadsCs);
Assert.Contains("FilterActive", downloadsCs);
Assert.Contains("FilterDone", downloadsCs);
Assert.Contains("FilterFailed", downloadsCs);
Assert.Contains("ClearFinishedDownloadsBtn", downloadsCs);
}
[Fact]
public void Gate182_QueuePage_CompactClipboardBanner_And_DoubleBezelCards_Audit()
{
var pagesCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Pages.cs"));
// 비대한 48px 카드 대신 슬림한 클립보드 센서 알약 및 28px/32px 컴팩트 큐 컨트롤 감사
Assert.Contains("_clipboardBanner", pagesCs);
Assert.Contains("paca_empty_queue.png", pagesCs);
Assert.Contains("재시도", pagesCs);
Assert.Contains("CompactQueueCard", pagesCs);
}
[Fact]
public void Gate183_TabbedRootPage_Modern_FiveTabs_Integrity_Audit()
{
var rootCs = File.ReadAllText(Path.Combine(MobileProjectDir, "TabbedRootPage.cs"));
// 8개 탭 산발적 나열 대신 5대 핵심 탭 정렬 및 서브 탭 자연스러운 수용 감사
Assert.Contains("QueuePage", rootCs);
Assert.Contains("DiscoverPage", rootCs);
Assert.Contains("BrowserPage", rootCs);
Assert.Contains("FavoritesPage", rootCs);
Assert.Contains("SyncSettingsPage", rootCs);
Assert.Contains("Paca Mobile", rootCs);
}
[Fact]
public void Gate184_SpeedDial_TouchReliability_And_FullHitTest_Audit()
{
var browserCs = File.ReadAllText(Path.Combine(MobileProjectDir, "BrowserPage.cs"));
// 스피드 다이얼 항목 터치 시 자식 요소(아이콘, 라벨 등)가 터치를 가로채지 않도록 InputTransparent 설정 감사
Assert.Contains("InputTransparent = true", browserCs);
Assert.Contains("CascadeInputTransparent = true", browserCs);
Assert.Contains("iconCircle.GestureRecognizers.Add(tap);", browserCs);
Assert.Contains("itemContainer.GestureRecognizers.Add(tap);", browserCs);
}
[Fact]
public void Gate185_Browser_Navigation_And_BackKeyHandling_Audit()
{
var browserCs = File.ReadAllText(Path.Combine(MobileProjectDir, "BrowserPage.cs"));
var tabbedRootCs = File.ReadAllText(Path.Combine(MobileProjectDir, "TabbedRootPage.cs"));
// 1. _isNavigating 락으로 인한 터치 무시/잠금 버그 완전 배제
Assert.DoesNotContain("_isNavigating", browserCs);
// 2. UI 스레드 안전 비동기 전환 보증
Assert.Contains("MainThread.BeginInvokeOnMainThread", browserCs);
// 3. 브라우저 페이지 및 탭 루트의 뒤로가기 제어 감사
Assert.Contains("public bool HandleBackButton()", browserCs);
Assert.Contains("browserPage.HandleBackButton()", tabbedRootCs);
}
[Fact]
public void Gate239_ApiClient_AutoRestore_Constructor_Audit()
{
var targetFile = File.Exists(Path.Combine(MobileCoreProjectDir, "ApiClient.cs"))
? Path.Combine(MobileCoreProjectDir, "ApiClient.cs")
: Path.Combine(MobileProjectDir, "ApiClient.cs");
var apiCs = File.ReadAllText(targetFile);
// ApiClient의 순수 생성자 및 영속 상태 복원 Restore() 메서드 계약 감사
Assert.Contains("public ApiClient(", apiCs);
Assert.Contains("public void Restore()", apiCs);
}
}

View file

@ -0,0 +1,121 @@
using System;
using System.Threading.Tasks;
using Paca.Mobile.Core;
using Paca.Server.Gateway;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// Gate 127 ~ 132: 3G/4G/5G WAN 게이트웨이 및 Wi-Fi ↔ 셀룰러 무중단 로밍 기계적 검증
/// </summary>
public class MobileWanGatewayRoamingGateTests
{
[Fact]
public void Gate127_ConnectCode_Generation_And_Validation()
{
var code = PacaConnectCodeGenerator.GenerateCode();
Assert.NotNull(code);
Assert.StartsWith("PACA-", code);
Assert.True(PacaConnectCodeGenerator.IsValid(code));
Assert.False(PacaConnectCodeGenerator.IsValid("INVALID-CODE"));
Assert.False(PacaConnectCodeGenerator.IsValid(""));
Assert.False(PacaConnectCodeGenerator.IsValid(null));
}
[Fact]
public void Gate128_WanGatewayConfig_State_Management()
{
var gateway = new WanGatewayManager(deviceName: "Encep-Studio-PC", localPort: 52100);
// 기본 상태는 비활성
Assert.False(gateway.IsActive);
Assert.Equal(GatewayMode.Disabled, gateway.CurrentMode);
// 원격 릴레이/터널 활성화 시뮬레이션
gateway.EnableCloudTunnel("https://relay.paca.app/tunnel-9876", "PACA-884123");
Assert.True(gateway.IsActive);
Assert.Equal(GatewayMode.CloudTunnel, gateway.CurrentMode);
Assert.Equal("https://relay.paca.app/tunnel-9876", gateway.PublicEndpoint);
Assert.Equal("PACA-884123", gateway.ConnectCode);
}
[Fact]
public void Gate129_Upnp_Mapping_Profile_Creation()
{
var gateway = new WanGatewayManager(deviceName: "Encep-Studio-PC", localPort: 52100);
gateway.EnableDirectPortMapping(publicIp: "203.0.113.50", externalPort: 52100);
Assert.True(gateway.IsActive);
Assert.Equal(GatewayMode.DirectPortMapping, gateway.CurrentMode);
Assert.Equal("http://203.0.113.50:52100", gateway.PublicEndpoint);
}
[Fact]
public void Gate130_RoamingProfile_Serialization()
{
var profile = new RoamingDeviceProfile(
DeviceId: "device-uuid-1234",
DeviceName: "Encep-Desktop",
LanEndpoint: "http://192.168.0.15:52100",
WanEndpoint: "https://relay.paca.app/tunnel-9876",
AuthToken: "token-secret-abc",
ConnectCode: "PACA-884123"
);
Assert.Equal("http://192.168.0.15:52100", profile.GetEndpointFor(NetworkEnvironment.LocalWifi));
Assert.Equal("https://relay.paca.app/tunnel-9876", profile.GetEndpointFor(NetworkEnvironment.CellularWan));
}
[Fact]
public void Gate131_RoamingConnectionManager_Auto_Failover()
{
var profile = new RoamingDeviceProfile(
DeviceId: "device-uuid-1234",
DeviceName: "Encep-Desktop",
LanEndpoint: "http://192.168.0.15:52100",
WanEndpoint: "https://relay.paca.app/tunnel-9876",
AuthToken: "token-secret-abc",
ConnectCode: "PACA-884123"
);
var roaming = new RoamingConnectionManager(profile);
// 초기 Wi-Fi 상태 -> LAN 우선
roaming.UpdateNetworkState(NetworkEnvironment.LocalWifi);
Assert.Equal("http://192.168.0.15:52100", roaming.ActiveEndpoint);
Assert.False(roaming.IsRoamingOnCellular);
// LAN 연결 실패 또는 셀룰러 환경 진입 -> WAN 게이트웨이로 핫-페일오버
roaming.RecordConnectionFailure();
Assert.Equal("https://relay.paca.app/tunnel-9876", roaming.ActiveEndpoint);
Assert.True(roaming.IsRoamingOnCellular);
// Wi-Fi 재접속 및 헬스체크 복구 -> LAN 복귀
roaming.UpdateNetworkState(NetworkEnvironment.LocalWifi);
roaming.RecordConnectionSuccess();
Assert.Equal("http://192.168.0.15:52100", roaming.ActiveEndpoint);
Assert.False(roaming.IsRoamingOnCellular);
}
[Fact]
public void Gate132_RoamingConnectionManager_Fallback_When_Wan_Empty()
{
var profile = new RoamingDeviceProfile(
DeviceId: "dev-only-lan",
DeviceName: "Desktop-LAN",
LanEndpoint: "http://192.168.1.10:52100",
WanEndpoint: null, // WAN 미설정 상태
AuthToken: "tok",
ConnectCode: null
);
var roaming = new RoamingConnectionManager(profile);
roaming.UpdateNetworkState(NetworkEnvironment.CellularWan);
// WAN이 없으면 LAN 주소를 유지하되 오류 상태 경고 플래그
Assert.Equal("http://192.168.1.10:52100", roaming.ActiveEndpoint);
Assert.True(roaming.RequiresWanSetup);
}
}

View file

@ -0,0 +1,74 @@
using System;
using Paca.Mobile;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// Gate 115: 모바일 웹 비디오 재생 엔진 무결성 감사 게이트
/// - 스마트 User-Agent 정제 (Version/4.0 및 ; wv 제거로 순수 모바일 Chrome 변환, 실제 기기명/크롬 버전 보존)
/// - MediaPlaybackBypassScript 안전성 (비디오 click 이벤트 강제 play 제거, window.chrome loadTimes/csi 더미 보장)
/// - SnifferScript 비침투성 (HTMLMediaElement.prototype.play 변조 배제, 안전한 이벤트 리스너 방식)
/// </summary>
public class MobileWebVideoPlaybackGateTests
{
[Fact]
public void Gate115_UserAgentSanitizer_RemovesWebViewTokens_PreservesRealModelAndVersion()
{
// 일반적인 안드로이드 시스템 WebView 기본 User-Agent
var rawWebViewUa = "Mozilla/5.0 (Linux; U; Android 14; ko-kr; SM-S928N Build/UP1A.231005.007; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/128.0.6613.127 Mobile Safari/537.36";
var sanitized = MobileUserAgentSanitizer.SanitizeMobile(rawWebViewUa);
// 1. 구글/유튜브가 인앱 브라우저로 감지하는 토큰 제거 확인
Assert.DoesNotContain("; wv", sanitized);
Assert.DoesNotContain("Version/4.0", sanitized);
// 2. 실제 디바이스 모델명 및 정확한 크롬 버전 보존 확인 (하드웨어 비디오 코덱 매핑 유지)
Assert.Contains("SM-S928N", sanitized);
Assert.Contains("Chrome/128.0.6613.127", sanitized);
Assert.Contains("Mobile Safari/537.36", sanitized);
}
[Fact]
public void Gate115_UserAgentSanitizer_HandlesFallbacksGracefully()
{
// 빈 문자열 또는 null 입력 시 안전한 기본 Chrome 모바일 UA 반환
var emptyResult = MobileUserAgentSanitizer.SanitizeMobile("");
Assert.StartsWith("Mozilla/5.0", emptyResult);
Assert.Contains("Mobile Safari/537.36", emptyResult);
Assert.DoesNotContain("Version/4.0", emptyResult);
// 이미 순수 모바일 브라우저 UA인 경우 원형 유지
var cleanUa = "Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36";
var result = MobileUserAgentSanitizer.SanitizeMobile(cleanUa);
Assert.Equal(cleanUa, result);
}
[Fact]
public void Gate115_MediaPlaybackBypassScript_HasNoHarmfulVideoClickInterference()
{
var js = MediaPlaybackBypassScript.BypassJs;
// 1. 유튜브 플레이어의 상태 머신을 망가뜨리는 click -> this.play() 리스너가 없어야 함
Assert.DoesNotContain("this.paused", js);
Assert.DoesNotContain("this.play()", js);
// 2. 구글/유튜브 스크립트가 호출하는 window.chrome 메트릭 API(loadTimes, csi)가 더미로 제공되어야 함
Assert.Contains("loadTimes", js);
Assert.Contains("csi", js);
}
[Fact]
public void Gate115_SnifferScript_NonIntrusiveMediaObservation()
{
var js = SnifferScript.InjectionJs;
// 1. HTMLMediaElement.prototype.play 를 직접 오버라이드하여 Promise/재생 체인을 망가뜨리지 않아야 함
Assert.DoesNotContain("HTMLMediaElement.prototype.play =", js);
// 2. URL.createObjectURL 안전 바인딩 또는 호출 보호가 있어야 함
Assert.DoesNotContain("apply(this, arguments)", js);
}
}

View file

@ -0,0 +1,57 @@
using System;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Mobile;
public class P2pQrPayloadCodecTests
{
[Fact]
public void Encode_And_Decode_Roundtrip_Succeeds()
{
var original = new P2pQrPayload
{
Host = "192.168.1.100",
Port = 5000,
Token = "secret-token-abcdef123456",
DeviceName = "Encep Desktop PC",
Fingerprint = "fp_9876543210"
};
string encoded = P2pQrPayloadCodec.Encode(original);
Assert.StartsWith("paca://pair?", encoded);
var decoded = P2pQrPayloadCodec.Decode(encoded);
Assert.NotNull(decoded);
Assert.Equal(original.Host, decoded.Host);
Assert.Equal(original.Port, decoded.Port);
Assert.Equal(original.Token, decoded.Token);
Assert.Equal(original.DeviceName, decoded.DeviceName);
Assert.Equal(original.Fingerprint, decoded.Fingerprint);
}
[Fact]
public void Decode_Invalid_Checksum_Throws()
{
var original = new P2pQrPayload
{
Host = "192.168.1.100",
Port = 5000,
Token = "secret-token",
DeviceName = "PC",
Fingerprint = "fp"
};
string encoded = P2pQrPayloadCodec.Encode(original);
// 체크섬 위조
string tampered = encoded.Replace("sig=", "sig=tampered");
Assert.Throws<InvalidOperationException>(() => P2pQrPayloadCodec.Decode(tampered));
}
[Fact]
public void Decode_NonPacaScheme_Throws()
{
Assert.Throws<ArgumentException>(() => P2pQrPayloadCodec.Decode("https://google.com"));
}
}