using System; using System.IO; using System.Text.RegularExpressions; using System.Threading.Tasks; using Paca.Mobile; using Paca.Mobile.Core.Sync; using Paca.Tests.Infrastructure; using Xunit; namespace Paca.Tests.Mobile; /// /// 모바일 컴패니언 앱(Paca.Mobile / Paca.Mobile.Core) 전용 RED/GREEN 파이프라인 확장 스위트. /// 1. 모바일 아키텍처 및 TFM/단일 프로젝트 빌드 설정 검증 /// 2. Designpaca 모바일 디자인 토큰, WCAG 2.1 대비율, 터치 타겟(48dp) 무결성 검증 /// 3. 페이지 계층 및 UI 컴포넌트 구조 검증 (Connect, Queue, Library, Downloads, Player) /// 4. ApiClient REST 프로토콜 & 모바일 백그라운드 동기화(MobileSyncManager) 무결성 검증 /// 5. 오프라인 스토리지(OfflineStore) 파일 격리 및 원자적 관리 검증 /// 6. 안드로이드 공유 시트(Share Intent) 페이로드 파서 및 엣지 케이스 검증 /// public class MobilePipelineTests { 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 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"); private static string MobileCsprojPath => Path.Combine(MobileProjectDir, "Paca.Mobile.csproj"); private static string MobileCoreCsprojPath => Path.Combine(MobileCoreProjectDir, "Paca.Mobile.Core.csproj"); // ========================================================================= // 1. 모바일 아키텍처 & TFM / 프로젝트 설정 게이트 // ========================================================================= [Fact] public void Architecture_MobileProjects_ExistAndConfigured() { Assert.True(File.Exists(MobileCsprojPath), "Paca.Mobile.csproj 파일이 존재해야 합니다."); Assert.True(File.Exists(MobileCoreCsprojPath), "Paca.Mobile.Core.csproj 파일이 존재해야 합니다."); var mobileCsproj = File.ReadAllText(MobileCsprojPath); Assert.Contains("UseMaui", mobileCsproj); Assert.Contains("SingleProject", mobileCsproj); Assert.Contains("net10.0-android", mobileCsproj); Assert.Contains("ApplicationTitle", mobileCsproj); Assert.Contains("com.companyname.paca.mobile", mobileCsproj); var coreCsproj = File.ReadAllText(MobileCoreCsprojPath); Assert.Contains("net8.0", coreCsproj); } [Fact] public void Architecture_MobileCore_HasNoPlatformSpecificUiCoupling() { var coreFiles = Directory.GetFiles(MobileCoreProjectDir, "*.cs", SearchOption.AllDirectories); foreach (var file in coreFiles) { var content = File.ReadAllText(file); Assert.DoesNotContain("using Microsoft.Maui.Controls;", content); Assert.DoesNotContain("using Android.", content); Assert.DoesNotContain("using UIKit;", content); } } // ========================================================================= // 2. Designpaca 모바일 디자인 토큰 & WCAG 명도 대비 & 터치 타겟 게이트 // ========================================================================= [Theory] [InlineData("#0B0C10", "#FFFFFF", 19.0)] // Dark canvas vs Pure White text -> AAA (>= 7.0:1) [InlineData("#0B0C10", "#E2E8F0", 15.0)] // Dark canvas vs TextPrimary -> AAA (>= 7.0:1) [InlineData("#0B0C10", "#94A3B8", 7.0)] // Dark canvas vs TextSecondary -> AAA (>= 7.0:1) [InlineData("#161822", "#FFFFFF", 16.0)] // Surface1 vs White -> AAA (>= 7.0:1) [InlineData("#5E56E8", "#FFFFFF", 4.5)] // Accent button vs White text -> AA (>= 4.5:1) [InlineData("#222438", "#FFFFFF", 14.0)] // Card/Frame vs White text -> AAA (>= 7.0:1) public void MobileDesign_WcagContrast_MeetsStandard(string bgHex, string fgHex, double minExpectedRatio) { var ratio = CalculateContrastRatio(bgHex, fgHex); Assert.True(ratio >= minExpectedRatio, $"[WCAG Gate] {bgHex} 배경 위 {fgHex} 전경의 명도 대비({ratio:F2}:1)가 최소 기준({minExpectedRatio:F2}:1)에 미달합니다."); } [Fact] public void MobileDesign_InteractiveElements_MeetMinimumTouchTarget() { // 모바일 가이드라인: 인터랙티브 버튼/입력창의 터치 타겟은 최소 44~48dp 이상 const double MinimumTouchTargetDp = 44.0; var pagesCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Pages.cs")); // Button 또는 Entry와 결합된 HeightRequest 검증 var buttonHeightMatches = Regex.Matches(pagesCs, @"new\s+(Button|Entry)\s*\{[^}]*HeightRequest\s*=\s*(\d+)"); foreach (Match match in buttonHeightMatches) { var controlType = match.Groups[1].Value; var height = double.Parse(match.Groups[2].Value); Assert.True(height >= MinimumTouchTargetDp, $"[Touch Target Gate] Pages.cs의 {controlType} HeightRequest={height}dp는 최소 터치 타겟({MinimumTouchTargetDp}dp) 미만입니다."); } } // ========================================================================= // 3. 페이지 계층 및 UI 구조 게이트 // ========================================================================= [Fact] public void Pages_TabbedRoot_Has4CoreTabs() { var rootCs = File.ReadAllText(Path.Combine(MobileProjectDir, "TabbedRootPage.cs")); Assert.Contains("QueuePage", rootCs); Assert.Contains("LibraryPage", rootCs); Assert.Contains("DownloadsPage", rootCs); Assert.Contains("ConnectPage", rootCs); Assert.Contains("Children.Add", rootCs); } [Fact] public void Pages_ConnectPage_ContainsQrAndManualInputs() { var pagesCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Pages.cs")); Assert.Contains("CameraBarcodeReaderView", pagesCs); Assert.Contains("PC 주소", pagesCs); Assert.Contains("수동 연결", pagesCs); Assert.Contains("연결 해제", pagesCs); } [Fact] public void Pages_QueuePage_ContainsInputAndPollingControls() { var pagesCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Pages.cs")); Assert.Contains("PC에서 받기", pagesCs); Assert.Contains("QueueCompletionDetector", pagesCs); Assert.Contains("ConnectionTroubleshoot", pagesCs); } [Fact] public void Pages_LibraryPage_ContainsStreamAndOfflineSave() { var pagesCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Pages.cs")); Assert.Contains("PlayerPage", pagesCs); Assert.Contains("SaveOffline", pagesCs); } [Fact] public void Pages_DownloadsPage_ContainsListAndPlayDelete() { var targetFile = File.Exists(Path.Combine(MobileProjectDir, "DownloadsPage.cs")) ? Path.Combine(MobileProjectDir, "DownloadsPage.cs") : Path.Combine(MobileProjectDir, "Pages.cs"); var content = File.ReadAllText(targetFile); Assert.Contains("AppOfflineStore", content); Assert.True(content.Contains("저장된 항목이 없습니다") || content.Contains("저장된 비디오가 없습니다")); Assert.True(content.Contains("삭제") || content.Contains("Delete") || content.Contains("🗑️")); } // ========================================================================= // 4. REST 프로토콜 계약 & MobileSyncManager 무결성 게이트 // ========================================================================= [Fact] public void ApiProtocol_MobileClientContract_MatchesServerEndpoints() { 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); Assert.Contains("/stream", apiClientCs); } [Fact] public void MobileSyncManager_DefaultConfig_ValidAndSecured() { var manager = new MobileSyncManager(); Assert.NotNull(manager.Config); Assert.NotNull(manager.Status); Assert.True(manager.Config.AutoSyncOnWifi); Assert.True(manager.Config.RequireBiometrics); Assert.False(manager.Status.IsSyncing); } [Fact] public void MobileSyncManager_WifiSsidPolicy_EnforcedCorrectly() { var config = new MobileSyncConfig { AutoSyncOnWifi = true, TargetSsid = "HomeNetwork_5G" }; var manager = new MobileSyncManager(config); // LTE/5G 모바일 데이터 시 동기화 거부 Assert.False(manager.CanSyncOnNetwork("HomeNetwork_5G", isWifi: false)); // 다른 Wi-Fi SSID 시 동기화 거부 Assert.False(manager.CanSyncOnNetwork("Public_Cafe_WiFi", isWifi: true)); // 일치하는 홈 Wi-Fi 시 허용 Assert.True(manager.CanSyncOnNetwork("HomeNetwork_5G", isWifi: true)); } [Fact] public async Task MobileSyncManager_TriggerSync_UpdatesStatusAndCount() { var manager = new MobileSyncManager(); Assert.Null(manager.Status.LastSyncUtc); Assert.Equal(0, manager.Status.CompletedDownloadsCount); var success = await manager.TriggerSyncAsync(async () => { await Task.Delay(10); return 3; // 3 items synced }); Assert.True(success); Assert.False(manager.Status.IsSyncing); Assert.NotNull(manager.Status.LastSyncUtc); Assert.Equal(3, manager.Status.CompletedDownloadsCount); Assert.Equal("", manager.Status.LastError); } // ========================================================================= // 5. 오프라인 스토리지(OfflineStore) 무결성 & 복원력 // ========================================================================= [Fact] public void OfflineStore_DirectoryIsolationAndSanitization() { var tempDir = Path.Combine(Path.GetTempPath(), $"paca_offline_test_{Guid.NewGuid():N}"); try { var store = new OfflineStore(tempDir); Assert.Empty(store.List()); // 경로 계산 검증 var path = store.PathFor("safe_id_100"); Assert.StartsWith(tempDir, path); Assert.EndsWith("safe_id_100.mp4", path); // 파일 생성 및 기록 Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllBytes(path, new byte[512]); store.Record("safe_id_100", "my_special_video.mp4"); Assert.True(store.Exists("safe_id_100")); var list = store.List(); Assert.Single(list); Assert.Equal("safe_id_100", list[0].Id); Assert.Equal("my_special_video.mp4", list[0].FileName); Assert.Equal(512L, list[0].SizeBytes); // 삭제 라운드트립 store.Delete("safe_id_100"); Assert.False(store.Exists("safe_id_100")); Assert.Empty(store.List()); } finally { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, recursive: true); } } // ========================================================================= // 6. 안드로이드 공유 시트(Share Intent) 페이로드 파서 엣지 케이스 // ========================================================================= [Theory] [InlineData("https://www.youtube.com/watch?v=dQw4w9WgXcQ", "https://www.youtube.com/watch?v=dQw4w9WgXcQ")] [InlineData("Check this out! https://twitter.com/user/status/123456789 (via Mobile)", "https://twitter.com/user/status/123456789")] [InlineData("http://localhost:5000/stream.m3u8?token=abc#t=10", "http://localhost:5000/stream.m3u8?token=abc#t=10")] [InlineData("여러 줄 텍스트 첫 번째\nhttps://vimeo.com/987654321\n두 번째 줄", "https://vimeo.com/987654321")] public void SharePayload_ExtractsValidUrlsFromMessyInputs(string rawShareText, string expectedUrl) { var extracted = SharePayload.ExtractUrl(rawShareText); Assert.Equal(expectedUrl, extracted); } [Theory] [InlineData("공유 텍스트이지만 URL이 전혀 없습니다.")] [InlineData("ftp://unsupported-scheme.com/file.mp4")] [InlineData(" \t\r\n ")] [InlineData(null)] public void SharePayload_ReturnsNullForInvalidOrMissingUrls(string? invalidShareText) { var extracted = SharePayload.ExtractUrl(invalidShareText); Assert.Null(extracted); } // ── 헬퍼 메서드 ── private static double CalculateContrastRatio(string hex1, string hex2) { var l1 = RelativeLuminance(hex1); var l2 = RelativeLuminance(hex2); var lighter = Math.Max(l1, l2); var darker = Math.Min(l1, l2); return (lighter + 0.05) / (darker + 0.05); } private static double RelativeLuminance(string hex) { hex = hex.TrimStart('#'); if (hex.Length == 8) hex = hex[2..]; // ARGB -> RGB var r = Convert.ToInt32(hex[..2], 16) / 255.0; var g = Convert.ToInt32(hex.Substring(2, 2), 16) / 255.0; var b = Convert.ToInt32(hex.Substring(4, 2), 16) / 255.0; r = (r <= 0.03928) ? r / 12.92 : Math.Pow((r + 0.055) / 1.055, 2.4); g = (g <= 0.03928) ? g / 12.92 : Math.Pow((g + 0.055) / 1.055, 2.4); b = (b <= 0.03928) ? b / 12.92 : Math.Pow((b + 0.055) / 1.055, 2.4); return 0.2126 * r + 0.7152 * g + 0.0722 * b; } }