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:
parent
dbc50abb71
commit
f1949024fc
81 changed files with 8611 additions and 0 deletions
107
tests/Paca.Tests/Mobile/MobileAdvancedConnectivityGateTests.cs
Normal file
107
tests/Paca.Tests/Mobile/MobileAdvancedConnectivityGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
135
tests/Paca.Tests/Mobile/MobileAdvancedNativeGateTests.cs
Normal file
135
tests/Paca.Tests/Mobile/MobileAdvancedNativeGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
237
tests/Paca.Tests/Mobile/MobileBrowserCoreTests.cs
Normal file
237
tests/Paca.Tests/Mobile/MobileBrowserCoreTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
129
tests/Paca.Tests/Mobile/MobileComplexJourneyTests.cs
Normal file
129
tests/Paca.Tests/Mobile/MobileComplexJourneyTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
142
tests/Paca.Tests/Mobile/MobileCoreFeaturesGateTests.cs
Normal file
142
tests/Paca.Tests/Mobile/MobileCoreFeaturesGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
152
tests/Paca.Tests/Mobile/MobileCoreTests.cs
Normal file
152
tests/Paca.Tests/Mobile/MobileCoreTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
118
tests/Paca.Tests/Mobile/MobileDesignSystemGateTests.cs
Normal file
118
tests/Paca.Tests/Mobile/MobileDesignSystemGateTests.cs
Normal 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, "빈 큐 일러스트 파일 크기가 정상이어야 합니다.");
|
||||
}
|
||||
}
|
||||
107
tests/Paca.Tests/Mobile/MobileMediaPlaybackGateTests.cs
Normal file
107
tests/Paca.Tests/Mobile/MobileMediaPlaybackGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
310
tests/Paca.Tests/Mobile/MobilePipelineTests.cs
Normal file
310
tests/Paca.Tests/Mobile/MobilePipelineTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
199
tests/Paca.Tests/Mobile/MobileProductionExcellenceGateTests.cs
Normal file
199
tests/Paca.Tests/Mobile/MobileProductionExcellenceGateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
93
tests/Paca.Tests/Mobile/MobileUiProductionGateTests.cs
Normal file
93
tests/Paca.Tests/Mobile/MobileUiProductionGateTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue