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
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue