using System; using System.IO; using System.Linq; using System.Threading.Tasks; using Paca.Mobile; using Xunit; namespace Paca.Tests.Mobile; /// /// PACA Mobile Browser Core 단위 및 도메인 스펙 검증 스위트 /// - 탭 관리자 (생성, 전환, 닫기, 시크릿 모드, 세션 영속화) /// - 로컬 방문 기록 (추가, 검색, 삭제, 원자적 저장) /// - 북마크 관리자 (추가, 중복 갱신, 삭제, 조회) /// - 비디오 스니퍼 (M3U8/MP4/DASH/Blob 네트워크 및 DOM JSON 파서) /// - 인앱 다운로드 엔진 (작업 큐, 상태 머신) /// 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); } }