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,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);
}
}