releases/tests/Paca.Tests/Mobile/MobilePipelineTests.cs
Yun Chan f1949024fc feat(mobile): advance Paca.Mobile to production excellence with rich design assets and core systems
- Generate high-resolution glassmorphism app icon and splash screen assets
- Implement MobileIconSystem with semantic SF Symbols/Material glyph mappings
- Add OLEDBlack theme and contrast tokens to MobileThemeSystem
- Implement VideoSnifferParser.NegotiateBestQuality for multi-stream quality resolution
- Enhance DiscoverPage, FavoritesPage, and SyncSettingsPage with production controls
- Add Gate 103-114 test suites with 100% test pass rate
2026-09-03 22:13:54 +09:00

310 lines
14 KiB
C#

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;
/// <summary>
/// 모바일 컴패니언 앱(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) 페이로드 파서 및 엣지 케이스 검증
/// </summary>
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 apiClientCs = File.ReadAllText(Path.Combine(MobileProjectDir, "ApiClient.cs"));
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;
}
}