using System.IO; using System.Text; using Paca.App; using Paca.App.Browser; using Paca.App.Browser.Features; using Paca.App.Core; using Paca.App.ViewModels; using Paca.Browser; using Paca.Core; using Paca.Core.Config; using Paca.Core.Detection; using Xunit; namespace Paca.Tests.Browser; /// /// 미디어 감지, 배너, 플레이어, 글로벌 미디어 컨트롤러 파이프라인. /// /// /// 18차 스마트 미디어 감지 플로팅 칩 & 내장 플레이어 단축키/배속/HUD 오버레이 검증 테스트. /// public class MediaDetectionBannerAndPlayerTests { private static string AppPath(params string[] parts) => Infrastructure.SourceTree.AppPath(Path.Combine(parts)); [Fact] public void DownloadManager_미디어_감지_시_ItemDetected_이벤트가_발생한다() { var config = new AppConfig(); var manager = new DownloadManager(config, _ => Task.FromResult(new List()), _ => { }); DownloadItemViewModel? detected = null; manager.ItemDetected += item => detected = item; manager.OnDetected(new DetectionContext("https://example.com/video.m3u8", "https://example.com", MediaKind.Hls)); Assert.NotNull(detected); Assert.Equal(MediaKind.Hls, detected!.Kind); Assert.Equal("HLS", detected.FormatTag); } [Fact] public void DownloadManager_웹페이지_제목이_있으면_미디어_제목으로_우선_사용한다() { var config = new AppConfig(); var manager = new DownloadManager(config, _ => Task.FromResult(new List()), _ => { }); DownloadItemViewModel? detected = null; manager.ItemDetected += item => detected = item; manager.OnDetected(new DetectionContext( "https://www.youtube.com/api/stats/playback/open", "https://www.youtube.com/watch?v=12345", MediaKind.Direct, Title: "멋진 유튜브 강좌 영상")); Assert.NotNull(detected); Assert.Equal("멋진 유튜브 강좌 영상", detected!.Title); } [Fact] public void PlayerWindow_BuildPlayerHtml_단축키_배속_스트림인포_스크립트가_포함된다() { var track = new MediaTrack("https://example.com/stream.m3u8", "Test Video", "Hls", DateTime.UtcNow); var html = PlayerWindow.BuildPlayerHtml(track); // 단축키 검증 (Space, ArrowLeft, ArrowRight, F, M, I 등) Assert.Contains("addEventListener('keydown'", html); Assert.Contains("ArrowLeft", html); Assert.Contains("ArrowRight", html); Assert.Contains("playbackRate", html); // HUD / 스트림 정보 오버레이 태그 검증 Assert.Contains("vd-hud", html); Assert.Contains("videoWidth", html); } [Fact] public void 메인윈도우_스마트_미디어_감지_플로팅_배너가_정의되어_있다() { var xamlPath = AppPath("MainWindow.xaml"); Assert.True(File.Exists(xamlPath), "MainWindow.xaml 이 존재해야 합니다."); var content = File.ReadAllText(xamlPath); // 감지 배너 요소 x:Name 존재 확인 Assert.Contains("x:Name=\"MediaDetectedBanner\"", content); Assert.Contains("x:Name=\"MediaDetectedBadge\"", content); Assert.Contains("x:Name=\"MediaDetectedTitle\"", content); Assert.Contains("x:Name=\"MediaDetectedDownloadBtn\"", content); Assert.Contains("x:Name=\"MediaDetectedPlayBtn\"", content); Assert.Contains("x:Name=\"MediaDetectedPlaylistBtn\"", content); Assert.Contains("x:Name=\"MediaDetectedCloseBtn\"", content); } [Fact] public void PlayerWindow_상단바에_배속조절과_PIP_토글버튼이_정의되어_있다() { var playerCodePath = AppPath("Browser", "PlayerWindow.cs"); Assert.True(File.Exists(playerCodePath), "PlayerWindow.cs 가 존재해야 합니다."); var content = File.ReadAllText(playerCodePath); // Topmost / PIP 및 배속 관련 코드 확인 Assert.Contains("Topmost", content); Assert.Contains("SetPlaybackRate", content); } } public class MediaDetectorTests { private readonly AppConfig _config = new(); [Theory] [InlineData("https://i.4cdn.org/gif/1788291823.webm", "video/webm", 500_000, MediaKind.Direct)] [InlineData("https://video.twimg.com/ext_tw_video/123/vid/720x1280/sample.mp4?tag=12", "video/mp4", 1_200_000, MediaKind.Direct)] [InlineData("https://example.com/clips/short.mp4", "binary/octet-stream", 45_000, MediaKind.Direct)] [InlineData("https://example.com/stream/index.m3u8", "application/vnd.apple.mpegurl", 0, MediaKind.Hls)] [InlineData("https://example.com/stream/manifest.mpd", "application/dash+xml", 0, MediaKind.Dash)] public void Classify_DetectsAccurately(string url, string ct, long len, MediaKind expected) { var kind = MediaDetector.Classify(url, ct, len, _config); Assert.Equal(expected, kind); } [Fact] public void MatchFormat_HandlesQueryStringsAndExtensions() { Assert.Equal("webm", MediaDetector.MatchFormat("https://i.4cdn.org/gif/test.webm?v=1", "")); Assert.Equal("mp4", MediaDetector.MatchFormat("https://preview.redd.it/clip.mp4?s=abc", "")); Assert.Equal("webm", MediaDetector.MatchFormat("https://example.com/stream/play", "video/webm")); Assert.Equal("mp4", MediaDetector.MatchFormat("https://example.com/stream/play", "video/mp4")); } [Fact] public void Classify_IgnoresExtremelySmallNonMediaNoise() { var kind = MediaDetector.Classify("https://example.com/icon.mp4", "video/mp4", 5_000, _config); Assert.Null(kind); } [Theory] [InlineData("https://example.com/hls/segment_001.ts")] [InlineData("https://example.com/stream/chunk-12345.m4s")] [InlineData("https://example.com/live/init.mp4")] [InlineData("https://example.com/live/seg-1-v1-a1.ts?token=xyz")] [InlineData("https://example.com/media/fragment_002.m4s")] public void Classify_IgnoresSegmentsAndChunks(string segmentUrl) { Assert.True(MediaDetector.IsSegment(segmentUrl)); var kind = MediaDetector.Classify(segmentUrl, "video/mp2t", 500_000, _config); Assert.Null(kind); } [Theory] [InlineData("https://www.youtube.com/s/player/68c85eb0/failure", "application/x-www-form-urlencoded")] [InlineData("https://www.youtube.com/api/stats/playback", "application/json")] [InlineData("https://www.youtube.com/pagead/interaction/?ai=xyz", "image/gif")] [InlineData("https://www.youtube.com/ptracking?html5=1", "application/octet-stream")] public void Classify_IgnoresTelemetryAndPlayerLoggingUrls(string url, string ct) { var kind = MediaDetector.Classify(url, ct, 100_000, _config); Assert.Null(kind); } [Theory] [InlineData("https://www.youtube.com/s/player/68c85eb0/failure")] [InlineData("https://www.youtube.com/api/stats/playback?ns=yt")] [InlineData("https://www.youtube.com/pagead/interaction/?ai=xyz")] [InlineData("https://www.youtube.com/ptracking?html5=1")] [InlineData("https://googleads.g.doubleclick.net/pagead/id")] [InlineData("https://www.google-analytics.com/g/collect")] public void IsNonMediaUrl_FiltersOutTelemetryAndScriptLogs(string url) { Assert.True(MediaDetector.IsNonMediaUrl(url)); } } // ── T2-10 미디어 재생목록 (Brave Playlist 패턴): 감지된 미디어를 쌓고 재생/제거/영속 ── public class MediaPlaylistTests : IDisposable { private readonly string _path = Path.Combine(Path.GetTempPath(), $"playlist_{Guid.NewGuid():N}.json"); private readonly MediaPlaylist _pl = new(); private MediaTrack Mk(string url = "https://v.test/a.mp4", string title = "트랙A", string kind = "Direct") => new(url, title, kind, AddedAt: DateTime.UtcNow); [Fact] public void Add는_재생목록_끝에_추가하고_중복_URL은_무시한다() { var t = Mk(); _pl.Add(t); _pl.Add(Mk(url: "https://v.test/b.mp4", title: "트랙B")); _pl.Add(Mk()); // 동일 URL 중복 Assert.Equal(2, _pl.Tracks.Count); Assert.Equal("트랙A", _pl.Tracks[0].Title); } [Fact] public void Remove와_Clear() { var a = Mk(); var b = Mk("https://v.test/b.mp4", "B"); _pl.Add(a); _pl.Add(b); _pl.Remove(a); Assert.Single(_pl.Tracks); _pl.Clear(); Assert.Empty(_pl.Tracks); } [Fact] public void 다음_이전_트랙은_순환한다() { _pl.Add(Mk("u1", "1")); _pl.Add(Mk("u2", "2")); _pl.Add(Mk("u3", "3")); _pl.CurrentIndex = 0; Assert.Equal(1, _pl.Next()); Assert.Equal(2, _pl.Next()); Assert.Equal(0, _pl.Next()); // 순환 Assert.Equal(2, _pl.Previous()); } [Fact] public void 현재_트랙은_CurrentIndex로_얻는다() { _pl.Add(Mk("u1", "1")); _pl.Add(Mk("u2", "2")); _pl.CurrentIndex = 1; Assert.Equal("u2", _pl.Current?.Url); } [Fact] public void 영속_라운드트립() { _pl.Add(Mk("u1", "제목1", "Hls")); _pl.Add(Mk("u2", "제목2", "Direct")); _pl.CurrentIndex = 1; _pl.Save(_path); var loaded = new MediaPlaylist(); Assert.Empty(loaded.Tracks); loaded.Load(_path); Assert.Equal(2, loaded.Tracks.Count); Assert.Equal("제목1", loaded.Tracks[0].Title); Assert.Equal("Hls", loaded.Tracks[0].Kind); Assert.Equal(1, loaded.CurrentIndex); } [Fact] public void 손상된_JSON은_빈_목록으로_시작() { File.WriteAllText(_path, "{broken"); var pl = new MediaPlaylist(); pl.Load(_path); Assert.Empty(pl.Tracks); } [Fact] public void 재생완료_표시와_완료필터() { _pl.Add(Mk("u1", "1")); _pl.Add(Mk("u2", "2")); _pl.MarkPlayed(_pl.Tracks[0]); Assert.True(_pl.Tracks[0].Played); Assert.False(_pl.Tracks[1].Played); } public void Dispose() { try { File.Delete(_path); } catch { } } } public sealed class GlobalMediaControllerTests { [Fact] public void InitialState_HasNoMedia_AndHidden() { var controller = new GlobalMediaController(); Assert.False(controller.HasMedia); Assert.False(controller.IsPlaying); Assert.Equal("", controller.Title); Assert.Equal("", controller.Artist); Assert.Equal("", controller.SourceDomain); } [Fact] public void UpdatePlaybackState_UpdatesProperties_AndSetsHasMedia() { var controller = new GlobalMediaController(); var tabId = Guid.NewGuid(); controller.UpdateMediaState(tabId, new MediaPlaybackState { HasMedia = true, IsPlaying = true, Title = "레딧에 올라온 사흘 만에 길어...", Artist = "새벽 세시쯤", SourceDomain = "youtube.com", ThumbnailUrl = "https://example.com/thumb.jpg", CurrentTime = 30.5, Duration = 180.0 }); Assert.True(controller.HasMedia); Assert.True(controller.IsPlaying); Assert.Equal("레딧에 올라온 사흘 만에 길어...", controller.Title); Assert.Equal("새벽 세시쯤", controller.Artist); Assert.Equal("youtube.com", controller.SourceDomain); Assert.Equal("https://example.com/thumb.jpg", controller.ThumbnailUrl); Assert.Equal(30.5, controller.CurrentTime); Assert.Equal(180.0, controller.Duration); Assert.Equal("00:30", controller.CurrentTimeFormatted); Assert.Equal("03:00", controller.DurationFormatted); } [Fact] public void MediaStopped_ClearsHasMedia_WhenAllTabsStop() { var controller = new GlobalMediaController(); var tabId = Guid.NewGuid(); controller.UpdateMediaState(tabId, new MediaPlaybackState { HasMedia = true, IsPlaying = true, Title = "테스트 영상", SourceDomain = "youtube.com" }); Assert.True(controller.HasMedia); // 정지 알림 controller.UpdateMediaState(tabId, new MediaPlaybackState { HasMedia = false, IsPlaying = false }); Assert.False(controller.HasMedia); Assert.False(controller.IsPlaying); } [Fact] public void TabClosed_RemovesTabMedia() { var controller = new GlobalMediaController(); var tab1 = Guid.NewGuid(); var tab2 = Guid.NewGuid(); controller.UpdateMediaState(tab1, new MediaPlaybackState { HasMedia = true, IsPlaying = true, Title = "영상 1" }); controller.UpdateMediaState(tab2, new MediaPlaybackState { HasMedia = true, IsPlaying = true, Title = "영상 2" }); Assert.True(controller.HasMedia); // tab2 닫힘 controller.OnTabClosed(tab2); Assert.True(controller.HasMedia); // tab1 남아있음 // tab1 닫힘 controller.OnTabClosed(tab1); Assert.False(controller.HasMedia); } [Fact] public void ControlCommands_RaiseEvents() { var controller = new GlobalMediaController(); var tabId = Guid.NewGuid(); controller.UpdateMediaState(tabId, new MediaPlaybackState { HasMedia = true, IsPlaying = true, Title = "재생 중" }); string lastCommand = ""; double seekParam = 0; controller.CommandRequested += (cmd, param) => { lastCommand = cmd; seekParam = param; }; controller.TogglePlayPause(); Assert.Equal("toggle", lastCommand); controller.SeekRelative(-10); Assert.Equal("seekRelative", lastCommand); Assert.Equal(-10, seekParam); controller.SeekRelative(10); Assert.Equal("seekRelative", lastCommand); Assert.Equal(10, seekParam); controller.Seek(45.0); Assert.Equal("seek", lastCommand); Assert.Equal(45.0, seekParam); controller.RequestPictureInPicture(); Assert.Equal("pip", lastCommand); } }