313 lines
14 KiB
C#
313 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 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;
|
|
}
|
|
}
|