feat(ads/seo/release): v1.2.0 with live Google Ads, isolated ad profile, ads.txt, and SEO/AEO/GEO optimization

This commit is contained in:
Yun Chan 2026-09-14 00:39:44 +09:00
parent a88aafa1e5
commit 0e568f1f0a
975 changed files with 130593 additions and 16783 deletions

View file

@ -0,0 +1,420 @@
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;
/// <summary>
/// 미디어 감지, 배너, 플레이어, 글로벌 미디어 컨트롤러 파이프라인.
/// </summary>
/// <summary>
/// 18차 스마트 미디어 감지 플로팅 칩 & 내장 플레이어 단축키/배속/HUD 오버레이 검증 테스트.
/// </summary>
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<Microsoft.Web.WebView2.Core.CoreWebView2Cookie>()), _ => { });
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<Microsoft.Web.WebView2.Core.CoreWebView2Cookie>()), _ => { });
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);
}
}