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:
parent
a88aafa1e5
commit
0e568f1f0a
975 changed files with 130593 additions and 16783 deletions
409
tests/Paca.Tests/Platform/PlatformPipelineTests.cs
Normal file
409
tests/Paca.Tests/Platform/PlatformPipelineTests.cs
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using Paca.Core;
|
||||
using Paca.Core.Config;
|
||||
using Paca.Core.Hls;
|
||||
using Paca.Core.Models;
|
||||
using Paca.Core.Platform;
|
||||
using Paca.Core.Social;
|
||||
using Paca.Core.Update;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// 크로스 플랫폼 추상화 & HLS 리먹서 연결 & 자동 업데이트 체커 파이프라인.
|
||||
/// </summary>
|
||||
|
||||
|
||||
/// <summary>HlsDownloader 가 주입된 IMediaMuxer 를 실제 리먹스 경로에서 사용하는지 검증(RED: ctor 미할당 회귀 방지).</summary>
|
||||
public class HlsMuxerWiringTests
|
||||
{
|
||||
private sealed class RecordingMuxer : IMediaMuxer
|
||||
{
|
||||
public int RemuxCalls;
|
||||
public Task RemuxAsync(string ffmpeg, string input, string output, CancellationToken ct)
|
||||
{
|
||||
RemuxCalls++;
|
||||
File.WriteAllText(output, "remuxed"); // 성공 시뮬레이션
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public Task DownloadDashAsync(string ffmpeg, Uri mpdUrl, string output, double durationSec,
|
||||
HttpClient http, IProgress<DownloadProgress>? progress, CancellationToken ct) => Task.CompletedTask;
|
||||
public string? ResolveFfmpeg(AppConfig config) => "ffmpeg";
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HlsDownloader_주입된_뮥서로_리먹스한다()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) return;
|
||||
using var server = new HttpListener();
|
||||
var port = Random.Shared.Next(20000, 60000);
|
||||
server.Prefixes.Add($"http://127.0.0.1:{port}/");
|
||||
server.Start();
|
||||
|
||||
var seg = new byte[16];
|
||||
using var listenerTask = Task.Run(async () =>
|
||||
{
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var ctx = await server.GetContextAsync();
|
||||
var path = ctx.Request.Url!.AbsolutePath;
|
||||
var body = path.EndsWith(".m3u8")
|
||||
? "#EXTM3U\n#EXT-X-TARGETDURATION:2\n#EXTINF:2.0,\nseg0.ts\n#EXTINF:2.0,\nseg1.ts\n#EXT-X-ENDLIST\n"
|
||||
: null;
|
||||
var buf = body != null ? System.Text.Encoding.UTF8.GetBytes(body) : seg;
|
||||
ctx.Response.ContentType = body != null ? "application/vnd.apple.mpegurl" : "video/mp2t";
|
||||
await ctx.Response.OutputStream.WriteAsync(buf);
|
||||
ctx.Response.Close();
|
||||
}
|
||||
});
|
||||
|
||||
var muxer = new RecordingMuxer();
|
||||
var config = new AppConfig();
|
||||
var dl = new HlsDownloader(new HttpClient(), config, muxer);
|
||||
var outBase = Path.Combine(Path.GetTempPath(), $"hls-{Guid.NewGuid():N}");
|
||||
try
|
||||
{
|
||||
var result = await dl.DownloadAsync(new Uri($"http://127.0.0.1:{port}/index.m3u8"), outBase);
|
||||
Assert.True(muxer.RemuxCalls > 0, "주입한 IMediaMuxer.RemuxAsync 가 호출되어야 한다");
|
||||
Assert.EndsWith(".mp4", result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
server.Stop();
|
||||
try { await listenerTask; } catch { }
|
||||
foreach (var f in Directory.GetFiles(Path.GetTempPath(), Path.GetFileName(outBase) + "*"))
|
||||
File.Delete(f);
|
||||
if (Directory.Exists(outBase + ".parts")) Directory.Delete(outBase + ".parts", true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ── Fake ─────────────────────────────────────────────────────────────
|
||||
public sealed class FakeProcessRunner : IProcessRunner
|
||||
{
|
||||
public ProcessSpec? LastSpec;
|
||||
public Func<ProcessSpec, Action<string, string>?, CancellationToken, Task<ProcessResult>> Behavior =
|
||||
(_, _, _) => Task.FromResult(new ProcessResult(0, "", ""));
|
||||
|
||||
public Task<ProcessResult> RunAsync(ProcessSpec spec, Action<string, string>? onLine, CancellationToken ct)
|
||||
{
|
||||
LastSpec = spec;
|
||||
return Behavior(spec, onLine, ct);
|
||||
}
|
||||
}
|
||||
|
||||
// 재구성 ②: Core Platform 추상화 — 모바일(M0) 전제가 되는 프로세스/먹서 주입 검증.
|
||||
public class ProcessRunnerAbstractionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DesktopProcessRunner_명령_실행과_캡처_Windows()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) return;
|
||||
var runner = new DesktopProcessRunner();
|
||||
var res = await runner.RunAsync(
|
||||
new ProcessSpec("cmd.exe", new[] { "/c", "echo hello-vd" }), null, CancellationToken.None);
|
||||
Assert.Equal(0, res.ExitCode);
|
||||
Assert.Contains("hello-vd", res.Stdout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DesktopProcessRunner_종료코드_전파()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) return;
|
||||
var runner = new DesktopProcessRunner();
|
||||
var res = await runner.RunAsync(
|
||||
new ProcessSpec("cmd.exe", new[] { "/c", "exit 3" }), null, CancellationToken.None);
|
||||
Assert.Equal(3, res.ExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DesktopProcessRunner_라인_탭_스트림_구분()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) return;
|
||||
// 인라인 리다이렉션 인용 문제를 피해 스크립트 파일로 stdout/stderr 분리 생성
|
||||
var script = Path.Combine(Path.GetTempPath(), $"vd_tap_{Guid.NewGuid():N}.cmd");
|
||||
File.WriteAllText(script, "@echo off\r\necho out-line\r\necho err-line 1>&2\r\n");
|
||||
try
|
||||
{
|
||||
var runner = new DesktopProcessRunner();
|
||||
var taps = new System.Collections.Concurrent.ConcurrentQueue<(string, string)>();
|
||||
var res = await runner.RunAsync(
|
||||
new ProcessSpec("cmd.exe", new[] { "/c", script }),
|
||||
(s, l) => taps.Enqueue((s, l)), CancellationToken.None);
|
||||
Assert.Equal(0, res.ExitCode);
|
||||
Assert.Contains(taps.ToArray(), t => t.Item1 == "out" && t.Item2.Contains("out-line"));
|
||||
Assert.Contains(taps.ToArray(), t => t.Item1 == "err" && t.Item2.Contains("err-line"));
|
||||
Assert.Contains("err-line", res.Stderr);
|
||||
}
|
||||
finally { try { File.Delete(script); } catch { } }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DesktopProcessRunner_EnvPrefix로_PATH_선두_주입()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) return;
|
||||
var runner = new DesktopProcessRunner();
|
||||
var marker = Path.GetTempPath(); // PATH 앞에 아무 값 — cmd 는 무시하지만 실행 자체는 검증
|
||||
var res = await runner.RunAsync(
|
||||
new ProcessSpec("cmd.exe", new[] { "/c", "echo ok" },
|
||||
EnvPrefix: new Dictionary<string, string> { ["PATH"] = marker }),
|
||||
null, CancellationToken.None);
|
||||
Assert.Equal(0, res.ExitCode);
|
||||
}
|
||||
}
|
||||
|
||||
public class MediaMuxerAbstractionTests
|
||||
{
|
||||
private static string Ffmpeg => OperatingSystem.IsWindows() ? "ffmpeg.exe" : "ffmpeg";
|
||||
|
||||
[Fact]
|
||||
public async Task RemuxAsync는_copy_리먹스_인자를_만든다()
|
||||
{
|
||||
var fake = new FakeProcessRunner();
|
||||
var muxer = new DesktopMediaMuxer(fake);
|
||||
await muxer.RemuxAsync(Ffmpeg, "in.ts", "out.mp4", CancellationToken.None);
|
||||
|
||||
var spec = fake.LastSpec!;
|
||||
Assert.Equal(Ffmpeg, spec.ExePath);
|
||||
Assert.Equal(new[] { "-y", "-hide_banner", "-loglevel", "error", "-i", "in.ts", "-c", "copy", "out.mp4" },
|
||||
spec.Args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemuxAsync_실패시_stderr_포함_예외()
|
||||
{
|
||||
var fake = new FakeProcessRunner
|
||||
{ Behavior = (_, _, _) => Task.FromResult(new ProcessResult(1, "", "boom-err")) };
|
||||
var muxer = new DesktopMediaMuxer(fake);
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => muxer.RemuxAsync(Ffmpeg, "in.ts", "out.mp4", CancellationToken.None));
|
||||
Assert.Contains("boom-err", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadDashAsync는_진행률을_파싱해_리포트한다()
|
||||
{
|
||||
var fake = new FakeProcessRunner
|
||||
{
|
||||
Behavior = (_, onLine, _) =>
|
||||
{
|
||||
onLine?.Invoke("out", "out_time_ms=2500000"); // 2.5s / 10s = 25%
|
||||
onLine?.Invoke("out", "progress=end");
|
||||
return Task.FromResult(new ProcessResult(0, "", ""));
|
||||
}
|
||||
};
|
||||
var muxer = new DesktopMediaMuxer(fake);
|
||||
var progress = new SyncProgress<Paca.Core.Models.DownloadProgress>();
|
||||
await muxer.DownloadDashAsync(Ffmpeg, new Uri("https://x.test/a.mpd"), "out.mp4",
|
||||
durationSec: 10, http: new HttpClient(), progress: progress, CancellationToken.None);
|
||||
|
||||
var spec = fake.LastSpec!;
|
||||
Assert.Contains("-progress", spec.Args);
|
||||
Assert.Contains("pipe:1", spec.Args);
|
||||
Assert.Contains("https://x.test/a.mpd", spec.Args);
|
||||
Assert.Contains("+faststart", spec.Args);
|
||||
Assert.Contains(progress.Items, r => Math.Abs(r.Percent - 25.0) < 0.01);
|
||||
}
|
||||
|
||||
/// <summary>Progress<T> 는 SyncContext 에 큐잉되어 레이스가 나므로 동기 기록용 구현.</summary>
|
||||
private sealed class SyncProgress<T> : IProgress<T>
|
||||
{
|
||||
public readonly List<T> Items = new();
|
||||
public void Report(T value) => Items.Add(value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadDashAsync_실패시_예외()
|
||||
{
|
||||
var fake = new FakeProcessRunner
|
||||
{ Behavior = (_, _, _) => Task.FromResult(new ProcessResult(2, "", "dash-fail")) };
|
||||
var muxer = new DesktopMediaMuxer(fake);
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => muxer.DownloadDashAsync(Ffmpeg, new Uri("https://x.test/a.mpd"), "o.mp4", 10,
|
||||
new HttpClient(), null, CancellationToken.None));
|
||||
Assert.Contains("dash-fail", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public class YtDlpRunnerAbstractionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetVersionAsync은_주입된_러너로_version_인자와_출력을_해석한다()
|
||||
{
|
||||
var fake = new FakeProcessRunner
|
||||
{ Behavior = (spec, _, _) => Task.FromResult(new ProcessResult(0, "2026.07.04\n", "")) };
|
||||
var version = await YtDlpRunner.GetVersionAsync("yt-dlp.exe", CancellationToken.None, fake);
|
||||
Assert.Equal("2026.07.04", version);
|
||||
Assert.Contains("--version", fake.LastSpec!.Args);
|
||||
Assert.Equal("yt-dlp.exe", fake.LastSpec.ExePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunCaptureAsync도_주입된_러너를_사용한다()
|
||||
{
|
||||
var fake = new FakeProcessRunner
|
||||
{ Behavior = (_, _, _) => Task.FromResult(new ProcessResult(0, "{\"id\":\"x\"}", "")) };
|
||||
var (exit, stdout, _) = await YtDlpRunner.RunCaptureAsync(
|
||||
"yt-dlp.exe", new[] { "-J", "https://youtu.be/x" }, CancellationToken.None, fake);
|
||||
Assert.Equal(0, exit);
|
||||
Assert.Contains("{\"id\"", stdout);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// DEPLOYMENT_PLAN §6 자동 업데이트 v1 — GitHub Releases latest 태그 비교 + 아키텍처별 설치 자산 선택.
|
||||
/// 네트워크는 가짜 핸들러로 격리.
|
||||
/// </summary>
|
||||
public class UpdateCheckerTests
|
||||
{
|
||||
private sealed class FakeHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _status;
|
||||
private readonly string _json;
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
|
||||
public FakeHandler(HttpStatusCode status, string json)
|
||||
{
|
||||
_status = status;
|
||||
_json = json;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
LastRequest = request;
|
||||
return Task.FromResult(new HttpResponseMessage(_status)
|
||||
{
|
||||
Content = new StringContent(_json, System.Text.Encoding.UTF8, "application/json"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private const string ReleaseJson = """
|
||||
{
|
||||
"tag_name": "v1.2.0",
|
||||
"html_url": "https://github.com/user/videodownloader/releases/tag/v1.2.0",
|
||||
"assets": [
|
||||
{ "name": "VideoDownloader-Setup-x64.exe", "browser_download_url": "https://github.com/user/videodownloader/releases/download/v1.2.0/VideoDownloader-Setup-x64.exe" },
|
||||
{ "name": "VideoDownloader-Setup-arm64.exe", "browser_download_url": "https://github.com/user/videodownloader/releases/download/v1.2.0/VideoDownloader-Setup-arm64.exe" }
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
[Theory]
|
||||
[InlineData("1.0.0", "v1.2.0", true)] // 최신이 더 높음
|
||||
[InlineData("1.2.0", "v1.2.0", false)] // 동일
|
||||
[InlineData("2.0.0", "v1.9.9", false)] // 현재가 더 높음
|
||||
[InlineData("1.0.0.0", "v1.0.1", true)] // 어셈블리 4자리 버전 허용
|
||||
[InlineData("1.0.0", "garbage", false)] // 파싱 불가면 갱신 아님
|
||||
public void 버전비교(string current, string latest, bool expected) =>
|
||||
Assert.Equal(expected, UpdateChecker.IsNewer(current, latest));
|
||||
|
||||
[Fact]
|
||||
public void 자산선택_x64와_arm64_구분()
|
||||
{
|
||||
var assets = new[]
|
||||
{
|
||||
new UpdateAsset("VideoDownloader-Setup-x64.exe", "https://dl/x64"),
|
||||
new UpdateAsset("VideoDownloader-Setup-arm64.exe", "https://dl/arm64"),
|
||||
};
|
||||
|
||||
Assert.Equal("https://dl/x64", UpdateChecker.SelectAsset(assets, "win-x64")!.DownloadUrl);
|
||||
Assert.Equal("https://dl/arm64", UpdateChecker.SelectAsset(assets, "win-arm64")!.DownloadUrl);
|
||||
Assert.Null(UpdateChecker.SelectAsset(assets, "linux-x64")); // 일치 없음
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task 최신확인_새버전이면_자산과_릴리스URL()
|
||||
{
|
||||
var handler = new FakeHandler(HttpStatusCode.OK, ReleaseJson);
|
||||
var checker = new UpdateChecker(handler);
|
||||
|
||||
var result = await checker.CheckAsync("user/videodownloader", "1.0.0", "win-x64");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result!.IsNewer);
|
||||
Assert.Equal("1.2.0", result.LatestVersion);
|
||||
Assert.Equal("https://github.com/user/videodownloader/releases/tag/v1.2.0", result.ReleaseUrl);
|
||||
Assert.Equal("VideoDownloader-Setup-x64.exe", result.Asset!.Name);
|
||||
|
||||
// 기본 채널(자체 Forgejo) 엔드포인트 + User-Agent(GitHub 채널 호환 규약 유지)
|
||||
Assert.Equal("https://git.chanpaca.net/api/v1/repos/user/videodownloader/releases/latest", handler.LastRequest!.RequestUri!.ToString());
|
||||
Assert.NotEmpty(handler.LastRequest.Headers.UserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task 최신확인_이미_최신이면_IsNewer_false()
|
||||
{
|
||||
var checker = new UpdateChecker(new FakeHandler(HttpStatusCode.OK, ReleaseJson));
|
||||
var result = await checker.CheckAsync("user/videodownloader", "1.2.0", "win-x64");
|
||||
Assert.False(result!.IsNewer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task 최신확인_API_실패는_예외로_드러남()
|
||||
{
|
||||
var checker = new UpdateChecker(new FakeHandler(HttpStatusCode.NotFound, """{"message":"Not Found"}"""));
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => checker.CheckAsync("user/none", "1.0.0", "win-x64"));
|
||||
}
|
||||
|
||||
// ── 배포 채널 전환 (DEPLOYMENT_PLAN §5-bis/§5-ter): 기본 = 자체 Forgejo 공개 릴리스 저장소 ──
|
||||
|
||||
[Fact]
|
||||
public async Task 최신확인_기본은_Forgejo_공개저장소_API()
|
||||
{
|
||||
var handler = new FakeHandler(HttpStatusCode.OK, ReleaseJson);
|
||||
var checker = new UpdateChecker(handler);
|
||||
|
||||
await checker.CheckAsync("Video-Downloader/releases", "1.0.0", "win-x64");
|
||||
|
||||
// Forgejo 는 /api/v1 접두가 필요 — GitHub 형태(api.github.com/repos/...)와 경로 규칙만 다르다
|
||||
Assert.Equal(
|
||||
"https://git.chanpaca.net/api/v1/repos/Video-Downloader/releases/releases/latest",
|
||||
handler.LastRequest!.RequestUri!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task 최신확인_apiBase_지정으로_GitHub_채널도_호환()
|
||||
{
|
||||
var handler = new FakeHandler(HttpStatusCode.OK, ReleaseJson);
|
||||
var checker = new UpdateChecker(handler);
|
||||
|
||||
var result = await checker.CheckAsync("user/videodownloader", "1.0.0", "win-x64",
|
||||
apiBase: "https://api.github.com");
|
||||
|
||||
Assert.True(result!.IsNewer);
|
||||
Assert.Equal("https://api.github.com/repos/user/videodownloader/releases/latest",
|
||||
handler.LastRequest!.RequestUri!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 자산선택_osx는_tar_gz_도_허용()
|
||||
{
|
||||
// macOS 프리뷰는 dmg 가 아니라 tar.gz 로 배포 중 — dmg 만 허용하면 mac 은 영원히 자산 없음
|
||||
var assets = new[]
|
||||
{
|
||||
new UpdateAsset("Paca-osx-arm64.tar.gz", "https://dl/osx-arm64"),
|
||||
new UpdateAsset("Paca-osx-x64.tar.gz", "https://dl/osx-x64"),
|
||||
};
|
||||
|
||||
Assert.Equal("https://dl/osx-arm64", UpdateChecker.SelectAsset(assets, "osx-arm64")!.DownloadUrl);
|
||||
Assert.Equal("https://dl/osx-x64", UpdateChecker.SelectAsset(assets, "osx-x64")!.DownloadUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 업데이트_기본채널은_공개릴리스저장소()
|
||||
{
|
||||
var config = new Paca.Core.Config.AppConfig();
|
||||
Assert.Equal("Video-Downloader/releases", config.UpdateRepo);
|
||||
Assert.Equal("https://git.chanpaca.net/api/v1", config.UpdateApiBase);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue