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
This commit is contained in:
Yun Chan 2026-09-03 22:13:54 +09:00
parent dbc50abb71
commit f1949024fc
81 changed files with 8611 additions and 0 deletions

View file

@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// 로컬 기기 디스커버리, 원격 클립보드, 절전/데이터 가드 및 복원성 게이트 (Gate 109 ~ Gate 114)
/// </summary>
public class MobileAdvancedConnectivityGateTests
{
[Fact]
public void Gate109_PacaHostBeacon_Discovery_Audit()
{
var beacon = new PacaHostBeacon();
var beaconPayload = "PACA_BEACON|DESKTOP-PACA|192.168.1.100:52100|token123";
var host = beacon.ParseBeacon(beaconPayload);
Assert.NotNull(host);
Assert.Equal("DESKTOP-PACA", host.HostName);
Assert.Equal("192.168.1.100:52100", host.Endpoint);
Assert.Equal("token123", host.AuthToken);
}
[Fact]
public void Gate110_CrossDeviceClipboardSync_Audit()
{
var clipSync = new CrossDeviceClipboardSync();
string? receivedUrl = null;
clipSync.OnMediaUrlReceived += url => receivedUrl = url;
// PC로부터 수신된 클립보드 패킷 처리
clipSync.ProcessIncomingPayload("https://twitter.com/test/status/123456");
Assert.Equal("https://twitter.com/test/status/123456", receivedUrl);
// 비미디어 텍스트는 필터링되어 무시되어야 함
receivedUrl = null;
clipSync.ProcessIncomingPayload("Hello this is not a media url");
Assert.Null(receivedUrl);
}
[Fact]
public void Gate111_MobilePowerSaverPolicy_Audit()
{
var policy = new MobilePowerSaverPolicy();
// 기본 6 세그먼트
Assert.Equal(6, policy.GetRecommendedSegments(batteryLevelPercent: 80, isCharging: false));
// 배터리 15% 이하 저전력 모드 -> 2개로 제한
Assert.Equal(2, policy.GetRecommendedSegments(batteryLevelPercent: 12, isCharging: false));
// 충전 중일 때는 100% 터보 허용
Assert.Equal(16, policy.GetRecommendedSegments(batteryLevelPercent: 12, isCharging: true));
}
[Fact]
public void Gate112_MobileDataSaverGuard_Audit()
{
var dataGuard = new MobileDataSaverGuard();
// 100MB 기본 임계값
Assert.Equal(100_000_000L, dataGuard.CellularWarningThresholdBytes);
// Wi-Fi 상태에서는 대용량도 즉시 허용
Assert.True(dataGuard.CanDownload(fileSizeBytes: 500_000_000, isWifi: true));
// 셀룰러 상태에서 500MB는 경고 플래그
Assert.False(dataGuard.CanDownload(fileSizeBytes: 500_000_000, isWifi: false));
// 셀룰러 상태에서 50MB는 허용
Assert.True(dataGuard.CanDownload(fileSizeBytes: 50_000_000, isWifi: false));
}
[Fact]
public void Gate113_MobileThumbnailCacheManager_Audit()
{
var cache = new MobileThumbnailCacheManager();
Assert.Null(cache.Get("test-key"));
cache.Put("test-key", new byte[] { 0xFF, 0xD8, 0xFF });
var cached = cache.Get("test-key");
Assert.NotNull(cached);
Assert.Equal(3, cached.Length);
// 캐시 삭제
cache.Evict("test-key");
Assert.Null(cache.Get("test-key"));
}
[Fact]
public void Gate114_NetworkFlapping_Stress_Resilience_Audit()
{
var qm = new MobileQueueManager();
var guard = new MobileNetworkGuard(qm);
var item = qm.Enqueue("https://example.com/live.m3u8", "Live Stream");
item.Status = MobileQueueStatus.Downloading;
// 10회 연속 단절 및 재연결 플래핑 스트레스
for (int i = 0; i < 10; i++)
{
guard.OnNetworkLost();
Assert.Equal(MobileQueueStatus.PausedNetwork, item.Status);
guard.OnNetworkRestored(isWifi: (i % 2 == 0));
Assert.Equal(MobileQueueStatus.Downloading, item.Status);
}
}
}

View file

@ -0,0 +1,135 @@
using System;
using System.Collections.Generic;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// 모바일 네이티브 컨트롤, 햅틱, 타이포 스케일러, 볼트, 오리엔테이션 게이트 (Gate 115 ~ Gate 122)
/// </summary>
public class MobileAdvancedNativeGateTests
{
[Fact]
public void Gate115_MobileTypographyScaler_Audit()
{
var scaler = new MobileTypographyScaler();
// 기본 1.0x 스케일
Assert.Equal(14.0, scaler.GetScaledFontSize(baseFontSize: 14.0, textScaleFactor: 1.0));
// 1.5x 접근성 큰 글꼴
Assert.Equal(21.0, scaler.GetScaledFontSize(baseFontSize: 14.0, textScaleFactor: 1.5));
// 최대 상한선(2.0x 초과 방지 클램핑)
Assert.Equal(28.0, scaler.GetScaledFontSize(baseFontSize: 14.0, textScaleFactor: 2.5));
}
[Fact]
public void Gate116_MobileOrientationSensor_Landscape_Audit()
{
var sensor = new MobileOrientationSensor();
Assert.Equal(MobileScreenOrientation.Portrait, sensor.CurrentOrientation);
Assert.False(sensor.IsFullscreenVideo);
// 가로 회전 감지
sensor.OnOrientationChanged(MobileScreenOrientation.Landscape);
Assert.Equal(MobileScreenOrientation.Landscape, sensor.CurrentOrientation);
Assert.True(sensor.IsFullscreenVideo);
// 컨트롤 자동 숨김 타이머 (3초)
Assert.Equal(3000, sensor.ControlsAutoHideTimeoutMs);
}
[Fact]
public void Gate117_MobileHapticFeedback_Audit()
{
var haptic = new MobileHapticFeedback();
HapticFeedbackType? triggered = null;
haptic.OnHapticTriggered += type => triggered = type;
haptic.NotifySuccess();
Assert.Equal(HapticFeedbackType.Success, triggered);
haptic.NotifyError();
Assert.Equal(HapticFeedbackType.Error, triggered);
haptic.NotifySelectionChange();
Assert.Equal(HapticFeedbackType.LightClick, triggered);
}
[Fact]
public void Gate118_MobileQueueReorderEngine_Audit()
{
var qm = new MobileQueueManager();
var item1 = qm.Enqueue("https://example.com/1.mp4", "Video 1");
var item2 = qm.Enqueue("https://example.com/2.mp4", "Video 2");
var item3 = qm.Enqueue("https://example.com/3.mp4", "Video 3");
var reorder = new MobileQueueReorderEngine(qm);
// item3을 맨 앞으로 이동 (index 2 -> 0)
reorder.MoveItem(fromIndex: 2, toIndex: 0);
Assert.Equal(item3.Id, qm.Items[0].Id);
Assert.Equal(item1.Id, qm.Items[1].Id);
Assert.Equal(item2.Id, qm.Items[2].Id);
}
[Fact]
public void Gate119_MobileBackgroundWorker_Notification_Audit()
{
var worker = new MobileBackgroundWorker();
Assert.False(worker.IsForegroundServiceRunning);
worker.StartService("Paca 다운로드 진행 중", totalActiveDownloads: 2);
Assert.True(worker.IsForegroundServiceRunning);
Assert.Equal("Paca 다운로드 진행 중 (2개 활성)", worker.LastNotificationTitle);
worker.StopService();
Assert.False(worker.IsForegroundServiceRunning);
}
[Fact]
public void Gate120_MobileMediaVault_Encryption_Audit()
{
var vault = new MobileMediaVault();
var plainBytes = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
var password = "SafePassword123!";
// 암호화
var encrypted = vault.EncryptMedia(plainBytes, password);
Assert.NotEqual(plainBytes, encrypted);
// 복호화
var decrypted = vault.DecryptMedia(encrypted, password);
Assert.Equal(plainBytes, decrypted);
}
[Fact]
public void Gate121_MobileAdBlockEngine_EasyList_Audit()
{
var adBlock = new MobileAdBlockEngine();
Assert.True(adBlock.IsAdUrl("https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"));
Assert.True(adBlock.IsAdUrl("https://adservice.google.com/adsid/google/ui"));
Assert.True(adBlock.IsAdUrl("https://securepubads.g.doubleclick.net/gampad/ads"));
// 정상 미디어 URL은 허용
Assert.False(adBlock.IsAdUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ"));
Assert.False(adBlock.IsAdUrl("https://vimeo.com/12345678"));
}
[Fact]
public void Gate122_MobileResolutionPicker_Estimation_Audit()
{
var picker = new MobileResolutionPicker();
// 1080p 60fps 비디오 10분(600초) 예상 용량 계산
var estimate1080p = picker.EstimateSizeAndDownloadTime(
durationSec: 600,
resolution: "1080p",
currentNetworkSpeedKbps: 10240 // 10MB/s (80Mbps)
);
Assert.True(estimate1080p.EstimatedSizeBytes > 0);
Assert.True(estimate1080p.EstimatedDurationSec > 0);
Assert.Equal("1080p Full HD", estimate1080p.ResolutionLabel);
}
}

View file

@ -0,0 +1,237 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Paca.Mobile;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// PACA Mobile Browser Core 단위 및 도메인 스펙 검증 스위트
/// - 탭 관리자 (생성, 전환, 닫기, 시크릿 모드, 세션 영속화)
/// - 로컬 방문 기록 (추가, 검색, 삭제, 원자적 저장)
/// - 북마크 관리자 (추가, 중복 갱신, 삭제, 조회)
/// - 비디오 스니퍼 (M3U8/MP4/DASH/Blob 네트워크 및 DOM JSON 파서)
/// - 인앱 다운로드 엔진 (작업 큐, 상태 머신)
/// </summary>
public class MobileBrowserCoreTests : IDisposable
{
private readonly string _testTempDir;
public MobileBrowserCoreTests()
{
_testTempDir = Path.Combine(Path.GetTempPath(), "paca_mobile_tests_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_testTempDir);
}
public void Dispose()
{
try
{
if (Directory.Exists(_testTempDir))
Directory.Delete(_testTempDir, recursive: true);
}
catch { }
}
// =========================================================================
// 1. 모바일 탭 관리자 (MobileTabManager)
// =========================================================================
[Fact]
public void TabManager_InitialState_HasSingleNewTab()
{
var manager = new MobileTabManager();
Assert.Equal(1, manager.Count);
Assert.NotNull(manager.ActiveTab);
Assert.True(manager.ActiveTab!.IsNewTabPage);
}
[Fact]
public void TabManager_AddAndSwitchTab_UpdatesActiveTabCorrectly()
{
var manager = new MobileTabManager();
var tab2 = manager.AddTab("https://m.youtube.com", isPrivate: false);
Assert.Equal(2, manager.Count);
Assert.Equal(tab2.Id, manager.ActiveTab?.Id);
Assert.Equal("https://m.youtube.com", manager.ActiveTab?.Url);
var firstTabId = manager.Tabs[0].Id;
var switched = manager.SwitchTab(firstTabId);
Assert.True(switched);
Assert.Equal(firstTabId, manager.ActiveTab?.Id);
}
[Fact]
public void TabManager_CloseTab_MaintainsValidActiveTab()
{
var manager = new MobileTabManager();
var tab2 = manager.AddTab("https://site2.com");
var tab3 = manager.AddTab("https://site3.com");
Assert.Equal(3, manager.Count);
Assert.Equal(tab3.Id, manager.ActiveTab?.Id);
// 마지막 탭 닫기 -> 바로 이전 탭(tab2)이 활성화되어야 함
manager.CloseTab(tab3.Id);
Assert.Equal(2, manager.Count);
Assert.Equal(tab2.Id, manager.ActiveTab?.Id);
// 모든 탭 닫기 -> 자동으로 새 탭 1개 생성
manager.CloseAllTabs();
Assert.Equal(1, manager.Count);
Assert.True(manager.ActiveTab?.IsNewTabPage);
}
[Fact]
public async Task TabManager_SessionPersistence_IgnoresPrivateTabsAndRestoresState()
{
var sessionFile = Path.Combine(_testTempDir, "tabs_session.json");
var manager = new MobileTabManager();
manager.AddTab("https://public-site1.com", isPrivate: false);
manager.AddTab("https://secret-site.com", isPrivate: true); // InPrivate
await manager.SaveSessionAsync(sessionFile);
Assert.True(File.Exists(sessionFile));
var restoredManager = new MobileTabManager();
await restoredManager.RestoreSessionAsync(sessionFile);
// InPrivate 탭은 저장되지 않아야 함
Assert.DoesNotContain(restoredManager.Tabs, t => t.Url == "https://secret-site.com");
Assert.Contains(restoredManager.Tabs, t => t.Url == "https://public-site1.com");
}
// =========================================================================
// 2. 방문 기록 영속 저장소 (MobileHistoryStore)
// =========================================================================
[Fact]
public async Task HistoryStore_AddVisitAndSearch_RanksByRecencyAndFiltersKeyword()
{
var historyFile = Path.Combine(_testTempDir, "history.json");
var store = new MobileHistoryStore(historyFile);
await store.AddVisitAsync("https://m.youtube.com/watch?v=123", "YouTube Video 1");
await store.AddVisitAsync("https://news.naver.com", "네이버 뉴스");
await store.AddVisitAsync("https://m.youtube.com/watch?v=456", "YouTube Video 2");
var recents = store.GetRecent(10);
Assert.Equal(3, recents.Count);
Assert.Equal("YouTube Video 2", recents[0].Title);
var searchResults = store.Search("youtube");
Assert.Equal(2, searchResults.Count);
// 중복 방문 시 VisitCount 증가
await store.AddVisitAsync("https://news.naver.com", "네이버 뉴스 - 갱신");
var naverItem = store.Search("naver").First();
Assert.Equal(2, naverItem.VisitCount);
Assert.Equal("네이버 뉴스 - 갱신", naverItem.Title);
}
[Fact]
public async Task HistoryStore_DeleteItemAndClearAll_SavesAtomically()
{
var historyFile = Path.Combine(_testTempDir, "history.json");
var store = new MobileHistoryStore(historyFile);
await store.AddVisitAsync("https://site1.com", "Site 1");
await store.AddVisitAsync("https://site2.com", "Site 2");
var itemToDelete = store.GetRecent().First(i => i.Url == "https://site1.com");
var deleted = await store.DeleteAsync(itemToDelete.Id);
Assert.True(deleted);
// 다시 로드하여 영속화 확인
var storeReloaded = new MobileHistoryStore(historyFile);
Assert.Single(storeReloaded.GetRecent());
await storeReloaded.ClearAllAsync();
Assert.Empty(storeReloaded.GetRecent());
}
// =========================================================================
// 3. 북마크 영속 저장소 (MobileBookmarkStore)
// =========================================================================
[Fact]
public async Task BookmarkStore_AddUpdateAndRemove_WorksAccurately()
{
var bookmarkFile = Path.Combine(_testTempDir, "bookmarks.json");
var store = new MobileBookmarkStore(bookmarkFile);
Assert.False(store.IsBookmarked("https://github.com"));
await store.AddOrUpdateBookmarkAsync("https://github.com", "GitHub", "개발");
Assert.True(store.IsBookmarked("https://github.com"));
var all = store.GetAll();
Assert.Single(all);
Assert.Equal("개발", all[0].Folder);
// 삭제
var removed = await store.RemoveByUrlAsync("https://github.com");
Assert.True(removed);
Assert.False(store.IsBookmarked("https://github.com"));
}
// =========================================================================
// 4. 비디오 스니퍼 파서 (VideoSnifferParser)
// =========================================================================
[Theory]
[InlineData("https://cdn.example.com/video/master.m3u8?token=xyz", "application/x-mpegURL", VideoStreamType.Hls)]
[InlineData("https://cdn.example.com/video/stream.mpd", "application/dash+xml", VideoStreamType.Dash)]
[InlineData("https://cdn.example.com/files/sample.mp4?dl=1", "video/mp4", VideoStreamType.Mp4)]
[InlineData("https://cdn.example.com/stream.webm", "video/webm", VideoStreamType.Direct)]
[InlineData("blob:https://m.youtube.com/83274-1234", null, VideoStreamType.Blob)]
public void VideoSniffer_NetworkStreams_ClassifiedCorrectly(string url, string? mime, VideoStreamType expectedType)
{
var detected = VideoSnifferParser.TrySniffNetworkStream(url, mime, null, out var item);
Assert.True(detected);
Assert.NotNull(item);
Assert.Equal(expectedType, item!.VideoType);
}
[Fact]
public void VideoSniffer_AdsAndFragments_Ignored()
{
var adUrl = "https://googleads.g.doubleclick.net/pagead/ads?video_id=123";
var isVideo = VideoSnifferParser.TrySniffNetworkStream(adUrl, "video/mp4", null, out _);
Assert.False(isVideo);
var segUrl = "https://cdn.example.com/segment123.ts";
var isSeg = VideoSnifferParser.TrySniffNetworkStream(segUrl, "video/mp2t", null, out _);
Assert.False(isSeg);
}
[Fact]
public void VideoSniffer_DomJsonPayload_ParsesMetadataAccurately()
{
var domJson = @"[
{
""src"": ""https://video.example.com/high_1080p.m3u8"",
""title"": ""모바일 테스트 비디오"",
""width"": 1920,
""height"": 1080,
""duration"": 145.5,
""poster"": ""https://video.example.com/thumb.jpg""
}
]";
var items = VideoSnifferParser.ParseDomPayload(domJson, "https://example.com/watch");
Assert.Single(items);
var video = items[0];
Assert.Equal("https://video.example.com/high_1080p.m3u8", video.StreamUrl);
Assert.Equal(VideoStreamType.Hls, video.VideoType);
Assert.Equal("모바일 테스트 비디오", video.Title);
Assert.Equal("1920x1080", video.Resolution);
Assert.Equal(145.5, video.DurationSeconds);
Assert.Equal("https://video.example.com/thumb.jpg", video.ThumbnailUrl);
}
}

View file

@ -0,0 +1,129 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Paca.Core.Config;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// 모바일 & 데스크톱 융합 복합 E2E 사용자 여정 시나리오 (Gate 98 ~ Gate 102)
/// </summary>
public class MobileComplexJourneyTests
{
[Fact]
public void Gate98_Journey1_Discover_To_Enqueue_To_Pin_To_TurboDownload_Audit()
{
// 1. 모바일에서 YouTube URL 탐색
var url = "https://www.youtube.com/watch?v=sample123";
var detection = MobileDiscoveryEngine.DetectPlatform(url);
Assert.True(detection.IsSupported);
Assert.Equal("YouTube", detection.PlatformName);
// 2. 큐 관리자에 등록
var qm = new MobileQueueManager();
var item1 = qm.Enqueue("https://example.com/other.mp4", "Other Video");
var item2 = qm.Enqueue(url, "Trending Sample Video");
// 3. 중요한 항목 상단 고정 (Pin to Top)
qm.Pin(item2.Id);
Assert.True(item2.IsPinned);
Assert.Equal(item2.Id, qm.Items[0].Id); // 0번째 인덱스로 승격
// 4. 터보 모드 설정 연계
var cfg = new AppConfig();
cfg.TurboModeEnabled = true;
cfg.MaxConcurrentSegments = 16;
Assert.True(cfg.TurboModeEnabled);
Assert.Equal(16, cfg.MaxConcurrentSegments);
}
[Fact]
public void Gate99_Journey2_NetworkLost_To_GuardPause_To_RestoredResume_Audit()
{
var qm = new MobileQueueManager();
var item1 = qm.Enqueue("https://example.com/v1.mp4", "Video 1");
var item2 = qm.Enqueue("https://example.com/v2.mp4", "Video 2");
item1.Status = MobileQueueStatus.Downloading;
item2.Status = MobileQueueStatus.Downloading;
var guard = new MobileNetworkGuard(qm);
// 엘리베이터 진입으로 LTE/Wi-Fi 신호 상실
guard.OnNetworkLost();
Assert.Equal(MobileQueueStatus.PausedNetwork, item1.Status);
Assert.Equal(MobileQueueStatus.PausedNetwork, item2.Status);
// Wi-Fi 재연결 감지 시 자동 재개
guard.OnNetworkRestored(isWifi: true);
Assert.Equal(MobileQueueStatus.Downloading, item1.Status);
Assert.Equal(MobileQueueStatus.Downloading, item2.Status);
}
[Fact]
public void Gate100_Journey3_PcTabSync_To_RemoteDownload_To_FavoritesStash_Audit()
{
// 1. PC에서 열린 탭 동기화 수신
var syncEngine = new MobileTabSyncEngine();
var pcTabs = new List<RemoteTabInfo>
{
new("tab-101", "Spring Concert Live", "https://vimeo.com/998877")
};
syncEngine.UpdateRemoteTabs(pcTabs);
Assert.Single(syncEngine.RemoteTabs);
// 2. 모바일에서 원격 다운로드 요청
string? downloadedUrl = null;
syncEngine.OnRemoteDownloadRequested += u => downloadedUrl = u;
syncEngine.RequestRemoteDownload("tab-101");
Assert.Equal("https://vimeo.com/998877", downloadedUrl);
// 3. 완료 후 모바일 보관함(Stash) 및 즐겨찾기에 등록
var favManager = new MobileFavoritesManager();
var fav = favManager.AddFavorite(downloadedUrl!, "Spring Concert Live", "음악");
favManager.ToggleStash(fav.Id);
Assert.True(fav.IsStashedForDownload);
Assert.Equal("음악", fav.Tag);
}
[Fact]
public void Gate101_Journey4_Deduplication_To_FailureRetry_Lifecycle_Audit()
{
var qm = new MobileQueueManager();
var item = qm.Enqueue("https://example.com/stream.m3u8", "HLS Live Stream");
item.Status = MobileQueueStatus.Downloading;
// 서버 503 에러로 실패 발생
item.Status = MobileQueueStatus.Failed;
item.ErrorMessage = "HTTP 503 Service Unavailable";
// 사용자 실패 항목 재시도 트리거
var retried = qm.RetryFailedItems();
Assert.Equal(1, retried);
Assert.Equal(MobileQueueStatus.Queued, item.Status);
Assert.Null(item.ErrorMessage);
}
[Fact]
public void Gate102_Journey5_Preferences_Persistence_And_ThemeSwitch_Audit()
{
var settings = new MobileSettingsManager();
settings.ThemeMode = MobileThemeMode.Dark;
settings.SpeedLimitKbps = 5120; // 5MB/s
settings.WifiOnlyDownload = true;
settings.NotifyOnCompletion = true;
var palette = MobileThemeSystem.GetPalette(settings.ThemeMode);
Assert.Equal("#0B0C10", palette.Background);
Assert.Equal("#5E56E8", palette.Accent);
// 테마를 고대비로 전환
settings.ThemeMode = MobileThemeMode.HighContrast;
var hcPalette = MobileThemeSystem.GetPalette(settings.ThemeMode);
Assert.Equal("#000000", hcPalette.Background);
Assert.Equal("#FFFF00", hcPalette.Accent);
}
}

View file

@ -0,0 +1,142 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// 모바일 프로덕션 7대 핵심 기능 게이트 (Gate 87 ~ Gate 92)
/// </summary>
public class MobileCoreFeaturesGateTests
{
[Fact]
public void Gate87_MobileDiscoveryEngine_UrlParsing_And_PlatformDetection_Audit()
{
// YouTube, Vimeo, Twitter, 일반 미디어 감지
var yt = MobileDiscoveryEngine.DetectPlatform("https://www.youtube.com/watch?v=dQw4w9WgXcQ");
Assert.Equal("YouTube", yt.PlatformName);
Assert.True(yt.IsSupported);
var vm = MobileDiscoveryEngine.DetectPlatform("https://vimeo.com/12345678");
Assert.Equal("Vimeo", vm.PlatformName);
Assert.True(vm.IsSupported);
var general = MobileDiscoveryEngine.DetectPlatform("https://example.com/video.m3u8");
Assert.Equal("HLS Stream", general.PlatformName);
Assert.True(general.IsSupported);
var unknown = MobileDiscoveryEngine.DetectPlatform("https://not-a-video.org/page.html");
Assert.Equal("Web Page", unknown.PlatformName);
}
[Fact]
public void Gate88_MobileQueueManager_Control_And_Retry_Audit()
{
var qm = new MobileQueueManager();
var item1 = qm.Enqueue("https://example.com/v1.mp4", "Video 1");
var item2 = qm.Enqueue("https://example.com/v2.mp4", "Video 2");
Assert.Equal(2, qm.Items.Count);
// 일시정지 & 재개
qm.Pause(item1.Id);
Assert.Equal(MobileQueueStatus.Paused, item1.Status);
qm.Resume(item1.Id);
Assert.Equal(MobileQueueStatus.Downloading, item1.Status);
// 상단 고정 (Pin)
qm.Pin(item2.Id);
Assert.True(item2.IsPinned);
// 실패 상태 설정 후 일괄 재시도 검증
item1.Status = MobileQueueStatus.Failed;
var retriedCount = qm.RetryFailedItems();
Assert.Equal(1, retriedCount);
Assert.Equal(MobileQueueStatus.Queued, item1.Status);
// 완료 항목 정리
item2.Status = MobileQueueStatus.Completed;
qm.ClearCompleted();
Assert.Single(qm.Items);
}
[Fact]
public void Gate89_MobileFavoritesManager_Stash_And_Tagging_Audit()
{
var fm = new MobileFavoritesManager();
var fav = fm.AddFavorite("https://example.com/movie.mp4", "Great Movie", "영화");
Assert.Single(fm.Favorites);
Assert.Equal("영화", fav.Tag);
Assert.False(fav.IsStashedForDownload);
// Stash(나중에 다운로드) 토글
fm.ToggleStash(fav.Id);
Assert.True(fav.IsStashedForDownload);
// 태그 검색 필터
var filtered = fm.GetByTag("영화");
Assert.Single(filtered);
}
[Fact]
public void Gate90_MobileTabSyncEngine_RemoteTabSync_Audit()
{
var sync = new MobileTabSyncEngine();
Assert.Empty(sync.RemoteTabs);
// PC 브라우저 탭 목록 동기화
var remoteTabs = new List<RemoteTabInfo>
{
new("tab-1", "GitHub Paca Repo", "https://github.com/test/paca"),
new("tab-2", "YouTube Video Stream", "https://youtube.com/watch?v=123")
};
sync.UpdateRemoteTabs(remoteTabs);
Assert.Equal(2, sync.RemoteTabs.Count);
Assert.Equal("GitHub Paca Repo", sync.RemoteTabs[0].Title);
// 모바일에서 원격 다운로드 요청 이벤트 트리거
string? requestedUrl = null;
sync.OnRemoteDownloadRequested += url => requestedUrl = url;
sync.RequestRemoteDownload("tab-2");
Assert.Equal("https://youtube.com/watch?v=123", requestedUrl);
}
[Fact]
public void Gate91_MobileSettingsManager_Preferences_Audit()
{
var settings = new MobileSettingsManager();
Assert.True(settings.AutoRetryOnReconnect);
Assert.False(settings.WifiOnlyDownload);
Assert.Equal(0, settings.SpeedLimitKbps);
Assert.Equal(MobileThemeMode.Dark, settings.ThemeMode);
settings.WifiOnlyDownload = true;
settings.SpeedLimitKbps = 2048;
settings.ThemeMode = MobileThemeMode.Light;
Assert.True(settings.WifiOnlyDownload);
Assert.Equal(2048, settings.SpeedLimitKbps);
Assert.Equal(MobileThemeMode.Light, settings.ThemeMode);
}
[Fact]
public void Gate92_MobileNetworkGuard_AutoResume_Audit()
{
var qm = new MobileQueueManager();
var guard = new MobileNetworkGuard(qm);
var item = qm.Enqueue("https://example.com/video.mp4", "Test Video");
item.Status = MobileQueueStatus.PausedNetwork;
// 네트워크 복구 이벤트 발생
guard.OnNetworkRestored(isWifi: true);
// 네트워크 단절로 멈췄던 항목이 자동으로 재개되어야 함
Assert.Equal(MobileQueueStatus.Downloading, item.Status);
}
}

View file

@ -0,0 +1,152 @@
using Paca.Mobile;
namespace Paca.Tests.Mobile;
/// <summary>Mobile.Core 순수 로직 — U5 완료 감지, 외부망 안내, U4 저장소(파일 시스템).</summary>
public class MobileCoreTests
{
// ── U5 완료 알림 감지 ──
[Fact]
public void 완료_전환_항목_ID_반환()
{
var d = new QueueCompletionDetector();
var prev = new[] { ("a", "다운로드 중"), ("b", "대기") };
var next = new[] { ("a", "완료"), ("b", "다운로드 중") };
Assert.Equal(new[] { "a" }, d.Detect(prev, next));
}
[Fact]
public void 이미_완료였던_항목은_재알림_없음()
{
var d = new QueueCompletionDetector();
var prev = new[] { ("a", "완료") };
var next = new[] { ("a", "완료") };
Assert.Empty(d.Detect(prev, next));
}
[Fact]
public void 첫_폴링에서_이미_완료된_항목은_무시() // 앱 시작 시 과거 완료 스팸 방지
{
var d = new QueueCompletionDetector();
Assert.Empty(d.Detect(Array.Empty<(string, string)>(), new[] { ("a", "완료") }));
}
[Fact]
public void 목록에서_사라진_항목은_알림_없음()
{
var d = new QueueCompletionDetector();
var prev = new[] { ("a", "다운로드 중") };
Assert.Empty(d.Detect(prev, Array.Empty<(string, string)>()));
}
// ── 외부망 안내(MOBILE_PLAN §7) ──
[Fact]
public void 미연결_안내()
{
var m = ConnectionTroubleshoot.Message(isConfigured: false, lastError: null);
Assert.Contains("연결", m);
}
[Fact]
public void 연결됐지만_실패_시_같은네트워크_VPN_안내()
{
var m = ConnectionTroubleshoot.Message(isConfigured: true, lastError: "No such host is known.");
Assert.Contains("같은 네트워크", m);
Assert.Contains("Tailscale", m); // 계획 §3: 자체 릴레이 대신 Tailscale 문서화
}
[Fact]
public void 정상_시_빈_문자열()
=> Assert.Equal("", ConnectionTroubleshoot.Message(isConfigured: true, lastError: null));
// ── M1 공유 타겟(U1): 공유 텍스트 → URL 추출 ──
[Theory]
[InlineData("https://youtu.be/abc123", "https://youtu.be/abc123")] // URL 만
[InlineData("재밌는 영상\nhttps://youtu.be/abc123", "https://youtu.be/abc123")] // 제목+URL (유튜브 공유 형식)
[InlineData("http://x.com/a?b=1 입니다", "http://x.com/a?b=1")] // http + 쿼리
[InlineData("a https://1.com b https://2.com", "https://1.com")] // 첫 번째 우선
public void 공유_텍스트에서_URL_추출(string text, string expected)
=> Assert.Equal(expected, SharePayload.ExtractUrl(text));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("URL 없는 그냥 텍스트")]
public void URL_없으면_null(string? text)
=> Assert.Null(SharePayload.ExtractUrl(text));
// ── U4 저장소(임시 디렉터리로 파일 시스템 검증) ──
[Fact]
public void OfflineStore_기록_목록_삭제_라운드트립()
{
var baseDir = Path.Combine(Path.GetTempPath(), $"vd_core_{Guid.NewGuid():N}");
var store = new OfflineStore(baseDir);
Assert.Empty(store.List());
var p = store.PathFor("id1");
Directory.CreateDirectory(Path.GetDirectoryName(p)!);
File.WriteAllBytes(p, new byte[123]);
store.Record("id1", "video.mp4");
Assert.True(store.Exists("id1"));
var list = store.List();
Assert.Single(list);
Assert.Equal(("id1", "video.mp4", 123L), (list[0].Id, list[0].FileName, list[0].SizeBytes));
store.Delete("id1");
Assert.False(store.Exists("id1"));
Assert.Empty(store.List());
Directory.Delete(baseDir, true);
}
[Fact]
public void OfflineStore_재시작_시_매니페스트_유지()
{
var baseDir = Path.Combine(Path.GetTempPath(), $"vd_core_{Guid.NewGuid():N}");
var store = new OfflineStore(baseDir);
var p = store.PathFor("id2");
Directory.CreateDirectory(Path.GetDirectoryName(p)!);
File.WriteAllBytes(p, new byte[10]);
store.Record("id2", "keep.mp4");
var reopened = new OfflineStore(baseDir); // 새 인스턴스 = 앱 재시작
Assert.True(reopened.Exists("id2"));
Assert.Equal("keep.mp4", reopened.List().Single().FileName);
Directory.Delete(baseDir, true);
}
[Fact]
public void MobileSyncManager_네트워크_SSID_및_WiFi_필터_검증()
{
var config = new Paca.Mobile.Core.Sync.MobileSyncConfig
{
AutoSyncOnWifi = true,
TargetSsid = "Home_5G"
};
var mgr = new Paca.Mobile.Core.Sync.MobileSyncManager(config);
Assert.True(mgr.CanSyncOnNetwork("Home_5G", isWifi: true));
Assert.False(mgr.CanSyncOnNetwork("Public_Cafe", isWifi: true));
Assert.False(mgr.CanSyncOnNetwork("Home_5G", isWifi: false)); // LTE/5G
}
[Fact]
public async Task MobileSyncManager_동기화_트리거_및_상태_갱신_검증()
{
var mgr = new Paca.Mobile.Core.Sync.MobileSyncManager();
Assert.Null(mgr.Status.LastSyncUtc);
bool ok = await mgr.TriggerSyncAsync(() => Task.FromResult(5));
Assert.True(ok);
Assert.NotNull(mgr.Status.LastSyncUtc);
Assert.Equal(5, mgr.Status.CompletedDownloadsCount);
Assert.Empty(mgr.Status.LastError);
}
}

View file

@ -0,0 +1,118 @@
using System;
using System.IO;
using Paca.Mobile.Core;
using Paca.Tests.Infrastructure;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// 모바일 프로덕션 레벨 디자인 시스템, 테마, 아이콘, 탭 및 에셋 무결성 게이트 (Gate 82 ~ Gate 86)
/// </summary>
public class MobileDesignSystemGateTests
{
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 Gate82_MobileThemeSystem_Themes_And_Tokens_Audit()
{
// 1. 테마 모드 정의 확인
Assert.True(Enum.IsDefined(typeof(MobileThemeMode), MobileThemeMode.Dark));
Assert.True(Enum.IsDefined(typeof(MobileThemeMode), MobileThemeMode.Light));
Assert.True(Enum.IsDefined(typeof(MobileThemeMode), MobileThemeMode.HighContrast));
Assert.True(Enum.IsDefined(typeof(MobileThemeMode), MobileThemeMode.SystemAuto));
// 2. 테마별 팔레트 토큰 검증
var darkPalette = MobileThemeSystem.GetPalette(MobileThemeMode.Dark);
Assert.Equal("#0B0C10", darkPalette.Background);
Assert.Equal("#161822", darkPalette.Surface1);
Assert.Equal("#5E56E8", darkPalette.Accent);
Assert.Equal("#FFFFFF", darkPalette.TextPrimary);
Assert.Equal("#94A3B8", darkPalette.TextSecondary);
var lightPalette = MobileThemeSystem.GetPalette(MobileThemeMode.Light);
Assert.Equal("#F8FAFC", lightPalette.Background);
Assert.Equal("#FFFFFF", lightPalette.Surface1);
Assert.Equal("#4F46E5", lightPalette.Accent);
Assert.Equal("#0F172A", lightPalette.TextPrimary);
var hcPalette = MobileThemeSystem.GetPalette(MobileThemeMode.HighContrast);
Assert.Equal("#000000", hcPalette.Background);
Assert.Equal("#FFFF00", hcPalette.Accent);
Assert.Equal("#FFFFFF", hcPalette.TextPrimary);
}
[Fact]
public void Gate83_MobileIconSystem_Glyphs_Completeness_Audit()
{
// 모바일 프로덕션 필수 아이콘 글리프 정의 검증
Assert.False(string.IsNullOrWhiteSpace(MobileIconSystem.Queue));
Assert.False(string.IsNullOrWhiteSpace(MobileIconSystem.Discover));
Assert.False(string.IsNullOrWhiteSpace(MobileIconSystem.Browser));
Assert.False(string.IsNullOrWhiteSpace(MobileIconSystem.Favorites));
Assert.False(string.IsNullOrWhiteSpace(MobileIconSystem.SyncSettings));
Assert.False(string.IsNullOrWhiteSpace(MobileIconSystem.Play));
Assert.False(string.IsNullOrWhiteSpace(MobileIconSystem.Pause));
Assert.False(string.IsNullOrWhiteSpace(MobileIconSystem.Retry));
Assert.False(string.IsNullOrWhiteSpace(MobileIconSystem.Delete));
Assert.False(string.IsNullOrWhiteSpace(MobileIconSystem.Pin));
Assert.False(string.IsNullOrWhiteSpace(MobileIconSystem.Download));
Assert.False(string.IsNullOrWhiteSpace(MobileIconSystem.Check));
}
[Fact]
public void Gate84_MobileTouchTarget_Standard_Audit()
{
// 모바일 인간공학 최소 터치 규격 및 패딩 검증
Assert.Equal(48.0, MobileThemeSystem.MinTouchTargetDp);
Assert.Equal(8.0, MobileThemeSystem.BaseGridUnitDp);
Assert.Equal(12.0, MobileThemeSystem.CornerRadiusCardDp);
Assert.Equal(24.0, MobileThemeSystem.CornerRadiusPillDp);
}
[Fact]
public void Gate85_MobileTabModel_Navigation_Audit()
{
// 5대 탭 시스템 모델 무결성 검증
var tabs = MobileNavigationModel.GetDefaultTabs();
Assert.Equal(5, tabs.Count);
Assert.Equal(MobileTabKind.Queue, tabs[0].Kind);
Assert.Equal("대기열", tabs[0].Title);
Assert.Equal(MobileTabKind.Discover, tabs[1].Kind);
Assert.Equal("탐색", tabs[1].Title);
Assert.Equal(MobileTabKind.Browser, tabs[2].Kind);
Assert.Equal("브라우저", tabs[2].Title);
Assert.Equal(MobileTabKind.Favorites, tabs[3].Kind);
Assert.Equal("보관함", tabs[3].Title);
Assert.Equal(MobileTabKind.SyncSettings, tabs[4].Kind);
Assert.Equal("동기화", tabs[4].Title);
// 뱃지 업데이트 지원
tabs[0].BadgeCount = 3;
Assert.Equal(3, tabs[0].BadgeCount);
}
[Fact]
public void Gate86_Mobile_Generated_Assets_Existence_Audit()
{
// 이미지 생성 도구로 제작되어 저장된 에셋 검증
var iconPath = Path.Combine(MobileProjectDir, "Resources", "AppIcon", "paca_app_icon.png");
var splashPath = Path.Combine(MobileProjectDir, "Resources", "Splash", "paca_splash_screen.png");
var emptyPath = Path.Combine(MobileProjectDir, "Resources", "Images", "paca_empty_queue.png");
Assert.True(File.Exists(iconPath), $"앱 아이콘이 존재해야 합니다: {iconPath}");
Assert.True(File.Exists(splashPath), $"스플래시 이미지가 존재해야 합니다: {splashPath}");
Assert.True(File.Exists(emptyPath), $"빈 큐 일러스트가 존재해야 합니다: {emptyPath}");
Assert.True(new FileInfo(iconPath).Length > 10000, "앱 아이콘 파일 크기가 정상이어야 합니다.");
Assert.True(new FileInfo(splashPath).Length > 10000, "스플래시 파일 크기가 정상이어야 합니다.");
Assert.True(new FileInfo(emptyPath).Length > 10000, "빈 큐 일러스트 파일 크기가 정상이어야 합니다.");
}
}

View file

@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// 모바일 프로덕션 미디어 플레이어 제스처, 자막, 배속, 스토리지 및 공유 게이트 (Gate 103 ~ Gate 108)
/// </summary>
public class MobileMediaPlaybackGateTests
{
[Fact]
public void Gate103_MobilePlayerGestureEngine_Audit()
{
var gesture = new MobilePlayerGestureEngine();
// 1. 수평 스와이프 -> Seek
var seekResult = gesture.OnHorizontalPan(deltaX: 100, currentPositionSec: 30, durationSec: 300);
Assert.True(seekResult.NewPositionSec > 30);
// 2. 좌측 수직 스와이프 -> 밝기 조절
var brightness = gesture.OnVerticalPan(isLeftScreen: true, deltaY: -50, currentBrightness: 0.5);
Assert.True(brightness > 0.5);
// 3. 우측 수직 스와이프 -> 볼륨 조절
var volume = gesture.OnVerticalPan(isLeftScreen: false, deltaY: -50, currentVolume: 0.5);
Assert.True(volume > 0.5);
// 4. 더블 탭 -> 토글
Assert.True(gesture.OnDoubleTap(isPlaying: false));
Assert.False(gesture.OnDoubleTap(isPlaying: true));
}
[Fact]
public void Gate104_MobilePlaybackSpeedController_Audit()
{
var controller = new MobilePlaybackSpeedController();
Assert.Equal(1.0, controller.CurrentSpeed);
controller.SetSpeed(1.5);
Assert.Equal(1.5, controller.CurrentSpeed);
controller.SetSpeed(3.0);
Assert.Equal(3.0, controller.CurrentSpeed);
// 음높이(Pitch) 보정 활성화
Assert.True(controller.PitchCorrectionEnabled);
}
[Fact]
public void Gate105_MobileSubtitleRenderer_Audit()
{
var renderer = new MobileSubtitleRenderer();
var vttSample = "WEBVTT\n\n00:00:01.000 --> 00:00:04.000\n안녕하세요, Paca 비디오 다운로더입니다.\n";
renderer.LoadSubtitles(vttSample);
Assert.Single(renderer.Cues);
var activeCue = renderer.GetActiveCue(TimeSpan.FromSeconds(2));
Assert.NotNull(activeCue);
Assert.Equal("안녕하세요, Paca 비디오 다운로더입니다.", activeCue.Text);
// 자막 스타일
renderer.FontSizeMode = SubtitleFontSize.Large;
Assert.Equal(SubtitleFontSize.Large, renderer.FontSizeMode);
}
[Fact]
public void Gate106_MobilePipController_Audit()
{
var pip = new MobilePipController();
Assert.False(pip.IsPipActive);
Assert.True(pip.BackgroundAudioEnabled);
pip.EnterPipMode();
Assert.True(pip.IsPipActive);
pip.ExitPipMode();
Assert.False(pip.IsPipActive);
}
[Fact]
public void Gate107_MobileStoragePolicy_Audit()
{
var policy = new MobileStoragePolicy();
// 90% 임계값
Assert.Equal(90.0, policy.WarningThresholdPercent);
// 95% 사용 시 경고 발생 검증
var warning = policy.CheckStorageUsage(totalBytes: 100_000_000, usedBytes: 95_000_000);
Assert.True(warning.IsWarningTriggered);
Assert.Equal(95.0, warning.UsagePercent);
}
[Fact]
public void Gate108_MobileMediaExporter_Audit()
{
var exporter = new MobileMediaExporter();
var exportResult = exporter.PrepareExport("sample_video.mp4", isAudioOnly: false);
Assert.Equal("video/mp4", exportResult.MimeType);
Assert.Contains("Movies", exportResult.DestinationFolder);
var audioResult = exporter.PrepareExport("sample_audio.mp3", isAudioOnly: true);
Assert.Equal("audio/mpeg", audioResult.MimeType);
Assert.Contains("Music", audioResult.DestinationFolder);
}
}

View file

@ -0,0 +1,310 @@
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;
}
}

View file

@ -0,0 +1,199 @@
using Paca.Mobile;
using System;
using System.Collections.Generic;
using System.Linq;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// 모바일 프로덕션 레벨 종합 고도화 게이트 테스트 (Gate 103 ~ Gate 110)
/// - 아이콘 시스템, 테마 시스템, 탭 시스템, 즐겨찾기, 영상 찾기, 재시도 큐 관리, 설정, 탭 동기화
/// </summary>
public class MobileProductionExcellenceGateTests
{
// [Gate 103] 아이콘 시스템: 이모지 배제 및 시맨틱 아이콘 글리프 매핑 무결성
[Theory]
[InlineData(MobileIconKind.Search, "magnifyingglass")]
[InlineData(MobileIconKind.Queue, "list.bullet")]
[InlineData(MobileIconKind.Download, "arrow.down.circle")]
[InlineData(MobileIconKind.Favorites, "star.fill")]
[InlineData(MobileIconKind.Settings, "gearshape.fill")]
[InlineData(MobileIconKind.Retry, "arrow.clockwise")]
public void Gate103_MobileIconSystem_SemanticGlyph_Audit(MobileIconKind kind, string expectedGlyph)
{
var glyph = MobileIconSystem.GetGlyph(kind);
Assert.Equal(expectedGlyph, glyph);
// 유니코드 이모지가 포함되지 않았는지 검증
Assert.DoesNotMatch(@"[\uD83C-\uDBFF\uDC00-\uDFFF]", glyph);
}
// [Gate 104] 테마 시스템: OLED Black을 포함한 4대 테마 토큰 및 명도 대비
[Theory]
[InlineData(MobileThemeMode.Dark, "#0B0C10", "#161822", "#FFFFFF", "#5E56E8")]
[InlineData(MobileThemeMode.Light, "#F8FAFC", "#FFFFFF", "#0F172A", "#4F46E5")]
[InlineData(MobileThemeMode.OLEDBlack, "#000000", "#090A0F", "#FFFFFF", "#00F0FF")]
[InlineData(MobileThemeMode.HighContrast, "#000000", "#000000", "#FFFFFF", "#FFFF00")]
public void Gate104_MobileThemeSystem_Tokens_And_Contrast_Audit(
MobileThemeMode mode, string bg, string card, string text, string accent)
{
var p = MobileThemeSystem.GetPalette(mode);
Assert.Equal(bg, p.Background);
Assert.Equal(card, p.CardBackground);
Assert.Equal(text, p.TextPrimary);
Assert.Equal(accent, p.Accent);
}
// [Gate 105] 탭 시스템: 탭 추가, 전환, 닫기, 시크릿 탭 모드 라이프사이클
[Fact]
public void Gate105_MobileTabManager_MultiTab_Lifecycle_Audit()
{
var tm = new MobileTabManager();
var tab1 = tm.NewTab("https://www.youtube.com", "YouTube");
var tab2 = tm.NewTab("https://vimeo.com", "Vimeo", isIncognito: true);
Assert.Equal(3, tm.Tabs.Count);
Assert.Equal(tab2.Id, tm.ActiveTab?.Id);
Assert.True(tab2.IsIncognito);
// 탭 1로 전환
tm.SwitchTab(tab1.Id);
Assert.Equal(tab1.Id, tm.ActiveTab?.Id);
// 탭 닫기
tm.CloseTab(tab2.Id);
Assert.Equal(2, tm.Tabs.Count);
Assert.Equal(tab1.Id, tm.ActiveTab?.Id);
}
// [Gate 106] 즐겨찾기 & 보관함(Stash): 태그 필터링 및 다운로드 대기 큐 연계
[Fact]
public void Gate106_MobileFavoritesManager_TagFilter_And_Stash_Audit()
{
var fav = new MobileFavoritesManager();
fav.AddFavorite("https://youtube.com/v1", "Rock Festival Live", "음악");
fav.AddFavorite("https://youtube.com/v2", "Dev Conference 2026", "기술");
var f3 = fav.AddFavorite("https://youtube.com/v3", "Jazz Night", "음악");
fav.ToggleStash(f3.Id);
var musicList = fav.GetByTag("음악");
Assert.Equal(2, musicList.Count);
var stashed = fav.GetStashedItems();
Assert.Single(stashed);
Assert.Equal(f3.Id, stashed[0].Id);
}
// [Gate 107] 영상 찾기(스니퍼 탐색): 플랫폼 감지 및 메타데이터 추출
[Fact]
public void Gate107_MobileDiscoveryEngine_PlatformDetection_And_FormatFilter_Audit()
{
var res1 = MobileDiscoveryEngine.DetectPlatform("https://youtu.be/abcdefg");
Assert.True(res1.IsSupported);
Assert.Equal("YouTube", res1.PlatformName);
Assert.Contains("1080p", res1.RecommendedQualities);
var res2 = MobileDiscoveryEngine.DetectPlatform("https://example.com/live.m3u8");
Assert.True(res2.IsSupported);
Assert.Equal("HLS Stream", res2.PlatformName);
}
// [Gate 108] 영상 재시도 및 큐 관리: 우선순위 핀, 실패 재시도, 일괄 비우기
[Fact]
public void Gate108_MobileQueueManager_Priorities_And_FailureRecovery_Audit()
{
var qm = new MobileQueueManager();
var i1 = qm.Enqueue("https://v1.mp4", "Video 1");
var i2 = qm.Enqueue("https://v2.mp4", "Video 2");
var i3 = qm.Enqueue("https://v3.mp4", "Video 3");
i2.Status = MobileQueueStatus.Failed;
i2.ErrorMessage = "Network Timeout";
// 실패 항목만 재시도
var count = qm.RetryFailedItems();
Assert.Equal(1, count);
Assert.Equal(MobileQueueStatus.Queued, i2.Status);
// 중요 항목 고정
qm.Pin(i3.Id);
Assert.Equal(i3.Id, qm.Items[0].Id);
// 완료된 항목 일괄 정리
i1.Status = MobileQueueStatus.Completed;
qm.ClearCompleted();
Assert.DoesNotContain(qm.Items, x => x.Id == i1.Id);
}
// [Gate 109] 설정 관리자: 영속성 및 기본값
[Fact]
public void Gate109_MobileSettingsManager_Defaults_And_Persistence_Audit()
{
var settings = new MobileSettingsManager();
Assert.Equal(MobileThemeMode.Dark, settings.ThemeMode);
Assert.False(settings.WifiOnlyDownload);
Assert.Equal(0, settings.SpeedLimitKbps); // 0 = 무제한
settings.SpeedLimitKbps = 2048;
settings.ThemeMode = MobileThemeMode.OLEDBlack;
Assert.Equal(2048, settings.SpeedLimitKbps);
Assert.Equal(MobileThemeMode.OLEDBlack, settings.ThemeMode);
}
// [Gate 110] 사용자 탭 동기화: PC 열린 탭 원격 수신 및 동기화 요청
[Fact]
public void Gate110_MobileTabSyncEngine_RemoteTabMapping_Audit()
{
var sync = new MobileTabSyncEngine();
sync.UpdateRemoteTabs(new List<RemoteTabInfo>
{
new("t1", "Design System Talk", "https://talk.com/1"),
new("t2", "Tutorial Part 2", "https://tutorial.com/2")
});
Assert.Equal(2, sync.RemoteTabs.Count);
string? requestedUrl = null;
sync.OnRemoteDownloadRequested += u => requestedUrl = u;
sync.RequestRemoteDownload("t2");
Assert.Equal("https://tutorial.com/2", requestedUrl);
}
// [Gate 114] 비디오 스니퍼: 다중 스트림 품질 자동 협상 (Quality Negotiation)
[Fact]
public void Gate114_VideoSniffer_QualityNegotiator_Audit()
{
var streams = new List<VideoSniffItem>
{
new() { StreamUrl = "https://cdn.com/4k.mp4", Resolution = "4K" },
new() { StreamUrl = "https://cdn.com/1080p.mp4", Resolution = "1080p" },
new() { StreamUrl = "https://cdn.com/720p.mp4", Resolution = "720p" },
new() { StreamUrl = "https://cdn.com/audio.m4a", Resolution = "Audio Only" }
};
// 1. 선호 화질 1080p -> 1080p 매칭
var best1080 = VideoSnifferParser.NegotiateBestQuality(streams, "1080p");
Assert.NotNull(best1080);
Assert.Equal("1080p", best1080.Resolution);
// 2. 선호 화질 1440p (목록에 없음) -> 1440p 이하 최고 화질인 1080p 선택
var fallback = VideoSnifferParser.NegotiateBestQuality(streams, "1440p");
Assert.NotNull(fallback);
Assert.Equal("1080p", fallback.Resolution);
// 3. 선호 화질 최고 화질("Max") -> 4K 선택
var max = VideoSnifferParser.NegotiateBestQuality(streams, "Max");
Assert.NotNull(max);
Assert.Equal("4K", max.Resolution);
// 4. 선호 화질 오디오("Audio Only") -> Audio Only 선택
var audio = VideoSnifferParser.NegotiateBestQuality(streams, "Audio Only");
Assert.NotNull(audio);
Assert.Equal("Audio Only", audio.Resolution);
}
}

View file

@ -0,0 +1,93 @@
using System;
using System.IO;
using System.Text.RegularExpressions;
using Paca.Tests.Infrastructure;
using Xunit;
namespace Paca.Tests.Mobile;
/// <summary>
/// 모바일 프로덕션 UI 뷰, 5대 탭, 컨트롤 실존 및 레이아웃 무결성 게이트 (Gate 93 ~ Gate 97)
/// </summary>
public class MobileUiProductionGateTests
{
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 Gate93_TabbedRootPage_FiveTabs_Completeness_Audit()
{
var rootCs = File.ReadAllText(Path.Combine(MobileProjectDir, "TabbedRootPage.cs"));
Assert.Contains("QueuePage", rootCs);
Assert.Contains("DiscoverPage", rootCs);
Assert.Contains("BrowserPage", rootCs);
Assert.Contains("FavoritesPage", rootCs);
Assert.Contains("SyncSettingsPage", rootCs);
}
[Fact]
public void Gate94_DiscoverPage_SearchControls_Audit()
{
var pagesCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Pages.cs"));
Assert.Contains("class DiscoverPage", pagesCs);
Assert.Contains("_searchEntry", pagesCs);
Assert.Contains("_platformChips", pagesCs);
Assert.Contains("YouTube", pagesCs);
Assert.Contains("Vimeo", pagesCs);
}
[Fact]
public void Gate95_FavoritesPage_Stash_And_Tagging_Ui_Audit()
{
var pagesCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Pages.cs"));
Assert.Contains("class FavoritesPage", pagesCs);
Assert.Contains("_tagFilter", pagesCs);
Assert.Contains("_favoritesList", pagesCs);
}
[Fact]
public void Gate96_QueuePage_EmptyIllustration_And_Retry_Audit()
{
var pagesCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Pages.cs"));
Assert.Contains("paca_empty_queue.png", pagesCs);
Assert.Contains("재시도", pagesCs);
}
[Fact]
public void Gate97_SyncSettingsPage_RemoteTabs_Ui_Audit()
{
var pagesCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Pages.cs"));
Assert.Contains("class SyncSettingsPage", pagesCs);
Assert.Contains("_remoteTabsList", pagesCs);
Assert.Contains("PC 탭 동기화", pagesCs);
}
[Fact]
public void Gate111_DiscoverPage_Enqueue_And_Favorite_Action_Buttons_Audit()
{
var pagesCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Pages.cs"));
Assert.Contains("_enqueueBtn", pagesCs);
Assert.Contains("_favoriteBtn", pagesCs);
Assert.Contains("다운로드 큐 추가", pagesCs);
Assert.Contains("즐겨찾기 저장", pagesCs);
}
[Fact]
public void Gate112_SyncSettingsPage_ThemeOptions_Includes_OLEDBlack()
{
var pagesCs = File.ReadAllText(Path.Combine(MobileProjectDir, "Pages.cs"));
Assert.Contains("OLED 블랙", pagesCs);
Assert.Contains("_wifiOnlySwitch", pagesCs);
Assert.Contains("_speedLimitEntry", pagesCs);
}
[Fact]
public void Gate113_AppIcon_And_Splash_Assets_Exist_And_Referenced()
{
var appIconPath = Path.Combine(MobileProjectDir, "Resources", "AppIcon", "paca_app_icon.png");
var splashPath = Path.Combine(MobileProjectDir, "Resources", "Splash", "paca_splash_screen.png");
Assert.True(File.Exists(appIconPath), "paca_app_icon.png must exist");
Assert.True(File.Exists(splashPath), "paca_splash_screen.png must exist");
}
}