feat(ads/seo/release): v1.2.0 with live Google Ads, isolated ad profile, ads.txt, and SEO/AEO/GEO optimization
This commit is contained in:
parent
a88aafa1e5
commit
0e568f1f0a
975 changed files with 130593 additions and 16783 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
115
tests/Paca.Tests/Mobile/MobileAndroidModernizationTests.cs
Normal file
115
tests/Paca.Tests/Mobile/MobileAndroidModernizationTests.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
39
tests/Paca.Tests/Mobile/MobileDeepLinkTargetGateTests.cs
Normal file
39
tests/Paca.Tests/Mobile/MobileDeepLinkTargetGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
270
tests/Paca.Tests/Mobile/MobileDesktopParityBrowserGateTests.cs
Normal file
270
tests/Paca.Tests/Mobile/MobileDesktopParityBrowserGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
194
tests/Paca.Tests/Mobile/MobileDesktopParityDownloadGateTests.cs
Normal file
194
tests/Paca.Tests/Mobile/MobileDesktopParityDownloadGateTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
151
tests/Paca.Tests/Mobile/MobileIntuitiveUxGateTests.cs
Normal file
151
tests/Paca.Tests/Mobile/MobileIntuitiveUxGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
197
tests/Paca.Tests/Mobile/MobileLanAutoDiscoveryGateTests.cs
Normal file
197
tests/Paca.Tests/Mobile/MobileLanAutoDiscoveryGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
82
tests/Paca.Tests/Mobile/MobileMediaIntegrityGateTests.cs
Normal file
82
tests/Paca.Tests/Mobile/MobileMediaIntegrityGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
118
tests/Paca.Tests/Mobile/MobileMenuContractGateTests.cs
Normal file
118
tests/Paca.Tests/Mobile/MobileMenuContractGateTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
81
tests/Paca.Tests/Mobile/MobileP2pAndMediaAdversarialTests.cs
Normal file
81
tests/Paca.Tests/Mobile/MobileP2pAndMediaAdversarialTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
127
tests/Paca.Tests/Mobile/MobilePacaDropBatchBundleGateTests.cs
Normal file
127
tests/Paca.Tests/Mobile/MobilePacaDropBatchBundleGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
165
tests/Paca.Tests/Mobile/MobilePrivateBrowsingGateTests.cs
Normal file
165
tests/Paca.Tests/Mobile/MobilePrivateBrowsingGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
102
tests/Paca.Tests/Mobile/MobileProductionFeaturesGateTests.cs
Normal file
102
tests/Paca.Tests/Mobile/MobileProductionFeaturesGateTests.cs
Normal 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
|
||||
}
|
||||
}
|
||||
55
tests/Paca.Tests/Mobile/MobileQrPairingUiGateTests.cs
Normal file
55
tests/Paca.Tests/Mobile/MobileQrPairingUiGateTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
44
tests/Paca.Tests/Mobile/MobileShareTextExtractorGateTests.cs
Normal file
44
tests/Paca.Tests/Mobile/MobileShareTextExtractorGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
124
tests/Paca.Tests/Mobile/MobileSmartClipboardSnifferGateTests.cs
Normal file
124
tests/Paca.Tests/Mobile/MobileSmartClipboardSnifferGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
107
tests/Paca.Tests/Mobile/MobileSwissMinimalExcellenceGateTests.cs
Normal file
107
tests/Paca.Tests/Mobile/MobileSwissMinimalExcellenceGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 엔티티(&, ", ', <, >) 복원
|
||||
/// </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(""안녕" & <세상>", "\"안녕\" & <세상>")]
|
||||
[InlineData("'PACA' & 브라우저", "'PACA' & 브라우저")]
|
||||
public void Gate513_HtmlEntityDecoding_RestoresEntities(string input, string expected)
|
||||
{
|
||||
var result = MobileTextEncodingSanitizer.CleanDecodedString(input);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
121
tests/Paca.Tests/Mobile/MobileWanGatewayRoamingGateTests.cs
Normal file
121
tests/Paca.Tests/Mobile/MobileWanGatewayRoamingGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
74
tests/Paca.Tests/Mobile/MobileWebVideoPlaybackGateTests.cs
Normal file
74
tests/Paca.Tests/Mobile/MobileWebVideoPlaybackGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
57
tests/Paca.Tests/Mobile/P2pQrPayloadCodecTests.cs
Normal file
57
tests/Paca.Tests/Mobile/P2pQrPayloadCodecTests.cs
Normal 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"));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue