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,107 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Paca.Avalonia;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Platform;
public class AvaloniaAndMobileDiscoveryGateTests
{
private static string SolutionRoot
{
get
{
var dir = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Paca.slnx")) && !File.Exists(Path.Combine(dir, "Paca.sln")))
{
var parent = Directory.GetParent(dir);
if (parent == null) break;
dir = parent.FullName;
}
return dir ?? AppContext.BaseDirectory;
}
}
// =========================================================================
// Gate 1: Avalonia MainViewModel CTS 리소스 누수 방지 검증
// =========================================================================
[Fact]
public async Task Gate_Avalonia_MainViewModel_Cts_Is_Disposed_And_Nulled_After_Execution()
{
var vm = new MainViewModel();
vm.Url = "https://example.com/not-supported"; // 즉시 실패/완료 유도
await vm.RunDownloadAsync();
Assert.False(vm.IsBusy);
Assert.False(vm.HasActiveCts, "다운로드 완료 또는 실패 후 CTS는 Dispose되고 null 처리되어야 합니다.");
}
// =========================================================================
// Gate 2: Avalonia MainViewModel Log 컬렉션 슬라이딩 윈도우 상한(200개) 검증
// =========================================================================
[Fact]
public void Gate_Avalonia_MainViewModel_Log_Sliding_Window_Limit()
{
var vm = new MainViewModel();
for (int i = 0; i < 300; i++)
{
vm.AddLogEntry($"Log item {i}");
}
Assert.True(vm.Log.Count <= 200, $"Log 항목 수는 최대 200개 이하로 캡핑되어야 합니다. 현재: {vm.Log.Count}");
}
// =========================================================================
// Gate 3: Avalonia MainWindow.axaml 바인딩 무결성 (CompileBindings 및 WPF 잔재 제거)
// =========================================================================
[Fact]
public void Gate_Avalonia_MainWindow_Axaml_Clean_Bindings()
{
var axamlPath = Path.Combine(SolutionRoot, "src", "Paca.Avalonia", "MainWindow.axaml");
Assert.True(File.Exists(axamlPath), $"MainWindow.axaml must exist at {axamlPath}");
var content = File.ReadAllText(axamlPath);
Assert.DoesNotContain("x:CompileBindings=\"False\"", content);
Assert.DoesNotContain("UpdateSourceTrigger=PropertyChanged", content);
}
// =========================================================================
// Gate 4: LanDiscoveryMessage 디바이스명에 파이프(|) 포함 시 파싱 무결성 검증
// =========================================================================
[Fact]
public void Gate_LanDiscoveryMessage_Parses_DeviceName_With_Pipes()
{
var devNameWithPipes = "My|Studio|PC";
var endpoint = "192.168.1.100:5000";
var rawPayload = $"PACA_DISCOVERY|PONG|{devNameWithPipes}|{endpoint}|1|abc123fingerprint";
var parsed = LanDiscoveryMessage.Parse(rawPayload);
Assert.NotNull(parsed);
Assert.Equal(LanDiscoveryMessageType.Pong, parsed.Type);
Assert.Equal(devNameWithPipes, parsed.DeviceName);
Assert.Equal(endpoint, parsed.Endpoint);
Assert.True(parsed.IsPairingAllowed);
Assert.Equal("abc123fingerprint", parsed.Fingerprint);
}
// =========================================================================
// Gate 5: LanDiscoveryProber IPv6 엔드포인트 브래킷 포맷팅 검증
// =========================================================================
[Fact]
public void Gate_LanDiscoveryProber_Formats_IPv6_Endpoint_With_Brackets()
{
using var prober = new LanDiscoveryProber();
DiscoveredLanDevice? foundDevice = null;
prober.OnDeviceFound += d => foundDevice = d;
// 원격 송신자가 0.0.0.0:5000을 보냈고, 송신자 IP가 IPv6인 경우
var ipv6Sender = "fe80::1";
prober.HandleReceivedPayload("PACA_DISCOVERY|PONG|TestNode|0.0.0.0:5000|1|fp", ipv6Sender);
Assert.NotNull(foundDevice);
Assert.Contains("[fe80::1]:5000", foundDevice.Endpoint);
Assert.Equal("http://[fe80::1]:5000", foundDevice.HttpBaseUrl);
}
}

View file

@ -0,0 +1,89 @@
using System.IO;
using System.Text.RegularExpressions;
using Xunit;
namespace Paca.Tests.Platform;
/// <summary>
/// Avalonia 크로스플랫폼 셸 회귀 방지 게이트 테스트 (Phase 5).
/// AVLN5001 경고 회귀 방어, MVVM 데이터 바인딩 무결성, URL 분류 로직 검증.
/// </summary>
public class AvaloniaShellGateTests
{
private static string GetSolutionRoot()
{
var dir = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Paca.slnx")) && !File.Exists(Path.Combine(dir, "Paca.sln")))
{
var parent = Directory.GetParent(dir);
if (parent == null) break;
dir = parent.FullName;
}
return dir ?? AppContext.BaseDirectory;
}
private static string AvaloniaProjectDir => Path.Combine(GetSolutionRoot(), "src", "Paca.Avalonia");
[Fact]
public void Gate_MainWindow_Axaml_Complies_With_Modern_Avalonia_Standards()
{
var axamlPath = Path.Combine(AvaloniaProjectDir, "MainWindow.axaml");
Assert.True(File.Exists(axamlPath), $"MainWindow.axaml must exist at {axamlPath}");
var axamlContent = File.ReadAllText(axamlPath);
// AVLN5001 방어: Obsolete Watermark 속성 사용 금지
Assert.DoesNotContain("Watermark=", axamlContent);
// PlaceholderText 속성 사용 확인
Assert.Contains("PlaceholderText=", axamlContent);
// 핵심 MVVM 바인딩 요소 존재 검증
Assert.Contains("{Binding Url", axamlContent);
Assert.Contains("{Binding Status", axamlContent);
Assert.Contains("{Binding Progress", axamlContent);
Assert.Contains("{Binding DownloadCommand}", axamlContent);
Assert.Contains("{Binding CancelCommand}", axamlContent);
}
[Fact]
public void Gate_MainViewModel_Contains_All_Required_Contracts()
{
var vmPath = Path.Combine(AvaloniaProjectDir, "MainViewModel.cs");
Assert.True(File.Exists(vmPath), $"MainViewModel.cs must exist at {vmPath}");
var vmContent = File.ReadAllText(vmPath);
// INotifyPropertyChanged 구현 및 RelayCommand 검증
Assert.Contains("INotifyPropertyChanged", vmContent);
Assert.Contains("RelayCommand", vmContent);
Assert.Contains("DownloadCommand", vmContent);
Assert.Contains("CancelCommand", vmContent);
Assert.Contains("DownloadByKindAsync", vmContent);
Assert.Contains("Classify", vmContent);
// 4대 스트림 타입 분기 검증
Assert.Contains("Kind.Social", vmContent);
Assert.Contains("Kind.Hls", vmContent);
Assert.Contains("Kind.Dash", vmContent);
Assert.Contains("Kind.Direct", vmContent);
}
[Theory]
[InlineData("https://www.youtube.com/watch?v=dQw4w9WgXcQ", "Social")]
[InlineData("https://youtu.be/dQw4w9WgXcQ", "Social")]
[InlineData("https://instagram.com/reel/123456", "Social")]
[InlineData("https://tiktok.com/@user/video/12345", "Social")]
[InlineData("https://x.com/user/status/12345", "Social")]
[InlineData("https://twitter.com/user/status/12345", "Social")]
[InlineData("https://example.com/video/master.m3u8", "Hls")]
[InlineData("https://example.com/stream/manifest.mpd", "Dash")]
[InlineData("https://example.com/files/video.mp4", "Direct")]
public void Gate_Url_Classification_Matches_Expected_Kind(string rawUrl, string expectedKind)
{
var uri = new Uri(rawUrl);
var actualKind = Paca.Avalonia.MainViewModel.Classify(uri).ToString();
Assert.Equal(expectedKind, actualKind);
}
}

View file

@ -0,0 +1,101 @@
using System.Diagnostics;
using System.IO;
using Xunit;
namespace Paca.Tests.Platform;
/// <summary>
/// Paca.Cli 파이프라인 회귀 방지 게이트 테스트 (Phase 5).
/// CLI 옵션 파싱(--help, --dashparse, --dash, HLS), 프로세스 종료 코드 검증.
/// </summary>
public class CliPipelineGateTests
{
private static string GetSolutionRoot()
{
var dir = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Paca.slnx")) && !File.Exists(Path.Combine(dir, "Paca.sln")))
{
var parent = Directory.GetParent(dir);
if (parent == null) break;
dir = parent.FullName;
}
return dir ?? AppContext.BaseDirectory;
}
private static string CliProjectDir => Path.Combine(GetSolutionRoot(), "src", "Paca.Cli");
[Fact]
public void Gate_Cli_Program_Source_Contains_All_Required_Pipelines()
{
var programPath = Path.Combine(CliProjectDir, "Program.cs");
Assert.True(File.Exists(programPath), $"Program.cs must exist at {programPath}");
var content = File.ReadAllText(programPath);
// 핵심 CLI 옵션 검증
Assert.Contains("--help", content);
Assert.Contains("-h", content);
Assert.Contains("--dashparse", content);
Assert.Contains("--dash", content);
Assert.Contains("HlsDownloader", content);
Assert.Contains("DashDownloader", content);
}
[Fact]
public async Task Gate_Cli_Help_Command_Exits_With_Code_Zero()
{
var root = GetSolutionRoot();
var psi = new ProcessStartInfo
{
FileName = "dotnet",
Arguments = "run --project src/Paca.Cli --no-build -- -h",
WorkingDirectory = root,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = new Process { StartInfo = psi };
process.Start();
var outputTask = process.StandardOutput.ReadToEndAsync();
var errorTask = process.StandardError.ReadToEndAsync();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await process.WaitForExitAsync(cts.Token);
var stdout = await outputTask;
var stderr = await errorTask;
Assert.Equal(0, process.ExitCode);
Assert.Contains("Usage: dotnet run", stdout);
}
[Fact]
public async Task Gate_Cli_NoArgs_Prints_Usage_And_Exits_Zero()
{
var root = GetSolutionRoot();
var psi = new ProcessStartInfo
{
FileName = "dotnet",
Arguments = "run --project src/Paca.Cli --no-build",
WorkingDirectory = root,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = new Process { StartInfo = psi };
process.Start();
var outputTask = process.StandardOutput.ReadToEndAsync();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await process.WaitForExitAsync(cts.Token);
var stdout = await outputTask;
Assert.Equal(0, process.ExitCode);
Assert.Contains("Usage: dotnet run", stdout);
}
}

View file

@ -0,0 +1,162 @@
using System;
using System.IO;
using System.Linq;
using Paca.Avalonia;
using Paca.Mobile.Core;
using Xunit;
namespace Paca.Tests.Platform;
/// <summary>
/// Phase 5: 크로스플랫폼 패리티 및 모바일/데스크톱 견고성 헌법 게이트 (Gate 501 ~ 503).
/// - Gate 501: Paca.Avalonia MainViewModel 일시정지/재개(Pause/Resume) 및 IDisposable 생명주기
/// - Gate 502: Paca.Mobile.Core LanDiscoveryProber 널 안전성, IPv6 및 P2P QR TTL 만료 검증
/// - Gate 503: Paca.Mobile.Core SmartCollectionEngine 널 타이틀 방어 및 M3U8 개행문자 살균
/// </summary>
public class CrossPlatformParityHardeningPhase5Tests
{
[Fact]
public void Gate501_Avalonia_MainViewModel_Pause_Resume_And_Disposable_Lifecycle_Audit()
{
using var vm = new MainViewModel();
// 1. IDisposable 구현 검증
Assert.IsAssignableFrom<IDisposable>(vm);
// 2. PauseCommand & ResumeCommand 배선 확인
Assert.NotNull(vm.PauseCommand);
Assert.NotNull(vm.ResumeCommand);
Assert.NotNull(vm.DownloadCommand);
Assert.NotNull(vm.CancelCommand);
// 3. 초기 상태: IsBusy=false, IsPaused=false, CanExecute 검증
Assert.False(vm.IsBusy);
Assert.False(vm.IsPaused);
Assert.False(vm.PauseCommand.CanExecute(null));
Assert.False(vm.ResumeCommand.CanExecute(null));
// 4. IsBusy 상태 시뮬레이션
vm.IsBusy = true;
Assert.True(vm.PauseCommand.CanExecute(null));
Assert.False(vm.ResumeCommand.CanExecute(null));
// 5. 일시정지(Pause) 실행
vm.Pause();
Assert.True(vm.IsPaused);
Assert.Equal("일시정지됨", vm.Status);
Assert.False(vm.PauseCommand.CanExecute(null));
Assert.True(vm.ResumeCommand.CanExecute(null));
// 6. 재개(Resume) 실행
vm.Resume();
Assert.False(vm.IsPaused);
Assert.Equal("다운로드 재개 중...", vm.Status);
Assert.True(vm.PauseCommand.CanExecute(null));
Assert.False(vm.ResumeCommand.CanExecute(null));
// 7. 다운로드 완료(IsBusy=false) 시 IsPaused 자동 초기화
vm.Pause();
Assert.True(vm.IsPaused);
vm.IsBusy = false;
Assert.False(vm.IsPaused);
Assert.False(vm.PauseCommand.CanExecute(null));
Assert.False(vm.ResumeCommand.CanExecute(null));
}
[Fact]
public void Gate502_LanDiscoveryProber_Robust_Address_And_NullSafe_Packet_Handling_Audit()
{
var prober = new LanDiscoveryProber();
// 1. Null/Whitespace 페이로드 안전성 (예외 미발생)
prober.HandleReceivedPayload(null!, null!);
prober.HandleReceivedPayload("", "192.168.1.50");
prober.HandleReceivedPayload(" ", "");
// 2. RemoteIp가 null/빈값인 경우 127.0.0.1 기본 폴백
var pong = LanDiscoveryMessage.CreatePong("PC-NullIp", ":5000", true, "FP-001");
prober.HandleReceivedPayload(pong.ToPayload(), null!);
var devices = prober.GetActiveDevices();
var devNullIp = devices.FirstOrDefault(d => d.DeviceName == "PC-NullIp");
Assert.NotNull(devNullIp);
Assert.Equal("127.0.0.1:5000", devNullIp.Endpoint);
// 3. IPv6 remoteIp의 브래킷([]) 포맷팅 검증
var pongIpv6 = LanDiscoveryMessage.CreatePong("PC-IPv6", ":52100", true, "FP-IPV6");
prober.HandleReceivedPayload(pongIpv6.ToPayload(), "fe80::1");
devices = prober.GetActiveDevices();
var devIpv6 = devices.FirstOrDefault(d => d.DeviceName == "PC-IPv6");
Assert.NotNull(devIpv6);
Assert.Equal("[fe80::1]:52100", devIpv6.Endpoint);
Assert.Equal("http://[fe80::1]:52100", devIpv6.HttpBaseUrl);
// 4. P2pQrPayloadCodec TTL 만료 검증
var now = DateTimeOffset.UtcNow;
var freshPayload = new P2pQrPayload
{
Host = "192.168.1.10",
Port = 5000,
Token = "token-fresh",
CreatedAtUnix = now.ToUnixTimeSeconds()
};
var encodedFresh = P2pQrPayloadCodec.Encode(freshPayload);
Assert.Contains("&ts=", encodedFresh);
var decodedFresh = P2pQrPayloadCodec.Decode(encodedFresh);
Assert.Equal(freshPayload.CreatedAtUnix, decodedFresh.CreatedAtUnix);
Assert.False(P2pQrPayloadCodec.IsExpired(decodedFresh, TimeSpan.FromMinutes(5), now));
var expiredPayload = new P2pQrPayload
{
Host = "192.168.1.10",
Port = 5000,
Token = "token-expired",
CreatedAtUnix = now.AddMinutes(-10).ToUnixTimeSeconds()
};
var encodedExpired = P2pQrPayloadCodec.Encode(expiredPayload);
var decodedExpired = P2pQrPayloadCodec.Decode(encodedExpired);
Assert.True(P2pQrPayloadCodec.IsExpired(decodedExpired, TimeSpan.FromMinutes(5), now));
}
[Fact]
public void Gate503_SmartCollectionEngine_NullSafe_Title_And_M3u8_Sanitization_Audit()
{
// 1. Title이 null이거나 빈 문자열인 경우 NRE 없이 정상 분류
var itemNullTitle = new MediaItemMetadata(
Id: "item-1",
Title: null!,
Url: "https://youtube.com/shorts/test1234",
DurationSeconds: 45,
SizeBytes: 1024 * 1024,
Format: "mp4"
);
var itemAudio = new MediaItemMetadata(
Id: "item-2",
Title: "Podcast Episode 1",
Url: "https://example.com/audio.mp3",
DurationSeconds: 1200,
SizeBytes: 20 * 1024 * 1024,
Format: "mp3"
);
var itemWithNewlines = new MediaItemMetadata(
Id: "item-3",
Title: "Title With\r\nDangerous\nNewlines",
Url: "https://example.com/video.mp4",
DurationSeconds: 300,
SizeBytes: 50 * 1024 * 1024,
Format: "mp4"
);
var collections = SmartCollectionEngine.GroupIntoCollections(new[] { itemNullTitle, itemAudio, itemWithNewlines });
Assert.Contains(itemNullTitle, collections[SmartCollectionEngine.CollectionShorts]);
Assert.Contains(itemAudio, collections[SmartCollectionEngine.CollectionMusic]);
Assert.Contains(itemWithNewlines, collections[SmartCollectionEngine.CollectionGeneral]);
// 2. M3U8 플레이리스트 생성 시 개행 문자 살균 검증
var m3u8 = SmartCollectionEngine.GenerateM3u8Playlist("Playlist\r\nName", new[] { itemWithNewlines });
Assert.DoesNotContain("\r\nDangerous", m3u8);
Assert.Contains("#PLAYLIST:Playlist Name", m3u8);
Assert.Contains("#EXTINF:300,Title With Dangerous Newlines", m3u8);
}
}

View 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&lt;T&gt; 는 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);
}
}