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
55
tests/Paca.Tests/Core/AdaptiveBufferScalerGateTests.cs
Normal file
55
tests/Paca.Tests/Core/AdaptiveBufferScalerGateTests.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
using System;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class AdaptiveBufferScalerGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate393_AdaptiveBufferScaler_SelectsGigabitUltra_ForHighThroughput()
|
||||
{
|
||||
// 80 MB/s
|
||||
var speed = 80.0 * 1024 * 1024;
|
||||
var profile = AdaptiveBufferScaler.GetAdaptationProfile(speed);
|
||||
|
||||
Assert.Equal(BufferScalingTier.GigabitUltra, profile.CurrentTier);
|
||||
Assert.Equal(512 * 1024, profile.BufferSizeBytes);
|
||||
Assert.Equal(20 * 1024 * 1024L, profile.RecommendedChunkSizeBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate394_AdaptiveBufferScaler_SelectsMedium_ForModerateSpeed()
|
||||
{
|
||||
// 5 MB/s
|
||||
var speed = 5.0 * 1024 * 1024;
|
||||
var profile = AdaptiveBufferScaler.GetAdaptationProfile(speed);
|
||||
|
||||
Assert.Equal(BufferScalingTier.MediumBandwidth, profile.CurrentTier);
|
||||
Assert.Equal(32 * 1024, profile.BufferSizeBytes);
|
||||
Assert.Equal(5 * 1024 * 1024L, profile.RecommendedChunkSizeBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate395_AdaptiveBufferScaler_SelectsLow_ForSlowConnections()
|
||||
{
|
||||
// 500 KB/s
|
||||
var speed = 500.0 * 1024;
|
||||
var profile = AdaptiveBufferScaler.GetAdaptationProfile(speed);
|
||||
|
||||
Assert.Equal(BufferScalingTier.LowBandwidth, profile.CurrentTier);
|
||||
Assert.Equal(8 * 1024, profile.BufferSizeBytes);
|
||||
Assert.Equal(1 * 1024 * 1024L, profile.RecommendedChunkSizeBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate396_AdaptiveBufferScaler_SmoothsSpeed_WithExponentialMovingAverage()
|
||||
{
|
||||
var prevAvg = 10.0;
|
||||
var newSample = 20.0;
|
||||
// With alpha 0.3: 0.3 * 20 + 0.7 * 10 = 6 + 7 = 13.0
|
||||
var smoothed = AdaptiveBufferScaler.SmoothSpeedEma(prevAvg, newSample, alpha: 0.3);
|
||||
|
||||
Assert.Equal(13.0, smoothed, 2);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Paca.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 적응형 청크 크기 조절, 체크섬 무결성, 발열 스로틀링, 트랜스코딩 프로파일 및 스트리밍 프록시 게이트 (Gate 131 ~ Gate 135)
|
||||
/// </summary>
|
||||
public class AdaptiveStreamingAndIntegrityGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate131_AdaptiveChunkSizingEngine_Audit()
|
||||
{
|
||||
var engine = new AdaptiveChunkSizingEngine();
|
||||
|
||||
// 60 Mbps (초고속망) -> 8MB 청크
|
||||
Assert.Equal(8 * 1024 * 1024, engine.CalculateOptimalChunkSize(measuredSpeedKbps: 60 * 1024));
|
||||
|
||||
// 20 Mbps (표준망) -> 4MB 청크
|
||||
Assert.Equal(4 * 1024 * 1024, engine.CalculateOptimalChunkSize(measuredSpeedKbps: 20 * 1024));
|
||||
|
||||
// 5 Mbps (저속망) -> 1MB 청크
|
||||
Assert.Equal(1 * 1024 * 1024, engine.CalculateOptimalChunkSize(measuredSpeedKbps: 5 * 1024));
|
||||
|
||||
// 500 Kbps (극저속망) -> 512KB 청크
|
||||
Assert.Equal(512 * 1024, engine.CalculateOptimalChunkSize(measuredSpeedKbps: 500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate132_MediaChecksumValidator_Sha256_Audit()
|
||||
{
|
||||
var validator = new MediaChecksumValidator();
|
||||
var sampleBytes = Encoding.UTF8.GetBytes("Paca Secure Media Download Engine 2026");
|
||||
|
||||
// 기대되는 SHA-256 해시 계산
|
||||
using var sha = SHA256.Create();
|
||||
var expectedHash = Convert.ToHexString(sha.ComputeHash(sampleBytes)).ToLowerInvariant();
|
||||
|
||||
Assert.True(validator.VerifySha256(sampleBytes, expectedHash));
|
||||
Assert.False(validator.VerifySha256(sampleBytes, "invalid_hash_string"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate133_ThermalThrottlingGuard_Audit()
|
||||
{
|
||||
var guard = new ThermalThrottlingGuard();
|
||||
Assert.Equal(42.0, guard.OverheatThresholdCelsius);
|
||||
Assert.Equal(38.0, guard.NormalRecoveryThresholdCelsius);
|
||||
|
||||
// 35°C 정상 -> 스로틀링 비활성, 6세그먼트
|
||||
Assert.False(guard.IsThrottled(currentTemperatureCelsius: 35.0));
|
||||
Assert.Equal(6, guard.GetAdjustedSegments(requestedSegments: 6, currentTemperatureCelsius: 35.0));
|
||||
|
||||
// 43°C 과열 -> 스로틀링 활성, 1세그먼트로 강제 감속
|
||||
Assert.True(guard.IsThrottled(currentTemperatureCelsius: 43.0));
|
||||
Assert.Equal(1, guard.GetAdjustedSegments(requestedSegments: 6, currentTemperatureCelsius: 43.0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate134_TranscodeProfileManager_Profiles_Audit()
|
||||
{
|
||||
var manager = new TranscodeProfileManager();
|
||||
|
||||
var mp4Args = manager.GetFfmpegProfileArgs("mp4_universal");
|
||||
Assert.Contains("-c:v libx264", mp4Args);
|
||||
Assert.Contains("-c:a aac", mp4Args);
|
||||
|
||||
var webmArgs = manager.GetFfmpegProfileArgs("webm_modern");
|
||||
Assert.Contains("-c:v libvpx-vp9", webmArgs);
|
||||
Assert.Contains("-c:a libopus", webmArgs);
|
||||
|
||||
var mp3Args = manager.GetFfmpegProfileArgs("audio_mp3_hq");
|
||||
Assert.Contains("-vn", mp3Args);
|
||||
Assert.Contains("-b:a 320k", mp3Args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate135_RemoteStreamProxyBuffer_RingBuffer_Audit()
|
||||
{
|
||||
var buffer = new RemoteStreamProxyBuffer(capacityBytes: 1024);
|
||||
Assert.Equal(0, buffer.BufferedBytes);
|
||||
|
||||
var chunk1 = new byte[256];
|
||||
Array.Fill(chunk1, (byte)0xAA);
|
||||
buffer.Write(chunk1);
|
||||
Assert.Equal(256, buffer.BufferedBytes);
|
||||
|
||||
var readTarget = new byte[128];
|
||||
var readCount = buffer.Read(readTarget, 0, 128);
|
||||
Assert.Equal(128, readCount);
|
||||
Assert.Equal(128, buffer.BufferedBytes);
|
||||
Assert.Equal(0xAA, readTarget[0]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Paca.Core.Hls;
|
||||
using Paca.Core.Platform;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class AdaptiveStreamingBitrateControllerGateTests
|
||||
{
|
||||
private readonly List<Variant> _sampleVariants = new()
|
||||
{
|
||||
new Variant(new Uri("https://paca.stream/v_1080.m3u8"), 6_000_000, "1920x1080", "avc1"),
|
||||
new Variant(new Uri("https://paca.stream/v_720.m3u8"), 2_800_000, "1280x720", "avc1"),
|
||||
new Variant(new Uri("https://paca.stream/v_480.m3u8"), 1_200_000, "854x480", "avc1"),
|
||||
new Variant(new Uri("https://paca.stream/v_360.m3u8"), 600_000, "640x360", "avc1")
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Gate464_AdaptiveStreamingBitrateController_CalculatesEwmaSpeed_Accurately()
|
||||
{
|
||||
var controller = new AdaptiveStreamingBitrateController();
|
||||
|
||||
// 1MB in 1 sec = 8,000,000 bps
|
||||
controller.RecordSample(bytes: 1_000_000, duration: TimeSpan.FromSeconds(1));
|
||||
var speed1 = controller.CurrentBandwidthBps;
|
||||
Assert.Equal(8_000_000, speed1);
|
||||
|
||||
// Next sample: 500KB in 1 sec = 4,000,000 bps
|
||||
// EWMA with alpha 0.5: 0.5 * 4M + 0.5 * 8M = 6,000,000 bps
|
||||
controller.RecordSample(bytes: 500_000, duration: TimeSpan.FromSeconds(1), alpha: 0.5);
|
||||
var speed2 = controller.CurrentBandwidthBps;
|
||||
Assert.Equal(6_000_000, speed2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate465_AdaptiveStreamingBitrateController_SelectsHighestQuality_UnderHighestStrategy()
|
||||
{
|
||||
var controller = new AdaptiveStreamingBitrateController(BitrateSelectionStrategy.HighestQuality);
|
||||
var selected = controller.SelectVariant(_sampleVariants);
|
||||
|
||||
Assert.NotNull(selected);
|
||||
Assert.Equal(6_000_000, selected.Bandwidth);
|
||||
Assert.Equal(1080, selected.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate466_AdaptiveStreamingBitrateController_SelectsLowestBandwidth_UnderDataSaverStrategy()
|
||||
{
|
||||
var controller = new AdaptiveStreamingBitrateController(BitrateSelectionStrategy.DataSaver);
|
||||
var selected = controller.SelectVariant(_sampleVariants);
|
||||
|
||||
Assert.NotNull(selected);
|
||||
Assert.Equal(600_000, selected.Bandwidth);
|
||||
Assert.Equal(360, selected.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate467_AdaptiveStreamingBitrateController_SelectsOptimalVariant_UnderDynamicAdaptiveStrategy()
|
||||
{
|
||||
var controller = new AdaptiveStreamingBitrateController(BitrateSelectionStrategy.DynamicAdaptive);
|
||||
|
||||
// Network throughput: 3,500,000 bps. With safety headroom 0.85 -> safe budget = 2,975,000 bps
|
||||
// Should select 720p (2,800,000 bps)
|
||||
controller.SetEstimatedBandwidth(3_500_000);
|
||||
var selected = controller.SelectVariant(_sampleVariants);
|
||||
|
||||
Assert.NotNull(selected);
|
||||
Assert.Equal(2_800_000, selected.Bandwidth);
|
||||
Assert.Equal(720, selected.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate468_AdaptiveStreamingBitrateController_EnforcesTargetCap_WhenConfigured()
|
||||
{
|
||||
// Strategy is Highest, but MaxCap is 3,000,000 bps -> should cap at 720p (2.8M)
|
||||
var controller = new AdaptiveStreamingBitrateController(
|
||||
strategy: BitrateSelectionStrategy.HighestQuality,
|
||||
maxBitrateCapBps: 3_000_000
|
||||
);
|
||||
|
||||
var selected = controller.SelectVariant(_sampleVariants);
|
||||
|
||||
Assert.NotNull(selected);
|
||||
Assert.Equal(2_800_000, selected.Bandwidth);
|
||||
Assert.Equal(720, selected.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate469_AdaptiveStreamingBitrateController_AppliesHysteresisDamping_ToPreventThrashing()
|
||||
{
|
||||
var controller = new AdaptiveStreamingBitrateController(
|
||||
strategy: BitrateSelectionStrategy.DynamicAdaptive,
|
||||
consecutiveSamplesForSwitch: 2
|
||||
);
|
||||
|
||||
// Initial: 1080p
|
||||
controller.SetEstimatedBandwidth(10_000_000);
|
||||
var initial = controller.SelectVariant(_sampleVariants);
|
||||
Assert.Equal(1080, initial.Height);
|
||||
|
||||
// Transient dip for 1 sample: 1,500,000 bps (480p territory: 1.5M * 0.85 = 1.275M >= 1.2M)
|
||||
controller.SetEstimatedBandwidth(1_500_000);
|
||||
var damped = controller.SelectVariant(_sampleVariants);
|
||||
// Hysteresis holds 1080p on first dip!
|
||||
Assert.Equal(1080, damped.Height);
|
||||
|
||||
// Second consecutive dip confirms downshift
|
||||
var confirmed = controller.SelectVariant(_sampleVariants);
|
||||
Assert.Equal(480, confirmed.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate470_AdaptiveStreamingBitrateController_IntegratesWithBatterySaver_ToDownshift()
|
||||
{
|
||||
var powerManager = new BatterySaverPowerManager();
|
||||
// Simulate battery at 15% (Critical battery saving active)
|
||||
powerManager.UpdateBatteryState(percent: 15, isCharging: false);
|
||||
|
||||
var controller = new AdaptiveStreamingBitrateController(
|
||||
strategy: BitrateSelectionStrategy.DynamicAdaptive,
|
||||
powerManager: powerManager
|
||||
);
|
||||
|
||||
// Even with 10Gbps unlimited network, Battery Saver enforces max 720p / 3Mbps to preserve CPU/GPU decode cycles
|
||||
controller.SetEstimatedBandwidth(100_000_000);
|
||||
var selected = controller.SelectVariant(_sampleVariants);
|
||||
|
||||
Assert.NotNull(selected);
|
||||
Assert.True(selected.Bandwidth <= 3_000_000);
|
||||
Assert.True(selected.Height <= 720);
|
||||
}
|
||||
}
|
||||
72
tests/Paca.Tests/Core/AudioAlbumTrackSplitterGateTests.cs
Normal file
72
tests/Paca.Tests/Core/AudioAlbumTrackSplitterGateTests.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Paca.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class AudioAlbumTrackSplitterGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate409_AudioAlbumTrackSplitter_BuildsSilenceDetectArgs_Properly()
|
||||
{
|
||||
var args = AudioAlbumTrackSplitter.BuildSilenceDetectArgs("album.mp3", noiseDb: -35.0, durationSec: 2.0);
|
||||
|
||||
Assert.Contains("-i \"album.mp3\"", args);
|
||||
Assert.Contains("silencedetect=noise=-35dB:d=2.0", args);
|
||||
Assert.Contains("-f null -", args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate410_AudioAlbumTrackSplitter_ParsesSilenceSegments_IntoTracks_WithMinDurationFilter()
|
||||
{
|
||||
var log = "[silence_detect @ 0x1234] silence_start: 177.5\n[silence_detect @ 0x1234] silence_end: 180.0 | silence_duration: 2.5\n[silence_detect @ 0x1234] silence_start: 357.0\n[silence_detect @ 0x1234] silence_end: 360.0 | silence_duration: 3.0";
|
||||
|
||||
var tracks = AudioAlbumTrackSplitter.ParseSilenceToTracks(log, totalDurationSec: 600.0, minTrackDurationSec: 30.0);
|
||||
|
||||
Assert.Equal(3, tracks.Count);
|
||||
|
||||
Assert.Equal(1, tracks[0].Index);
|
||||
Assert.Equal(0.0, tracks[0].StartSec, 1);
|
||||
Assert.Equal(177.5, tracks[0].EndSec, 1);
|
||||
|
||||
Assert.Equal(2, tracks[1].Index);
|
||||
Assert.Equal(180.0, tracks[1].StartSec, 1);
|
||||
Assert.Equal(357.0, tracks[1].EndSec, 1);
|
||||
|
||||
Assert.Equal(3, tracks[2].Index);
|
||||
Assert.Equal(360.0, tracks[2].StartSec, 1);
|
||||
Assert.Equal(600.0, tracks[2].EndSec, 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate411_AudioAlbumTrackSplitter_BuildsLosslessSplitCommand_PerTrack()
|
||||
{
|
||||
var track = new AlbumTrackSegment(1, 0.0, 177.5, "Track 01");
|
||||
var cmd = AudioAlbumTrackSplitter.BuildSplitCommand("album.mp3", track, "out_01.mp3");
|
||||
|
||||
Assert.Contains("-ss 0.000", cmd);
|
||||
Assert.Contains("-to 177.500", cmd);
|
||||
Assert.Contains("-i \"album.mp3\"", cmd);
|
||||
Assert.Contains("-c copy", cmd);
|
||||
Assert.Contains("\"out_01.mp3\"", cmd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate412_AudioAlbumTrackSplitter_GeneratesM3uPlaylist_Accurately()
|
||||
{
|
||||
var tracks = new List<AlbumTrackSegment>
|
||||
{
|
||||
new(1, 0.0, 180.0, "Intro Jam"),
|
||||
new(2, 180.0, 420.0, "Main Song")
|
||||
};
|
||||
|
||||
var m3u = AudioAlbumTrackSplitter.GenerateM3uPlaylist("My Album", tracks, "track_{0:D2}.mp3");
|
||||
|
||||
Assert.StartsWith("#EXTM3U", m3u.TrimStart());
|
||||
Assert.Contains("#EXTINF:180,My Album - Intro Jam", m3u);
|
||||
Assert.Contains("track_01.mp3", m3u);
|
||||
Assert.Contains("#EXTINF:240,My Album - Main Song", m3u);
|
||||
Assert.Contains("track_02.mp3", m3u);
|
||||
}
|
||||
}
|
||||
50
tests/Paca.Tests/Core/AudioChannelDownmixerGateTests.cs
Normal file
50
tests/Paca.Tests/Core/AudioChannelDownmixerGateTests.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
using System;
|
||||
using Paca.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class AudioChannelDownmixerGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate329_AudioChannelDownmixer_BuildsStandardItuR775Filter()
|
||||
{
|
||||
var filter = AudioChannelDownmixer.BuildDownmixFilterArgs(DownmixMode.StandardItuR775);
|
||||
|
||||
Assert.Contains("pan=stereo|FL=0.7071*FL+0.5*FC+0.7071*BL|FR=0.7071*FR+0.5*FC+0.7071*BR", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate330_AudioChannelDownmixer_BuildsNightDialogBoostFilter()
|
||||
{
|
||||
var filter = AudioChannelDownmixer.BuildDownmixFilterArgs(DownmixMode.NightDialogBoost);
|
||||
|
||||
// Center channel (FC) boosted to 1.0, side noise attenuated
|
||||
Assert.Contains("pan=stereo|FL=0.5*FL+1.0*FC+0.3*BL|FR=0.5*FR+1.0*FC+0.3*BR", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate331_AudioChannelDownmixer_BuildsMonoCompatibilityFilter()
|
||||
{
|
||||
var filter = AudioChannelDownmixer.BuildDownmixFilterArgs(DownmixMode.MonoCompatibility);
|
||||
|
||||
Assert.Contains("pan=mono|c0=0.5*FL+0.5*FR", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate332_AudioChannelDownmixer_BuildsFullCommandLine_WithVideoCopy()
|
||||
{
|
||||
var cmd = AudioChannelDownmixer.BuildDownmixCommandLine(
|
||||
inputPath: "C:\\movies\\surround51.mp4",
|
||||
outputPath: "C:\\movies\\stereo_dialog.mp4",
|
||||
mode: DownmixMode.NightDialogBoost,
|
||||
copyVideo: true
|
||||
);
|
||||
|
||||
Assert.Contains("-i \"C:\\movies\\surround51.mp4\"", cmd);
|
||||
Assert.Contains("-c:v copy", cmd);
|
||||
Assert.Contains("-filter:a \"pan=stereo|FL=0.5*FL+1.0*FC+0.3*BL|FR=0.5*FR+1.0*FC+0.3*BR\"", cmd);
|
||||
Assert.Contains("-c:a aac -b:a 192k", cmd);
|
||||
Assert.EndsWith("\"C:\\movies\\stereo_dialog.mp4\" -y", cmd);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
using System;
|
||||
using Paca.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class AudioDynamicRangeCompressorGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate429_AudioDynamicRangeCompressor_BuildsPresetFilter_Accurately()
|
||||
{
|
||||
var filter = AudioDynamicRangeCompressor.BuildFilter(CompressorPreset.NightListening);
|
||||
|
||||
Assert.Contains("threshold=-24dB", filter);
|
||||
Assert.Contains("ratio=6", filter);
|
||||
Assert.Contains("alimiter=limit=-1dB", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate430_AudioDynamicRangeCompressor_BuildsCustomCompressorFilter()
|
||||
{
|
||||
var filter = AudioDynamicRangeCompressor.BuildCustomFilter(
|
||||
thresholdDb: -22.0,
|
||||
ratio: 5.0,
|
||||
attackMs: 8.0,
|
||||
releaseMs: 120.0,
|
||||
makeupDb: 5.0,
|
||||
limitPeakDb: -0.5
|
||||
);
|
||||
|
||||
Assert.Contains("threshold=-22dB", filter);
|
||||
Assert.Contains("ratio=5", filter);
|
||||
Assert.Contains("attack=8", filter);
|
||||
Assert.Contains("release=120", filter);
|
||||
Assert.Contains("makeup=5dB", filter);
|
||||
Assert.Contains("alimiter=limit=-0.5dB", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate431_AudioDynamicRangeCompressor_HandlesPassthrough()
|
||||
{
|
||||
var filter = AudioDynamicRangeCompressor.BuildFilter(CompressorPreset.None);
|
||||
Assert.Equal("anull", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate432_AudioDynamicRangeCompressor_BuildsFfmpegArgs_Properly()
|
||||
{
|
||||
var args = AudioDynamicRangeCompressor.BuildFfmpegArgs("in.mp4", "out.mp4", CompressorPreset.NightListening, copyVideo: true);
|
||||
|
||||
Assert.Contains("-i \"in.mp4\"", args);
|
||||
Assert.Contains("-c:v copy", args);
|
||||
Assert.Contains("-af \"acompressor=", args);
|
||||
Assert.Contains("-c:a aac -b:a 192k", args);
|
||||
Assert.Contains("\"out.mp4\"", args);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class AudioEqualizerProfileManagerGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate417_AudioEqualizerProfileManager_ProvidesStandardPresets()
|
||||
{
|
||||
var bass = AudioEqualizerProfileManager.GetPreset(EqualizerPresetType.BassBoost);
|
||||
Assert.True(bass.Gains[0] > 0); // 31 Hz
|
||||
Assert.True(bass.Gains[1] > 0); // 62 Hz
|
||||
|
||||
var podcast = AudioEqualizerProfileManager.GetPreset(EqualizerPresetType.PodcastVoice);
|
||||
Assert.True(podcast.Gains[0] < 0); // cut sub-bass
|
||||
Assert.True(podcast.Gains[5] > 0); // 1 kHz boost
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate418_AudioEqualizerProfileManager_ClampsGains_WithinSafeLimits()
|
||||
{
|
||||
var rawGains = new double[] { -30.0, 50.0, 0, 0, 0, 0, 0, 0, 0, 0 };
|
||||
var profile = AudioEqualizerProfileManager.CreateCustomProfile("Test", rawGains);
|
||||
|
||||
Assert.Equal(-15.0, profile.Gains[0]);
|
||||
Assert.Equal(15.0, profile.Gains[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate419_AudioEqualizerProfileManager_GeneratesFfmpegEqualizerFilter_Correctly()
|
||||
{
|
||||
// Flat preset (all 0s) returns "anull"
|
||||
var flat = AudioEqualizerProfileManager.GetPreset(EqualizerPresetType.Flat);
|
||||
var flatFilter = AudioEqualizerProfileManager.BuildFfmpegFilter(flat);
|
||||
Assert.Equal("anull", flatFilter);
|
||||
|
||||
// Bass boost generates equalizer filters only for non-zero bands
|
||||
var bass = AudioEqualizerProfileManager.GetPreset(EqualizerPresetType.BassBoost);
|
||||
var bassFilter = AudioEqualizerProfileManager.BuildFfmpegFilter(bass);
|
||||
Assert.Contains("equalizer=f=31:width_type=o:width=1:g=", bassFilter);
|
||||
Assert.Contains("equalizer=f=62:width_type=o:width=1:g=", bassFilter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate420_AudioEqualizerProfileManager_SerializesAndDeserializes_CustomProfileJson()
|
||||
{
|
||||
var original = AudioEqualizerProfileManager.GetPreset(EqualizerPresetType.VocalEnhance);
|
||||
var json = AudioEqualizerProfileManager.ToJson(original);
|
||||
var restored = AudioEqualizerProfileManager.FromJson(json);
|
||||
|
||||
Assert.NotNull(restored);
|
||||
Assert.Equal(original.Name, restored.Name);
|
||||
Assert.Equal(original.Gains.Length, restored.Gains.Length);
|
||||
for (int i = 0; i < original.Gains.Length; i++)
|
||||
{
|
||||
Assert.Equal(original.Gains[i], restored.Gains[i], 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
64
tests/Paca.Tests/Core/AudioPitchShiftEngineGateTests.cs
Normal file
64
tests/Paca.Tests/Core/AudioPitchShiftEngineGateTests.cs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
using System;
|
||||
using Paca.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class AudioPitchShiftEngineGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate385_AudioPitchShift_CalculatesAccuratePitchScale()
|
||||
{
|
||||
// 0 semitones -> 1.0
|
||||
Assert.Equal(1.0, AudioPitchShiftEngine.CalculatePitchScale(0.0), 4);
|
||||
|
||||
// +12 semitones (1 octave up) -> 2.0
|
||||
Assert.Equal(2.0, AudioPitchShiftEngine.CalculatePitchScale(12.0), 4);
|
||||
|
||||
// -12 semitones (1 octave down) -> 0.5
|
||||
Assert.Equal(0.5, AudioPitchShiftEngine.CalculatePitchScale(-12.0), 4);
|
||||
|
||||
// +1 semitone -> ~1.0595
|
||||
Assert.Equal(1.0595, AudioPitchShiftEngine.CalculatePitchScale(1.0), 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate386_AudioPitchShift_BuildsFfmpegFilterChain()
|
||||
{
|
||||
// +2 semitones (scale ~ 1.1225)
|
||||
var filter = AudioPitchShiftEngine.BuildPitchShiftFilter(semitones: 2.0, sampleRate: 44100);
|
||||
|
||||
Assert.Contains("asetrate=", filter);
|
||||
Assert.Contains("atempo=", filter);
|
||||
Assert.Contains("aresample=44100", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate387_AudioPitchShift_ClampsExtremeSemitoneRanges()
|
||||
{
|
||||
var clampedHigh = AudioPitchShiftEngine.ClampSemitones(24.0);
|
||||
Assert.Equal(12.0, clampedHigh);
|
||||
|
||||
var clampedLow = AudioPitchShiftEngine.ClampSemitones(-30.0);
|
||||
Assert.Equal(-12.0, clampedLow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate388_AudioPitchShift_BuildsFullCommandLine_WithVideoCopy()
|
||||
{
|
||||
var cmd = AudioPitchShiftEngine.BuildPitchShiftCommandLine(
|
||||
inputPath: "C:\\music\\karaoke.mp4",
|
||||
outputPath: "C:\\music\\karaoke_plus1.mp4",
|
||||
semitones: 1.0,
|
||||
isAudioOnly: false,
|
||||
sampleRate: 48000
|
||||
);
|
||||
|
||||
Assert.Contains("-i \"C:\\music\\karaoke.mp4\"", cmd);
|
||||
Assert.Contains("-c:v copy", cmd);
|
||||
Assert.Contains("-filter:a \"asetrate=", cmd);
|
||||
Assert.Contains("aresample=48000", cmd);
|
||||
Assert.Contains("-c:a aac -b:a 192k", cmd);
|
||||
Assert.EndsWith("\"C:\\music\\karaoke_plus1.mp4\" -y", cmd);
|
||||
}
|
||||
}
|
||||
76
tests/Paca.Tests/Core/AudioVocalStemExtractorGateTests.cs
Normal file
76
tests/Paca.Tests/Core/AudioVocalStemExtractorGateTests.cs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
using System;
|
||||
using Paca.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class AudioVocalStemExtractorGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate317_AudioVocalStemExtractor_BuildsKaraokeVocalRemovalFilter()
|
||||
{
|
||||
var filter = AudioVocalStemExtractor.BuildFfmpegStemFilter(AudioStemMode.VocalRemovalKaraoke);
|
||||
|
||||
Assert.Contains("pan=stereo|c0=0.5*c0-0.5*c1|c1=0.5*c1-0.5*c0", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate318_AudioVocalStemExtractor_BuildsSpeechVoiceEnhanceFilter()
|
||||
{
|
||||
var filter = AudioVocalStemExtractor.BuildFfmpegStemFilter(AudioStemMode.VoiceEnhanceSpeechOnly);
|
||||
|
||||
Assert.Contains("highpass=f=200", filter);
|
||||
Assert.Contains("lowpass=f=3400", filter);
|
||||
Assert.Contains("afftdn=nf=-25", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate319_AudioVocalStemExtractor_BuildsBassBoostFilter()
|
||||
{
|
||||
var filter = AudioVocalStemExtractor.BuildFfmpegStemFilter(AudioStemMode.BassBoost);
|
||||
|
||||
Assert.Contains("bass=g=8:f=110:w=0.6", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate320_AudioVocalStemExtractor_BuildsFullCommandLine_Correctly()
|
||||
{
|
||||
var cmd = AudioVocalStemExtractor.BuildStemCommandLine(
|
||||
inputPath: "C:\\music\\song.mp3",
|
||||
outputPath: "C:\\music\\song_karaoke.mp3",
|
||||
mode: AudioStemMode.VocalRemovalKaraoke,
|
||||
audioCodec: "libmp3lame"
|
||||
);
|
||||
|
||||
Assert.Contains("-i \"C:\\music\\song.mp3\"", cmd);
|
||||
Assert.Contains("-filter:a \"pan=stereo|c0=0.5*c0-0.5*c1|c1=0.5*c1-0.5*c0\"", cmd);
|
||||
Assert.Contains("-c:a libmp3lame -b:a 320k", cmd);
|
||||
Assert.EndsWith("\"C:\\music\\song_karaoke.mp3\" -y", cmd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate321_AudioVocalStemExtractor_BuildsIsolatedVocalsAcapellaFilter()
|
||||
{
|
||||
var filter = AudioVocalStemExtractor.BuildFfmpegStemFilter(AudioStemMode.IsolatedVocalsAcapella);
|
||||
|
||||
Assert.Contains("0.5*c0+0.5*c1", filter);
|
||||
Assert.Contains("bandpass=f=1200", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate322_AudioVocalStemExtractor_BuildsNightModeDialogueNormalizationFilter()
|
||||
{
|
||||
var filter = AudioVocalStemExtractor.BuildFfmpegStemFilter(AudioStemMode.NightModeDialogueNormalization);
|
||||
|
||||
Assert.Contains("dynaudnorm=f=150", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate323_AudioVocalStemExtractor_BuildsPodcastBroadcastSpeechFilter()
|
||||
{
|
||||
var filter = AudioVocalStemExtractor.BuildFfmpegStemFilter(AudioStemMode.PodcastBroadcastSpeech);
|
||||
|
||||
Assert.Contains("highpass=f=80", filter);
|
||||
Assert.Contains("afftdn=nf=-20", filter);
|
||||
}
|
||||
}
|
||||
88
tests/Paca.Tests/Core/AudioVolumeNormalizerGateTests.cs
Normal file
88
tests/Paca.Tests/Core/AudioVolumeNormalizerGateTests.cs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
using System;
|
||||
using Paca.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class AudioVolumeNormalizerGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate301_AudioVolumeNormalizer_ProvidesStandardTargetPresets()
|
||||
{
|
||||
var streaming = AudioVolumeNormalizer.PodcastStreamingPreset;
|
||||
Assert.Equal(-16.0, streaming.TargetI);
|
||||
Assert.Equal(-1.0, streaming.TargetTp);
|
||||
Assert.Equal(11.0, streaming.TargetLra);
|
||||
|
||||
var ebu = AudioVolumeNormalizer.StandardEbuR128Preset;
|
||||
Assert.Equal(-23.0, ebu.TargetI);
|
||||
Assert.Equal(-1.0, ebu.TargetTp);
|
||||
Assert.Equal(7.0, ebu.TargetLra);
|
||||
|
||||
var night = AudioVolumeNormalizer.NightModeCompressedPreset;
|
||||
Assert.Equal(-20.0, night.TargetI);
|
||||
Assert.Equal(-2.0, night.TargetTp);
|
||||
Assert.Equal(5.0, night.TargetLra);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate302_AudioVolumeNormalizer_BuildsFilterArgs_Accurately()
|
||||
{
|
||||
var preset = AudioVolumeNormalizer.PodcastStreamingPreset;
|
||||
var filter = AudioVolumeNormalizer.BuildLoudnormFilterArgs(preset);
|
||||
|
||||
Assert.Equal("loudnorm=I=-16.0:TP=-1.0:LRA=11.0", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate303_AudioVolumeNormalizer_BuildsCommandLine_WithVideoPassthrough()
|
||||
{
|
||||
var preset = AudioVolumeNormalizer.PodcastStreamingPreset;
|
||||
var cmd = AudioVolumeNormalizer.BuildNormalizeCommandLine(
|
||||
inputPath: "C:\\videos\\clip.mp4",
|
||||
outputPath: "C:\\videos\\clip_normalized.mp4",
|
||||
preset: preset,
|
||||
copyVideo: true
|
||||
);
|
||||
|
||||
Assert.Contains("-i \"C:\\videos\\clip.mp4\"", cmd);
|
||||
Assert.Contains("-c:v copy", cmd);
|
||||
Assert.Contains("-filter:a \"loudnorm=I=-16.0:TP=-1.0:LRA=11.0\"", cmd);
|
||||
Assert.Contains("-c:a aac -b:a 192k", cmd);
|
||||
Assert.EndsWith("\"C:\\videos\\clip_normalized.mp4\" -y", cmd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate304_AudioVolumeNormalizer_ParsesFirstPassStats_AndBuildsSecondPassFilter()
|
||||
{
|
||||
var ffmpegFirstPassOutput = @"
|
||||
[Parsed_loudnorm_0 @ 0000021c1c68f140]
|
||||
{
|
||||
""input_i"" : ""-28.52"",
|
||||
""input_tp"" : ""-4.12"",
|
||||
""input_lra"" : ""9.30"",
|
||||
""input_thresh"" : ""-39.10"",
|
||||
""output_i"" : ""-16.12"",
|
||||
""output_tp"" : ""-1.00"",
|
||||
""output_lra"" : ""7.80"",
|
||||
""output_thresh"" : ""-26.65"",
|
||||
""normalization_type"" : ""dynamic"",
|
||||
""target_offset"" : ""0.12""
|
||||
}
|
||||
";
|
||||
|
||||
var stats = AudioVolumeNormalizer.ParseLoudnormFirstPassStats(ffmpegFirstPassOutput);
|
||||
|
||||
Assert.NotNull(stats);
|
||||
Assert.Equal(-28.52, stats!.InputI);
|
||||
Assert.Equal(-4.12, stats.InputTp);
|
||||
Assert.Equal(9.30, stats.InputLra);
|
||||
Assert.Equal(-39.10, stats.InputThresh);
|
||||
Assert.Equal(0.12, stats.TargetOffset);
|
||||
|
||||
var preset = AudioVolumeNormalizer.PodcastStreamingPreset;
|
||||
var secondPassFilter = AudioVolumeNormalizer.BuildSecondPassFilterArgs(preset, stats);
|
||||
|
||||
Assert.Equal("loudnorm=I=-16.0:TP=-1.0:LRA=11.0:measured_I=-28.52:measured_TP=-4.12:measured_LRA=9.30:measured_thresh=-39.10:offset=0.12:linear=true", secondPassFilter);
|
||||
}
|
||||
}
|
||||
52
tests/Paca.Tests/Core/BandwidthRateLimiterGateTests.cs
Normal file
52
tests/Paca.Tests/Core/BandwidthRateLimiterGateTests.cs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
using System;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class BandwidthRateLimiterGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate277_BandwidthRateLimiter_UnlimitedPreset_AllowsFullSpeed()
|
||||
{
|
||||
var limiter = new BandwidthRateLimiter(BandwidthLimitPreset.Unlimited);
|
||||
|
||||
var delay = limiter.ComputeThrottleDelayMs(chunkBytes: 10_000_000, elapsedMs: 10);
|
||||
Assert.Equal(0, delay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate278_BandwidthRateLimiter_TokenBucket_EnforcesByteCap()
|
||||
{
|
||||
// 1 MB/s = 1,000,000 bytes/sec
|
||||
var limiter = new BandwidthRateLimiter(bytesPerSecond: 1_000_000);
|
||||
|
||||
// Downloaded 1MB in 200ms -> should throttle ~800ms
|
||||
var delay = limiter.ComputeThrottleDelayMs(chunkBytes: 1_000_000, elapsedMs: 200);
|
||||
Assert.InRange(delay, 750, 850);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate279_BandwidthRateLimiter_Presets_FormatLabelsCleanly()
|
||||
{
|
||||
Assert.Equal("무제한", BandwidthRateLimiter.GetPresetLabel(BandwidthLimitPreset.Unlimited));
|
||||
Assert.Equal("10 MB/s (고속)", BandwidthRateLimiter.GetPresetLabel(BandwidthLimitPreset.Fast10MB));
|
||||
Assert.Equal("5 MB/s (일반)", BandwidthRateLimiter.GetPresetLabel(BandwidthLimitPreset.Medium5MB));
|
||||
Assert.Equal("1 MB/s (절약)", BandwidthRateLimiter.GetPresetLabel(BandwidthLimitPreset.Low1MB));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate280_BandwidthRateLimiter_DynamicRateChange_TakesEffectImmediately()
|
||||
{
|
||||
var limiter = new BandwidthRateLimiter(bytesPerSecond: 1_000_000);
|
||||
Assert.Equal(1_000_000, limiter.BytesPerSecond);
|
||||
|
||||
// Update mid-stream
|
||||
limiter.SetRate(5_000_000);
|
||||
Assert.Equal(5_000_000, limiter.BytesPerSecond);
|
||||
|
||||
// Now 1MB at 5MB/s expects 200ms total time, elapsed 100ms -> delay ~100ms
|
||||
var delay = limiter.ComputeThrottleDelayMs(chunkBytes: 1_000_000, elapsedMs: 100);
|
||||
Assert.InRange(delay, 80, 120);
|
||||
}
|
||||
}
|
||||
91
tests/Paca.Tests/Core/BandwidthScheduleProfileGateTests.cs
Normal file
91
tests/Paca.Tests/Core/BandwidthScheduleProfileGateTests.cs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Paca.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class BandwidthScheduleProfileGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate313_BandwidthScheduleProfile_DefinesWorkAndNightProfiles()
|
||||
{
|
||||
var workProfile = new BandwidthProfile("WorkHours", MaxBytesPerSecond: 5 * 1024 * 1024, MaxConcurrentTasks: 2);
|
||||
var nightProfile = new BandwidthProfile("NightUncapped", MaxBytesPerSecond: 0, MaxConcurrentTasks: 8);
|
||||
|
||||
Assert.Equal(5 * 1024 * 1024, workProfile.MaxBytesPerSecond);
|
||||
Assert.Equal(2, workProfile.MaxConcurrentTasks);
|
||||
Assert.True(nightProfile.IsUnlimited);
|
||||
Assert.Equal(8, nightProfile.MaxConcurrentTasks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate314_BandwidthScheduleProfile_SelectsWorkProfile_DuringDaytime()
|
||||
{
|
||||
var workProfile = new BandwidthProfile("WorkHours", 5 * 1024 * 1024, 2);
|
||||
var defaultProfile = new BandwidthProfile("Default", 0, 4);
|
||||
|
||||
var config = new BandwidthScheduleConfig
|
||||
{
|
||||
IsEnabled = true,
|
||||
DefaultProfile = defaultProfile,
|
||||
Rules = new List<BandwidthTimeRule>
|
||||
{
|
||||
new(StartTime: TimeSpan.FromHours(9), EndTime: TimeSpan.FromHours(18), Profile: workProfile)
|
||||
}
|
||||
};
|
||||
|
||||
var dayTime = new DateTime(2026, 9, 4, 14, 30, 0); // 14:30
|
||||
var active = BandwidthScheduleProfileEngine.GetActiveProfile(dayTime, config);
|
||||
|
||||
Assert.Equal("WorkHours", active.ProfileName);
|
||||
Assert.Equal(5 * 1024 * 1024, active.MaxBytesPerSecond);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate315_BandwidthScheduleProfile_SelectsNightProfile_Overnight()
|
||||
{
|
||||
var nightProfile = new BandwidthProfile("NightUncapped", 0, 8);
|
||||
var defaultProfile = new BandwidthProfile("Default", 10 * 1024 * 1024, 3);
|
||||
|
||||
var config = new BandwidthScheduleConfig
|
||||
{
|
||||
IsEnabled = true,
|
||||
DefaultProfile = defaultProfile,
|
||||
Rules = new List<BandwidthTimeRule>
|
||||
{
|
||||
new(StartTime: TimeSpan.FromHours(23), EndTime: TimeSpan.FromHours(7), Profile: nightProfile)
|
||||
}
|
||||
};
|
||||
|
||||
var overnight = new DateTime(2026, 9, 5, 3, 15, 0); // 03:15
|
||||
var active = BandwidthScheduleProfileEngine.GetActiveProfile(overnight, config);
|
||||
|
||||
Assert.Equal("NightUncapped", active.ProfileName);
|
||||
Assert.True(active.IsUnlimited);
|
||||
Assert.Equal(8, active.MaxConcurrentTasks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate316_BandwidthScheduleProfile_FallsBackToDefault_WhenDisabledOrOutOfRange()
|
||||
{
|
||||
var workProfile = new BandwidthProfile("WorkHours", 2 * 1024 * 1024, 1);
|
||||
var defaultProfile = new BandwidthProfile("Default", 0, 4);
|
||||
|
||||
var config = new BandwidthScheduleConfig
|
||||
{
|
||||
IsEnabled = false, // Disabled
|
||||
DefaultProfile = defaultProfile,
|
||||
Rules = new List<BandwidthTimeRule>
|
||||
{
|
||||
new(StartTime: TimeSpan.FromHours(9), EndTime: TimeSpan.FromHours(18), Profile: workProfile)
|
||||
}
|
||||
};
|
||||
|
||||
var dayTime = new DateTime(2026, 9, 4, 14, 0, 0);
|
||||
var active = BandwidthScheduleProfileEngine.GetActiveProfile(dayTime, config);
|
||||
|
||||
Assert.Equal("Default", active.ProfileName);
|
||||
Assert.True(active.IsUnlimited);
|
||||
}
|
||||
}
|
||||
64
tests/Paca.Tests/Core/BatteryPowerGovernorGateTests.cs
Normal file
64
tests/Paca.Tests/Core/BatteryPowerGovernorGateTests.cs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
using System;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class BatteryPowerGovernorGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate249_BatteryPowerGovernor_DetectsLowBattery_AndEntersLowPowerMode()
|
||||
{
|
||||
var governor = new BatteryPowerGovernor();
|
||||
|
||||
// 15% battery, discharging
|
||||
governor.UpdateBatteryState(batteryLevelPercent: 15, isCharging: false);
|
||||
|
||||
Assert.True(governor.IsLowPowerModeActive);
|
||||
Assert.Equal(1, governor.MaxRecommendedDownloads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate250_BatteryPowerGovernor_AcPowerConnected_RestoresHighPerformance()
|
||||
{
|
||||
var governor = new BatteryPowerGovernor();
|
||||
|
||||
// 15% battery, but charging on AC
|
||||
governor.UpdateBatteryState(batteryLevelPercent: 15, isCharging: true);
|
||||
|
||||
Assert.False(governor.IsLowPowerModeActive);
|
||||
Assert.True(governor.MaxRecommendedDownloads >= 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate251_BatteryPowerGovernor_ConfigurableThreshold()
|
||||
{
|
||||
var governor = new BatteryPowerGovernor(lowBatteryThresholdPercent: 30);
|
||||
|
||||
// 25% battery with 30% threshold -> low power active
|
||||
governor.UpdateBatteryState(batteryLevelPercent: 25, isCharging: false);
|
||||
Assert.True(governor.IsLowPowerModeActive);
|
||||
|
||||
// 35% battery with 30% threshold -> normal
|
||||
governor.UpdateBatteryState(batteryLevelPercent: 35, isCharging: false);
|
||||
Assert.False(governor.IsLowPowerModeActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate252_BatteryPowerGovernor_AppliesPlayerRenderPolicy()
|
||||
{
|
||||
var governor = new BatteryPowerGovernor();
|
||||
|
||||
// Low power
|
||||
governor.UpdateBatteryState(batteryLevelPercent: 10, isCharging: false);
|
||||
var lowPolicy = governor.GetRenderPolicy();
|
||||
Assert.False(lowPolicy.EnableGlassmorphismBlur);
|
||||
Assert.Equal(30, lowPolicy.TargetFramerate);
|
||||
|
||||
// High power
|
||||
governor.UpdateBatteryState(batteryLevelPercent: 90, isCharging: false);
|
||||
var highPolicy = governor.GetRenderPolicy();
|
||||
Assert.True(highPolicy.EnableGlassmorphismBlur);
|
||||
Assert.Equal(60, highPolicy.TargetFramerate);
|
||||
}
|
||||
}
|
||||
147
tests/Paca.Tests/Core/BatterySaverPowerManagerTests.cs
Normal file
147
tests/Paca.Tests/Core/BatterySaverPowerManagerTests.cs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
using System;
|
||||
using Paca.Core;
|
||||
using Paca.Core.Platform;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class BatterySaverPowerManagerTests
|
||||
{
|
||||
[Fact]
|
||||
public void 정상_상태_완전한_동시성_유지_및_백그라운드_작업_허용()
|
||||
{
|
||||
var manager = new BatterySaverPowerManager();
|
||||
manager.UpdateBatteryState(percent: 85, isCharging: false);
|
||||
|
||||
Assert.False(manager.IsLowPowerModeActive);
|
||||
Assert.False(manager.ShouldDeferBackgroundProcessing);
|
||||
Assert.Equal(8, manager.CalculateOptimalConcurrency(baseConcurrency: 8));
|
||||
Assert.True(manager.CanExecuteCpuIntensiveTask());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 충전_중일_때는_배터리_15퍼센트여도_풀파워_유지()
|
||||
{
|
||||
var manager = new BatterySaverPowerManager();
|
||||
manager.UpdateBatteryState(percent: 15, isCharging: true);
|
||||
|
||||
Assert.False(manager.IsLowPowerModeActive);
|
||||
Assert.False(manager.ShouldDeferBackgroundProcessing);
|
||||
Assert.Equal(8, manager.CalculateOptimalConcurrency(baseConcurrency: 8));
|
||||
Assert.True(manager.CanExecuteCpuIntensiveTask());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 배터리_20퍼센트_이하_방전_중_동시성_감축_및_백그라운드_유예()
|
||||
{
|
||||
var manager = new BatterySaverPowerManager(lowBatteryThresholdPercent: 20);
|
||||
manager.UpdateBatteryState(percent: 18, isCharging: false);
|
||||
|
||||
Assert.True(manager.IsLowPowerModeActive);
|
||||
Assert.True(manager.ShouldDeferBackgroundProcessing);
|
||||
// 8개의 병렬 다운로드 -> 2개로 자동 감축 (전력 및 RF 소모 75% 절감)
|
||||
Assert.Equal(2, manager.CalculateOptimalConcurrency(baseConcurrency: 8));
|
||||
Assert.False(manager.CanExecuteCpuIntensiveTask());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 배터리_10퍼센트_이하_크리티컬_단일_스트림_최소_전력_모드()
|
||||
{
|
||||
var manager = new BatterySaverPowerManager();
|
||||
manager.UpdateBatteryState(percent: 8, isCharging: false);
|
||||
|
||||
Assert.True(manager.IsLowPowerModeActive);
|
||||
// 크리티컬 상황에서는 최소 1개 스트림으로 다운로드 중단 방지
|
||||
Assert.Equal(1, manager.CalculateOptimalConcurrency(baseConcurrency: 8));
|
||||
Assert.True(manager.ShouldDeferBackgroundProcessing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OS_절전_모드_활성화시_배터리_잔량_무관_절전_진입()
|
||||
{
|
||||
var manager = new BatterySaverPowerManager();
|
||||
manager.UpdateBatteryState(percent: 60, isCharging: false, isOsPowerSaverEnabled: true);
|
||||
|
||||
Assert.True(manager.IsLowPowerModeActive);
|
||||
Assert.True(manager.ShouldDeferBackgroundProcessing);
|
||||
Assert.Equal(2, manager.CalculateOptimalConcurrency(baseConcurrency: 8));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 충전기_연결시_즉시_풀파워_복구_및_이벤트_발행()
|
||||
{
|
||||
var manager = new BatterySaverPowerManager();
|
||||
manager.UpdateBatteryState(percent: 12, isCharging: false);
|
||||
Assert.True(manager.IsLowPowerModeActive);
|
||||
|
||||
bool eventFired = false;
|
||||
PowerStateChangedEventArgs? capturedArgs = null;
|
||||
manager.PowerStateChanged += (s, e) =>
|
||||
{
|
||||
eventFired = true;
|
||||
capturedArgs = e;
|
||||
};
|
||||
|
||||
// 충전기 연결
|
||||
manager.UpdateBatteryState(percent: 12, isCharging: true);
|
||||
|
||||
Assert.True(eventFired);
|
||||
Assert.NotNull(capturedArgs);
|
||||
Assert.False(capturedArgs!.IsLowPowerModeActive);
|
||||
Assert.False(manager.IsLowPowerModeActive);
|
||||
Assert.Equal(8, manager.CalculateOptimalConcurrency(baseConcurrency: 8));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 절전_모드_대역폭_권장_제한치_계산_검증()
|
||||
{
|
||||
var manager = new BatterySaverPowerManager();
|
||||
|
||||
// 정상 상태에서는 설정된 대역폭 유지
|
||||
manager.UpdateBatteryState(percent: 90, isCharging: false);
|
||||
Assert.Equal(10000, manager.CalculateBandwidthLimitKbps(baseLimitKbps: 10000));
|
||||
Assert.Null(manager.CalculateBandwidthLimitKbps(baseLimitKbps: null));
|
||||
|
||||
// 절전 모드에서는 대역폭 상한 제안 (무제한일 경우 2000 Kbps로 쓰로틀링)
|
||||
manager.UpdateBatteryState(percent: 15, isCharging: false);
|
||||
Assert.Equal(2000, manager.CalculateBandwidthLimitKbps(baseLimitKbps: null));
|
||||
// 기존 제한이 더 크면 2000으로 강제
|
||||
Assert.Equal(2000, manager.CalculateBandwidthLimitKbps(baseLimitKbps: 8000));
|
||||
// 기존 제한이 이미 2000보다 작으면 기존 것 존중
|
||||
Assert.Equal(1000, manager.CalculateBandwidthLimitKbps(baseLimitKbps: 1000));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HlsDownloader_배터리_절전모드_연동_동시성_감축_검증()
|
||||
{
|
||||
var powerManager = new BatterySaverPowerManager();
|
||||
var config = new Paca.Core.Config.AppConfig { Concurrency = 8 };
|
||||
var http = new System.Net.Http.HttpClient();
|
||||
var downloader = new Paca.Core.Hls.HlsDownloader(http, config, Paca.Core.Platform.DesktopMediaMuxer.Default, powerManager);
|
||||
|
||||
// 정상 상태: 8
|
||||
powerManager.UpdateBatteryState(percent: 90, isCharging: false);
|
||||
Assert.Equal(8, downloader.GetEffectiveConcurrency());
|
||||
|
||||
// 배터리 15% 방전 중: 2로 감축
|
||||
powerManager.UpdateBatteryState(percent: 15, isCharging: false);
|
||||
Assert.Equal(2, downloader.GetEffectiveConcurrency());
|
||||
|
||||
// 충전기 연결: 8로 즉시 복귀
|
||||
powerManager.UpdateBatteryState(percent: 15, isCharging: true);
|
||||
Assert.Equal(8, downloader.GetEffectiveConcurrency());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VideoPosterExtractor_배터리_절전모드시_썸네일_추출_유예_판정_검증()
|
||||
{
|
||||
var powerManager = new BatterySaverPowerManager();
|
||||
powerManager.UpdateBatteryState(percent: 15, isCharging: false);
|
||||
|
||||
Assert.True(VideoPosterExtractor.ShouldDeferExtraction(powerManager));
|
||||
|
||||
powerManager.UpdateBatteryState(percent: 15, isCharging: true);
|
||||
Assert.False(VideoPosterExtractor.ShouldDeferExtraction(powerManager));
|
||||
Assert.False(VideoPosterExtractor.ShouldDeferExtraction(null));
|
||||
}
|
||||
}
|
||||
64
tests/Paca.Tests/Core/BookmarkChapterExporterGateTests.cs
Normal file
64
tests/Paca.Tests/Core/BookmarkChapterExporterGateTests.cs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class BookmarkChapterExporterGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate405_BookmarkChapterExporter_FormatsYoutubeTimestamps_Correctly()
|
||||
{
|
||||
// 45 seconds -> 00:45
|
||||
Assert.Equal("00:45", BookmarkChapterExporter.FormatYoutubeTimestamp(45.0));
|
||||
|
||||
// 12 minutes 30 seconds -> 12:30
|
||||
Assert.Equal("12:30", BookmarkChapterExporter.FormatYoutubeTimestamp(750.0));
|
||||
|
||||
// 1 hour 15 minutes 4 seconds -> 01:15:04
|
||||
Assert.Equal("01:15:04", BookmarkChapterExporter.FormatYoutubeTimestamp(4504.0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate406_BookmarkChapterExporter_ExportsYoutubeTimecodes_WithZeroStart()
|
||||
{
|
||||
var doc = new MediaBookmarkDocument("vid-1", "lecture.mp4");
|
||||
doc.Bookmarks.Add(new MediaBookmark("1", 150.0, "Introduction", null, "#FFF", DateTime.UtcNow));
|
||||
doc.Bookmarks.Add(new MediaBookmark("2", 600.0, "Main Content", null, "#FFF", DateTime.UtcNow));
|
||||
|
||||
var text = BookmarkChapterExporter.ExportYoutubeTimecodes(doc);
|
||||
|
||||
Assert.Contains("00:00 - 시작", text);
|
||||
Assert.Contains("02:30 - Introduction", text);
|
||||
Assert.Contains("10:00 - Main Content", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate407_BookmarkChapterExporter_ExportsWebVttChapters_Accurately()
|
||||
{
|
||||
var doc = new MediaBookmarkDocument("vid-1", "podcast.mp4");
|
||||
doc.Bookmarks.Add(new MediaBookmark("1", 0.0, "Intro", null, "#FFF", DateTime.UtcNow));
|
||||
doc.Bookmarks.Add(new MediaBookmark("2", 120.0, "Discussion", null, "#FFF", DateTime.UtcNow));
|
||||
|
||||
var vtt = BookmarkChapterExporter.ExportWebVttChapters(doc, totalDurationSec: 300.0);
|
||||
|
||||
Assert.StartsWith("WEBVTT - Chapters", vtt.TrimStart());
|
||||
Assert.Contains("00:00:00.000 --> 00:02:00.000", vtt);
|
||||
Assert.Contains("Intro", vtt);
|
||||
Assert.Contains("00:02:00.000 --> 00:05:00.000", vtt);
|
||||
Assert.Contains("Discussion", vtt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate408_BookmarkChapterExporter_HandlesEmptyDocumentGracefully()
|
||||
{
|
||||
var emptyDoc = new MediaBookmarkDocument("vid-0", "empty.mp4");
|
||||
|
||||
var yt = BookmarkChapterExporter.ExportYoutubeTimecodes(emptyDoc);
|
||||
Assert.Equal("00:00 - 시작", yt.Trim());
|
||||
|
||||
var vtt = BookmarkChapterExporter.ExportWebVttChapters(emptyDoc, totalDurationSec: 60.0);
|
||||
Assert.Contains("00:00:00.000 --> 00:01:00.000", vtt);
|
||||
}
|
||||
}
|
||||
33
tests/Paca.Tests/Core/ChromeMemorySaverPolicyTests.cs
Normal file
33
tests/Paca.Tests/Core/ChromeMemorySaverPolicyTests.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
using System.Collections.Generic;
|
||||
using Paca.Browser.Tabs;
|
||||
using Paca.Core.Config;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class ChromeMemorySaverPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void CanDiscardTab_Protects_Excluded_Domains()
|
||||
{
|
||||
var mgr = new TabManager(() => new TabItem());
|
||||
var activeTab = mgr.NewTab("https://news.ycombinator.com");
|
||||
var workTab = mgr.NewTab("https://mail.google.com/mail/u/0", activate: false);
|
||||
var slackTab = mgr.NewTab("https://app.slack.com/client", activate: false);
|
||||
var normalTab = mgr.NewTab("https://wikipedia.org/wiki/Paca", activate: false);
|
||||
|
||||
var exclusions = new List<string> { "mail.google.com", "slack.com" };
|
||||
|
||||
Assert.False(mgr.CanDiscardTab(workTab, exclusions));
|
||||
Assert.False(mgr.CanDiscardTab(slackTab, exclusions));
|
||||
Assert.True(mgr.CanDiscardTab(normalTab, exclusions));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppConfig_Contains_MemorySaver_Properties_With_Safe_Defaults()
|
||||
{
|
||||
var config = new AppConfig();
|
||||
Assert.True(config.MemorySaverEnabled);
|
||||
Assert.NotNull(config.MemorySaverExclusions);
|
||||
}
|
||||
}
|
||||
224
tests/Paca.Tests/Core/CoreAndServerResilienceGateTests.cs
Normal file
224
tests/Paca.Tests/Core/CoreAndServerResilienceGateTests.cs
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Paca.Core.Config;
|
||||
using Paca.Core.Hls;
|
||||
using Paca.Core.Models;
|
||||
using Paca.Core.Platform;
|
||||
using Paca.Mobile.Core;
|
||||
using Paca.Server;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class CoreAndServerResilienceGateTests
|
||||
{
|
||||
// =========================================================================
|
||||
// Gate 1: Paca.Server 로컬 API 보안 - 비웹 스킴(file, javascript 등) 주입 차단
|
||||
// =========================================================================
|
||||
[Theory]
|
||||
[InlineData("javascript:alert(1)")]
|
||||
[InlineData("file:///C:/Windows/System32/calc.exe")]
|
||||
[InlineData("data:text/html,<h1>PWNED</h1>")]
|
||||
[InlineData("gopher://evil.com/payload")]
|
||||
public async Task Gate_Server_Rejects_Non_Web_Schemes_In_Tab_Open(string illegalUrl)
|
||||
{
|
||||
var options = new ServerOptions
|
||||
{
|
||||
Port = 0,
|
||||
Token = "test-token-1234567890",
|
||||
Queue = new FakeQueueAdapter(),
|
||||
TabOpener = _ => { }
|
||||
};
|
||||
|
||||
await using var server = await ServerHost.StartAsync(options);
|
||||
using var client = new HttpClient();
|
||||
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", options.Token);
|
||||
|
||||
var payload = new Dictionary<string, string> { ["url"] = illegalUrl };
|
||||
var resp = await client.PostAsJsonAsync($"http://127.0.0.1:{server.Port}/api/tabs/open", payload);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("file:///C:/secrets.txt")]
|
||||
[InlineData("javascript:void(0)")]
|
||||
[InlineData("ftp://ftp.example.com/test")]
|
||||
public async Task Gate_Server_Rejects_Non_Web_Schemes_In_Queue(string illegalUrl)
|
||||
{
|
||||
var options = new ServerOptions
|
||||
{
|
||||
Port = 0,
|
||||
Token = "test-token-1234567890",
|
||||
Queue = new FakeQueueAdapter()
|
||||
};
|
||||
|
||||
await using var server = await ServerHost.StartAsync(options);
|
||||
using var client = new HttpClient();
|
||||
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", options.Token);
|
||||
|
||||
var payload = new { url = illegalUrl, title = "Illegal" };
|
||||
var resp = await client.PostAsJsonAsync($"http://127.0.0.1:{server.Port}/api/queue", payload);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Gate 2: Paca.Server TabProvider 동시 컬렉션 수정 안전성
|
||||
// =========================================================================
|
||||
[Fact]
|
||||
public async Task Gate_Server_TabProvider_Thread_Safety_Against_Concurrent_Modification()
|
||||
{
|
||||
var tabList = new List<RemoteTabInfo>
|
||||
{
|
||||
new("tab-1", "Tab 1", "https://example.com/1", false, true)
|
||||
};
|
||||
|
||||
var options = new ServerOptions
|
||||
{
|
||||
Port = 0,
|
||||
Token = "test-token-1234567890",
|
||||
Queue = new FakeQueueAdapter(),
|
||||
TabProvider = () =>
|
||||
{
|
||||
// 열거 시점에 다른 스레드가 리스트를 변경하는 레이스 컨디션 시뮬레이션
|
||||
tabList.Add(new RemoteTabInfo($"tab-{Guid.NewGuid()}", "New Tab", "https://example.com", false, false));
|
||||
return tabList;
|
||||
}
|
||||
};
|
||||
|
||||
await using var server = await ServerHost.StartAsync(options);
|
||||
using var client = new HttpClient();
|
||||
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", options.Token);
|
||||
|
||||
// 동시 변경이 발생해도 500 InternalServerError (Collection was modified) 없이 200 OK 응답해야 함
|
||||
var resp = await client.GetAsync($"http://127.0.0.1:{server.Port}/api/tabs");
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Gate 3: HlsDownloader 영구 4xx(404, 403) 오류 시 즉각 탈출 검증 (불필요한 재시도 방어)
|
||||
// =========================================================================
|
||||
[Fact]
|
||||
public async Task Gate_HlsDownloader_Aborts_Immediately_On_Permanent_4xx_Error()
|
||||
{
|
||||
int requestCount = 0;
|
||||
var handler = new MockHttpHandler(req =>
|
||||
{
|
||||
Interlocked.Increment(ref requestCount);
|
||||
return new HttpResponseMessage(HttpStatusCode.NotFound);
|
||||
});
|
||||
|
||||
using var http = new HttpClient(handler);
|
||||
var config = new AppConfig { Retries = 5 }; // 설정은 5회 재시도이지만 404면 즉시 중단해야 함
|
||||
var downloader = new HlsDownloader(http, config, new FakeMediaMuxer());
|
||||
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "paca_test_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
try
|
||||
{
|
||||
await Assert.ThrowsAnyAsync<Exception>(async () =>
|
||||
{
|
||||
await downloader.DownloadAsync(new Uri("https://example.com/missing.m3u8"), Path.Combine(tempDir, "out"));
|
||||
});
|
||||
|
||||
// 404 Not Found는 5회까지 재시도하지 않고 1회만에 즉각 중단되어야 함
|
||||
Assert.Equal(1, requestCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Gate 4: HlsDownloader 0바이트 또는 트렁케이션 청크 감지 및 재시도
|
||||
// =========================================================================
|
||||
[Fact]
|
||||
public async Task Gate_HlsDownloader_Rejects_ZeroByte_And_Truncated_Segments()
|
||||
{
|
||||
int segmentAttempts = 0;
|
||||
var m3u8 = "#EXTM3U\n#EXT-X-TARGETDURATION:10\n#EXTINF:10.0,\nseg1.ts\n#EXT-X-ENDLIST";
|
||||
|
||||
var handler = new MockHttpHandler(req =>
|
||||
{
|
||||
if (req.RequestUri!.AbsoluteUri.EndsWith(".m3u8"))
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(m3u8) };
|
||||
}
|
||||
|
||||
// seg1.ts 첫 시도는 0바이트 응답, 2번째 시도는 정상 100바이트 응답
|
||||
var attempt = Interlocked.Increment(ref segmentAttempts);
|
||||
if (attempt == 1)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ByteArrayContent(Array.Empty<byte>())
|
||||
};
|
||||
}
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ByteArrayContent(new byte[100])
|
||||
};
|
||||
});
|
||||
|
||||
using var http = new HttpClient(handler);
|
||||
var config = new AppConfig { Retries = 3 };
|
||||
var downloader = new HlsDownloader(http, config, new FakeMediaMuxer());
|
||||
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "paca_test_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
try
|
||||
{
|
||||
// 첫 시도가 0바이트이므로 실패 처리 후 2번째 시도에서 복구되어야 함
|
||||
var result = await downloader.DownloadAsync(new Uri("https://example.com/test.m3u8"), Path.Combine(tempDir, "out"));
|
||||
Assert.True(File.Exists(result));
|
||||
Assert.True(segmentAttempts >= 2, "0바이트 청크를 거르고 최소 2회 이상 시도해야 합니다.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helper Mock ──
|
||||
private sealed class MockHttpHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
|
||||
public MockHttpHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) => _responder = responder;
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(_responder(request));
|
||||
}
|
||||
|
||||
private sealed class FakeMediaMuxer : IMediaMuxer
|
||||
{
|
||||
public Task RemuxAsync(string ffmpeg, string input, string output, CancellationToken ct)
|
||||
{
|
||||
File.Copy(input, output, overwrite: true);
|
||||
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) => "fake-ffmpeg";
|
||||
}
|
||||
|
||||
private sealed class FakeQueueAdapter : IQueueAdapter
|
||||
{
|
||||
#pragma warning disable CS0067
|
||||
public event Action? Changed;
|
||||
#pragma warning restore CS0067
|
||||
public Task<string> AddAsync(string url, string? title = null) => Task.FromResult(Guid.NewGuid().ToString("N"));
|
||||
public Task<IReadOnlyList<QueueItemDto>> GetItemsAsync() => Task.FromResult<IReadOnlyList<QueueItemDto>>(Array.Empty<QueueItemDto>());
|
||||
public Task PauseAsync(string id) => Task.CompletedTask;
|
||||
public Task ResumeAsync(string id) => Task.CompletedTask;
|
||||
public Task CancelAsync(string id) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Paca.Core.Config;
|
||||
using Paca.Core.Hls;
|
||||
using Paca.Core.Models;
|
||||
using Paca.Core.Platform;
|
||||
using Paca.Core.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6: 코어 스트리밍 매니페스트 복원력 및 보안 볼트 키 영속화 헌법 게이트 (Gate 601 ~ 603).
|
||||
/// - Gate 601: HlsDownloader 재생목록(M3U8) 매니페스트 취득 시 일시적 네트워크 장애(5xx/타임아웃) 자동 재시도
|
||||
/// - Gate 602: OsProtectedCredentialVault 하위 디렉터리 자동 생성 및 마스터 키 영속화 안전성
|
||||
/// - Gate 603: M3u8Parser 손상된 HTML 에러 페이지 및 빈 스트림 무결성 방어
|
||||
/// </summary>
|
||||
public class CoreStreamingAndVaultResiliencePhase6Tests
|
||||
{
|
||||
private sealed class StubMuxer : IMediaMuxer
|
||||
{
|
||||
public Task RemuxAsync(string ffmpeg, string input, string output, CancellationToken ct)
|
||||
{
|
||||
File.Copy(input, output, overwrite: true);
|
||||
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) => "mock_ffmpeg";
|
||||
}
|
||||
|
||||
private sealed class RetryCountingHandler : HttpMessageHandler
|
||||
{
|
||||
public int PlaylistRequests = 0;
|
||||
private readonly string _validM3u8;
|
||||
|
||||
public RetryCountingHandler(string validM3u8)
|
||||
{
|
||||
_validM3u8 = validM3u8;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = request.RequestUri?.AbsolutePath ?? "";
|
||||
if (path.EndsWith(".m3u8"))
|
||||
{
|
||||
PlaylistRequests++;
|
||||
if (PlaylistRequests == 1)
|
||||
{
|
||||
// 1회차 요청은 503 일시 장애 시뮬레이션
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.ServiceUnavailable));
|
||||
}
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(_validM3u8, Encoding.UTF8, "application/vnd.apple.mpegurl")
|
||||
});
|
||||
}
|
||||
|
||||
if (path.EndsWith(".ts"))
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ByteArrayContent(new byte[128])
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gate601_HlsDownloader_Retries_Playlist_Manifest_On_Transient_Faults_Audit()
|
||||
{
|
||||
var validM3u8 = "#EXTM3U\n#EXT-X-TARGETDURATION:2\n#EXTINF:2.0,\nseg0.ts\n#EXT-X-ENDLIST\n";
|
||||
var handler = new RetryCountingHandler(validM3u8);
|
||||
using var http = new HttpClient(handler);
|
||||
var config = new AppConfig { Retries = 3 };
|
||||
var downloader = new HlsDownloader(http, config, new StubMuxer());
|
||||
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "paca_gate601_" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var outputBase = Path.Combine(tempDir, "video");
|
||||
var result = await downloader.DownloadAsync(new Uri("http://mock-server.local/stream.m3u8"), outputBase);
|
||||
|
||||
Assert.True(File.Exists(result));
|
||||
Assert.True(handler.PlaylistRequests >= 2, $"Expected at least 2 requests (1 failure + 1 retry), actual: {handler.PlaylistRequests}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(tempDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gate602_OsProtectedCredentialVault_Ensures_Directory_Created_For_KeyFile_Audit()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "paca_gate602_" + Guid.NewGuid().ToString("N"));
|
||||
var nestedDir = Path.Combine(tempDir, "deeply", "nested", "secure", "vaults");
|
||||
var vaultPath = Path.Combine(nestedDir, "test.pacavault");
|
||||
var keyPath = vaultPath + ".key";
|
||||
|
||||
try
|
||||
{
|
||||
Assert.False(Directory.Exists(nestedDir));
|
||||
|
||||
var mockAuth = new MockBiometricAuthenticator();
|
||||
using (var vault = new OsProtectedCredentialVault(vaultPath, mockAuth))
|
||||
{
|
||||
var unlockResult = await vault.UnlockWithBiometricAsync();
|
||||
Assert.True(unlockResult.IsSuccess);
|
||||
|
||||
// 디렉터리와 키 파일이 정상적으로 생성되었는지 검증
|
||||
Assert.True(Directory.Exists(nestedDir), "Vault directory should be auto-created");
|
||||
Assert.True(File.Exists(keyPath), "Master key file should be created");
|
||||
Assert.True(new FileInfo(keyPath).Length > 0, "Key file should not be empty");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(tempDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate603_M3u8Parser_Corrupted_And_Empty_Manifest_Safety_Audit()
|
||||
{
|
||||
var baseUri = new Uri("http://example.com/live/playlist.m3u8");
|
||||
|
||||
// 1. 빈 문자열 및 공백
|
||||
var emptyResult = M3u8Parser.Parse("", baseUri);
|
||||
Assert.NotNull(emptyResult);
|
||||
Assert.Equal(PlaylistType.Media, emptyResult.Type);
|
||||
Assert.Empty(emptyResult.Variants);
|
||||
Assert.Empty(emptyResult.Segments);
|
||||
|
||||
// 2. HTML 403 오류 응답 페이지 주입
|
||||
var htmlError = "<!DOCTYPE html><html><head><title>403 Forbidden</title></head><body><h1>Access Denied</h1></body></html>";
|
||||
var htmlResult = M3u8Parser.Parse(htmlError, baseUri);
|
||||
Assert.NotNull(htmlResult);
|
||||
Assert.Empty(htmlResult.Variants);
|
||||
Assert.Empty(htmlResult.Segments);
|
||||
}
|
||||
}
|
||||
84
tests/Paca.Tests/Core/DataUsageQuotaTrackerGateTests.cs
Normal file
84
tests/Paca.Tests/Core/DataUsageQuotaTrackerGateTests.cs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
using System;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class DataUsageQuotaTrackerGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate333_DataUsageQuotaTracker_AccumulatesWifiAndCellularSeparately()
|
||||
{
|
||||
var tracker = new DataUsageQuotaTracker();
|
||||
|
||||
tracker.RecordUsage(bytes: 500 * 1024 * 1024, isCellular: false); // 500 MB Wifi
|
||||
tracker.RecordUsage(bytes: 300 * 1024 * 1024, isCellular: false); // 300 MB Wifi
|
||||
tracker.RecordUsage(bytes: 200 * 1024 * 1024, isCellular: true); // 200 MB Cellular
|
||||
|
||||
Assert.Equal(800 * 1024 * 1024L, tracker.TotalWifiBytes);
|
||||
Assert.Equal(200 * 1024 * 1024L, tracker.TotalCellularBytes);
|
||||
Assert.Equal(1000 * 1024 * 1024L, tracker.TotalBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate334_DataUsageQuotaTracker_TriggersWarningAt80Percent()
|
||||
{
|
||||
var settings = new QuotaSettings(
|
||||
MonthlyCellularQuotaBytes: 10L * 1024 * 1024 * 1024, // 10 GB
|
||||
WarningThresholdPercent: 80.0,
|
||||
AutoPauseOnQuotaExceeded: true
|
||||
);
|
||||
|
||||
// 8.2 GB consumed (82%)
|
||||
var currentBytes = (long)(8.2 * 1024 * 1024 * 1024);
|
||||
var status = DataUsageQuotaTracker.EvaluateQuota(currentBytes, settings);
|
||||
|
||||
Assert.True(status.IsWarning);
|
||||
Assert.False(status.IsExceeded);
|
||||
Assert.False(status.ShouldPauseDownload);
|
||||
Assert.True(status.UsagePercent >= 80.0 && status.UsagePercent < 100.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate335_DataUsageQuotaTracker_AutoPausesWhen100PercentExceeded()
|
||||
{
|
||||
var settings = new QuotaSettings(
|
||||
MonthlyCellularQuotaBytes: 5L * 1024 * 1024 * 1024, // 5 GB
|
||||
WarningThresholdPercent: 80.0,
|
||||
AutoPauseOnQuotaExceeded: true
|
||||
);
|
||||
|
||||
// 5.1 GB consumed (102%)
|
||||
var currentBytes = (long)(5.1 * 1024 * 1024 * 1024);
|
||||
var status = DataUsageQuotaTracker.EvaluateQuota(currentBytes, settings);
|
||||
|
||||
Assert.True(status.IsWarning);
|
||||
Assert.True(status.IsExceeded);
|
||||
Assert.True(status.ShouldPauseDownload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate336_DataUsageQuotaTracker_PassesGracefully_WhenUnlimitedOrAutoPauseDisabled()
|
||||
{
|
||||
var unlimitedSettings = new QuotaSettings(
|
||||
MonthlyCellularQuotaBytes: 0, // 0 = Unlimited
|
||||
WarningThresholdPercent: 80.0,
|
||||
AutoPauseOnQuotaExceeded: true
|
||||
);
|
||||
|
||||
var statusUnlimited = DataUsageQuotaTracker.EvaluateQuota(100L * 1024 * 1024 * 1024, unlimitedSettings);
|
||||
Assert.False(statusUnlimited.IsWarning);
|
||||
Assert.False(statusUnlimited.IsExceeded);
|
||||
Assert.False(statusUnlimited.ShouldPauseDownload);
|
||||
|
||||
var noAutoPause = new QuotaSettings(
|
||||
MonthlyCellularQuotaBytes: 1L * 1024 * 1024 * 1024,
|
||||
WarningThresholdPercent: 80.0,
|
||||
AutoPauseOnQuotaExceeded: false // Auto pause disabled
|
||||
);
|
||||
|
||||
var statusNoPause = DataUsageQuotaTracker.EvaluateQuota(2L * 1024 * 1024 * 1024, noAutoPause);
|
||||
Assert.True(statusNoPause.IsExceeded);
|
||||
Assert.False(statusNoPause.ShouldPauseDownload); // Allowed because auto pause is disabled
|
||||
}
|
||||
}
|
||||
140
tests/Paca.Tests/Core/DirectDownloaderIntegrityGateTests.cs
Normal file
140
tests/Paca.Tests/Core/DirectDownloaderIntegrityGateTests.cs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Paca.Core.Config;
|
||||
using Paca.Core.Hls;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class DirectDownloaderIntegrityGateTests : IDisposable
|
||||
{
|
||||
private readonly string _testDir;
|
||||
|
||||
public DirectDownloaderIntegrityGateTests()
|
||||
{
|
||||
_testDir = Path.Combine(Path.GetTempPath(), "paca_direct_test_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_testDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { if (Directory.Exists(_testDir)) Directory.Delete(_testDir, recursive: true); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate471_DirectDownloader_GuessExt_DetectsExtensions_FromUrlAndMediaType()
|
||||
{
|
||||
// Direct extension in URL
|
||||
Assert.Equal(".mp4", DirectDownloader.GuessExt("", new Uri("https://paca.stream/video.mp4")));
|
||||
Assert.Equal(".webm", DirectDownloader.GuessExt("", new Uri("https://paca.stream/clip.webm")));
|
||||
Assert.Equal(".mkv", DirectDownloader.GuessExt("", new Uri("https://paca.stream/movie.mkv")));
|
||||
Assert.Equal(".mp3", DirectDownloader.GuessExt("", new Uri("https://paca.stream/audio.mp3")));
|
||||
Assert.Equal(".flac", DirectDownloader.GuessExt("", new Uri("https://paca.stream/track.flac")));
|
||||
|
||||
// Fallback from MIME ContentType
|
||||
Assert.Equal(".mp4", DirectDownloader.GuessExt("video/mp4", new Uri("https://paca.stream/stream?id=123")));
|
||||
Assert.Equal(".webm", DirectDownloader.GuessExt("video/webm", new Uri("https://paca.stream/stream?id=123")));
|
||||
Assert.Equal(".m4a", DirectDownloader.GuessExt("audio/mp4", new Uri("https://paca.stream/stream?id=123")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gate472_DirectDownloader_VerifiesChecksum_WhenProvided_AndMatches()
|
||||
{
|
||||
var content = "PACA-DIRECT-DOWNLOAD-VERIFIED-DATA";
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
var sha256 = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
|
||||
var handler = new SimpleMockHttp(bytes);
|
||||
using var client = new HttpClient(handler);
|
||||
var config = new AppConfig();
|
||||
var downloader = new DirectDownloader(client, config);
|
||||
|
||||
var outputBase = Path.Combine(_testDir, "test_success");
|
||||
var resultPath = await downloader.DownloadAsync(
|
||||
new Uri("https://paca.test/file.mp4"),
|
||||
outputBase,
|
||||
progress: null,
|
||||
ct: CancellationToken.None,
|
||||
expectedSha256: sha256
|
||||
);
|
||||
|
||||
Assert.True(File.Exists(resultPath));
|
||||
Assert.Equal(bytes.Length, new FileInfo(resultPath).Length);
|
||||
Assert.False(File.Exists(outputBase + ".part"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gate473_DirectDownloader_ThrowsInvalidDataException_WhenChecksumMismatches()
|
||||
{
|
||||
var content = "TAMPERED-DATA-PAYLOAD";
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
|
||||
var handler = new SimpleMockHttp(bytes);
|
||||
using var client = new HttpClient(handler);
|
||||
var config = new AppConfig();
|
||||
var downloader = new DirectDownloader(client, config);
|
||||
|
||||
var outputBase = Path.Combine(_testDir, "test_corrupt");
|
||||
|
||||
await Assert.ThrowsAsync<InvalidDataException>(async () =>
|
||||
{
|
||||
await downloader.DownloadAsync(
|
||||
new Uri("https://paca.test/file.mp4"),
|
||||
outputBase,
|
||||
progress: null,
|
||||
ct: CancellationToken.None,
|
||||
expectedSha256: "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gate474_DirectDownloader_AtomicPartCommit_CleansPartFile_UponCompletion()
|
||||
{
|
||||
var content = "PACA-ATOMIC-COMMIT-STREAM";
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
|
||||
var handler = new SimpleMockHttp(bytes);
|
||||
using var client = new HttpClient(handler);
|
||||
var config = new AppConfig();
|
||||
var downloader = new DirectDownloader(client, config);
|
||||
|
||||
var outputBase = Path.Combine(_testDir, "atomic_output");
|
||||
var final = await downloader.DownloadAsync(
|
||||
new Uri("https://paca.test/video.mp4"),
|
||||
outputBase
|
||||
);
|
||||
|
||||
Assert.True(File.Exists(final));
|
||||
Assert.False(File.Exists(outputBase + ".part"));
|
||||
}
|
||||
|
||||
private sealed class SimpleMockHttp : HttpMessageHandler
|
||||
{
|
||||
private readonly byte[] _data;
|
||||
|
||||
public SimpleMockHttp(byte[] data)
|
||||
{
|
||||
_data = data;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ByteArrayContent(_data)
|
||||
};
|
||||
response.Content.Headers.ContentType = new MediaTypeHeaderValue("video/mp4");
|
||||
response.Content.Headers.ContentLength = _data.Length;
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
using System.Net;
|
||||
using Paca.Core.Config;
|
||||
using Paca.Core.Hls;
|
||||
using Paca.Core.Platform;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class DownloadFolderCleanlinessAndPacaIsolationGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate_DownloadMetaPathResolver_CreatesHiddenPacaFolder_AndSubdirectories()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "paca_clean_test_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
try
|
||||
{
|
||||
var outputBase = Path.Combine(tempRoot, "test_video");
|
||||
var partsDir = DownloadMetaPathResolver.GetPartsDirectory(outputBase);
|
||||
var tempPartFile = DownloadMetaPathResolver.GetTempPartFilePath(outputBase);
|
||||
var pacaDir = DownloadMetaPathResolver.GetPacaDirectory(tempRoot);
|
||||
|
||||
Assert.True(Directory.Exists(pacaDir), ".paca 폴더가 생성되어야 한다.");
|
||||
var di = new DirectoryInfo(pacaDir);
|
||||
Assert.True((di.Attributes & FileAttributes.Hidden) != 0, ".paca 폴더는 Hidden 속성을 가져야 한다.");
|
||||
|
||||
// partsDir은 .paca/parts 하위에 존재해야 함
|
||||
Assert.Contains(Path.Combine(".paca", "parts"), partsDir);
|
||||
Assert.DoesNotContain(tempRoot + Path.DirectorySeparatorChar + "test_video.parts", partsDir);
|
||||
|
||||
// tempPartFile은 .paca/temp 하위에 존재해야 함
|
||||
Assert.Contains(Path.Combine(".paca", "temp"), tempPartFile);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempRoot))
|
||||
{
|
||||
try { Directory.Delete(tempRoot, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gate_DownloadFolder_HlsDownloader_StoresPartsInsidePacaHiddenFolder()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "paca_hls_clean_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
|
||||
// Fake HTTP Listener for sample m3u8
|
||||
var listener = new HttpListener();
|
||||
var port = 58921;
|
||||
listener.Prefixes.Add($"http://127.0.0.1:{port}/");
|
||||
listener.Start();
|
||||
|
||||
var serverTask = Task.Run(async () =>
|
||||
{
|
||||
while (listener.IsListening)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ctx = await listener.GetContextAsync();
|
||||
var path = ctx.Request.Url?.AbsolutePath ?? "";
|
||||
if (path.EndsWith("playlist.m3u8"))
|
||||
{
|
||||
var m3u8 = "#EXTM3U\n#EXT-X-TARGETDURATION:2\n#EXTINF:2.0,\nseg0.ts\n#EXT-X-ENDLIST\n";
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(m3u8);
|
||||
ctx.Response.ContentType = "application/vnd.apple.mpegurl";
|
||||
ctx.Response.OutputStream.Write(bytes);
|
||||
ctx.Response.Close();
|
||||
}
|
||||
else if (path.EndsWith("seg0.ts"))
|
||||
{
|
||||
var seg = new byte[1024];
|
||||
new Random(42).NextBytes(seg);
|
||||
ctx.Response.ContentType = "video/MP2T";
|
||||
ctx.Response.OutputStream.Write(seg);
|
||||
ctx.Response.Close();
|
||||
}
|
||||
}
|
||||
catch { break; }
|
||||
}
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
using var http = new HttpClient();
|
||||
var config = new AppConfig { Concurrency = 1, OutputFolder = tempRoot };
|
||||
var fakeMuxer = new FakeMediaMuxer();
|
||||
var hls = new HlsDownloader(http, config, fakeMuxer);
|
||||
|
||||
var outputBase = Path.Combine(tempRoot, "my_video");
|
||||
var res = await hls.DownloadAsync(new Uri($"http://127.0.0.1:{port}/playlist.m3u8"), outputBase);
|
||||
|
||||
// 1. 루트 폴더에 .parts 폴더나 .json 파일이 절대 없어야 한다!
|
||||
var rootDirs = Directory.GetDirectories(tempRoot).Select(Path.GetFileName).ToList();
|
||||
Assert.DoesNotContain("my_video.parts", rootDirs);
|
||||
|
||||
var rootFiles = Directory.GetFiles(tempRoot).Select(Path.GetFileName).ToList();
|
||||
Assert.DoesNotContain(".chunkcache.json", rootFiles);
|
||||
Assert.DoesNotContain("chunkcache.json", rootFiles);
|
||||
|
||||
// 2. 최종 mp4 파일은 루트에 존재해야 한다
|
||||
Assert.True(File.Exists(outputBase + ".mp4"), "최종 mp4는 루트에 있어야 함");
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
try { await serverTask; } catch { }
|
||||
if (Directory.Exists(tempRoot))
|
||||
{
|
||||
try { Directory.Delete(tempRoot, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gate_DownloadFolder_DirectDownloader_IsolatesPartFileInPacaHiddenDir()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "paca_direct_clean_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
|
||||
try
|
||||
{
|
||||
var fakeHandler = new SimpleMockHandler(new byte[512]);
|
||||
using var client = new HttpClient(fakeHandler);
|
||||
var config = new AppConfig { OutputFolder = tempRoot };
|
||||
var downloader = new DirectDownloader(client, config);
|
||||
|
||||
var outputBase = Path.Combine(tempRoot, "direct_file");
|
||||
var final = await downloader.DownloadAsync(new Uri("https://test.paca/video.mp4"), outputBase);
|
||||
|
||||
Assert.True(File.Exists(final));
|
||||
// 루트 폴더에 .part 파일이 없어야 함
|
||||
Assert.False(File.Exists(outputBase + ".part"), "루트 폴더에 .part 파일이 존재해서는 안 됨");
|
||||
|
||||
var rootFiles = Directory.GetFiles(tempRoot).Select(Path.GetFileName).ToList();
|
||||
Assert.DoesNotContain("direct_file.part", rootFiles);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempRoot))
|
||||
{
|
||||
try { Directory.Delete(tempRoot, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SimpleMockHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly byte[] _data;
|
||||
public SimpleMockHandler(byte[] data) => _data = data;
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var resp = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ByteArrayContent(_data)
|
||||
};
|
||||
resp.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("video/mp4");
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeMediaMuxer : IMediaMuxer
|
||||
{
|
||||
public Task RemuxAsync(string ffmpeg, string input, string output, CancellationToken ct)
|
||||
{
|
||||
File.Copy(input, output, overwrite: true);
|
||||
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) => "fake-ffmpeg";
|
||||
}
|
||||
}
|
||||
57
tests/Paca.Tests/Core/DownloadSelfHealingEngineGateTests.cs
Normal file
57
tests/Paca.Tests/Core/DownloadSelfHealingEngineGateTests.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
using System;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Gate 175 ~ 178: 지능형 실패 진단 및 스마트 자가 치유(Self-Healing) 엔진 기계적 검증
|
||||
/// </summary>
|
||||
public class DownloadSelfHealingEngineGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate175_FailureDiagnostics_Classifies_429_RateLimit()
|
||||
{
|
||||
var rawError = "HTTP 429 Too Many Requests: Rate limit exceeded by YouTube server";
|
||||
var diag = DownloadFailureDiagnostics.Analyze(rawError, httpStatusCode: 429);
|
||||
|
||||
Assert.Equal(FailureCategory.RateLimited, diag.Category);
|
||||
Assert.True(diag.CanAutoRetry);
|
||||
Assert.Equal(TimeSpan.FromSeconds(60), diag.SuggestedDelay);
|
||||
Assert.Contains("요청 한도", diag.UserFriendlyMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate176_FailureDiagnostics_Classifies_DiskFull()
|
||||
{
|
||||
var rawError = "System.IO.IOException: There is not enough space on the disk";
|
||||
var diag = DownloadFailureDiagnostics.Analyze(rawError);
|
||||
|
||||
Assert.Equal(FailureCategory.DiskFull, diag.Category);
|
||||
Assert.False(diag.CanAutoRetry); // 디스크 공간 부족은 자동 재시도 불가(수동 정리 필요)
|
||||
Assert.Contains("저장 공간", diag.UserFriendlyMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate177_FailureDiagnostics_Classifies_NetworkLoss_And_Enables_AutoResume()
|
||||
{
|
||||
var rawError = "System.Net.Sockets.SocketException: The network connection was lost";
|
||||
var diag = DownloadFailureDiagnostics.Analyze(rawError);
|
||||
|
||||
Assert.Equal(FailureCategory.NetworkLoss, diag.Category);
|
||||
Assert.True(diag.CanAutoRetry);
|
||||
Assert.True(diag.CanAutoResume); // 이어받기 가능
|
||||
Assert.Contains("인터넷 연결", diag.UserFriendlyMessage);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, 5)] // 1회차: 5초
|
||||
[InlineData(2, 10)] // 2회차: 10초
|
||||
[InlineData(3, 20)] // 3회차: 20초
|
||||
[InlineData(4, 40)] // 4회차: 40초
|
||||
public void Gate178_SelfHealingStrategy_ExponentialBackoff(int attempt, int expectedSeconds)
|
||||
{
|
||||
var delay = SelfHealingActionStrategy.CalculateExponentialBackoff(attempt, baseDelaySec: 5, maxDelaySec: 60);
|
||||
Assert.Equal(TimeSpan.FromSeconds(expectedSeconds), delay);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
using System;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class DownloadTokenBucketRateLimiterGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate453_DownloadTokenBucketRateLimiter_AllowsImmediateConsumption_WhenBucketFull()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var limiter = new DownloadTokenBucketRateLimiter(rateBytesPerSec: 1_000_000, burstCapacityBytes: 2_000_000, initialTime: now);
|
||||
|
||||
var decision = limiter.Request(500_000, now);
|
||||
|
||||
Assert.True(decision.IsAllowedImmediately);
|
||||
Assert.Equal(TimeSpan.Zero, decision.DelayRequired);
|
||||
Assert.Equal(1_500_000, limiter.AvailableTokens, 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate454_DownloadTokenBucketRateLimiter_CalculatesRequiredDelay_WhenTokensExhausted()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var limiter = new DownloadTokenBucketRateLimiter(rateBytesPerSec: 1_000_000, burstCapacityBytes: 1_000_000, initialTime: now);
|
||||
|
||||
// Exhaust all tokens
|
||||
limiter.Request(1_000_000, now);
|
||||
|
||||
// Request another 500KB immediately (needs 500ms refill)
|
||||
var decision = limiter.Request(500_000, now);
|
||||
|
||||
Assert.False(decision.IsAllowedImmediately);
|
||||
Assert.Equal(TimeSpan.FromMilliseconds(500), decision.DelayRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate455_DownloadTokenBucketRateLimiter_RefillsTokensOverTime_UpToCapacity()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var limiter = new DownloadTokenBucketRateLimiter(rateBytesPerSec: 1_000_000, burstCapacityBytes: 2_000_000, initialTime: now);
|
||||
|
||||
// Consume everything
|
||||
limiter.Request(2_000_000, now);
|
||||
Assert.Equal(0, limiter.AvailableTokens, 1);
|
||||
|
||||
// Advance time by 3 seconds (3MB generated, capped at 2MB burst)
|
||||
var future = now.AddSeconds(3);
|
||||
var decision = limiter.Request(1, future);
|
||||
|
||||
Assert.True(decision.IsAllowedImmediately);
|
||||
Assert.Equal(2_000_000 - 1, limiter.AvailableTokens, 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate456_DownloadTokenBucketRateLimiter_DynamicRateAdjustment_PreservesTokens()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var limiter = new DownloadTokenBucketRateLimiter(rateBytesPerSec: 1_000_000, burstCapacityBytes: 2_000_000, initialTime: now);
|
||||
|
||||
limiter.Request(1_000_000, now);
|
||||
Assert.Equal(1_000_000, limiter.AvailableTokens, 1);
|
||||
|
||||
// Adjust rate to 5 MB/s
|
||||
limiter.SetRate(5_000_000, now);
|
||||
Assert.Equal(1_000_000, limiter.AvailableTokens, 1);
|
||||
Assert.Equal(5_000_000, limiter.RateBytesPerSec);
|
||||
}
|
||||
}
|
||||
84
tests/Paca.Tests/Core/DualSubtitleComposerGateTests.cs
Normal file
84
tests/Paca.Tests/Core/DualSubtitleComposerGateTests.cs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class DualSubtitleComposerGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate289_DualSubtitleComposer_AlignsOverlappingCues_Simultaneously()
|
||||
{
|
||||
var primaryCues = new List<SubtitleCue>
|
||||
{
|
||||
new(10.0, 15.0, "Hello world")
|
||||
};
|
||||
var secondaryCues = new List<SubtitleCue>
|
||||
{
|
||||
new(11.0, 14.0, "안녕하세요")
|
||||
};
|
||||
|
||||
var composer = new DualSubtitleComposer(primaryCues, secondaryCues);
|
||||
|
||||
// At 12.0s, both are active
|
||||
var active = composer.GetActiveDualCue(currentPosSec: 12.0);
|
||||
|
||||
Assert.NotNull(active);
|
||||
Assert.Equal("Hello world", active!.PrimaryText);
|
||||
Assert.Equal("안녕하세요", active.SecondaryText);
|
||||
Assert.True(active.HasDual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate290_DualSubtitleComposer_HandlesMissingSecondary_Gracefully()
|
||||
{
|
||||
var primaryCues = new List<SubtitleCue>
|
||||
{
|
||||
new(10.0, 15.0, "Hello world")
|
||||
};
|
||||
var secondaryCues = new List<SubtitleCue>
|
||||
{
|
||||
new(11.0, 14.0, "안녕하세요")
|
||||
};
|
||||
|
||||
var composer = new DualSubtitleComposer(primaryCues, secondaryCues);
|
||||
|
||||
// At 10.5s, only primary is active
|
||||
var active = composer.GetActiveDualCue(currentPosSec: 10.5);
|
||||
|
||||
Assert.NotNull(active);
|
||||
Assert.Equal("Hello world", active!.PrimaryText);
|
||||
Assert.Null(active.SecondaryText);
|
||||
Assert.False(active.HasDual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate291_DualSubtitleComposer_StylesSecondarySubtitle_WithSmallerFontToken()
|
||||
{
|
||||
var style = DualSubtitleComposer.DefaultSecondaryStyle;
|
||||
Assert.True(style.FontScale < 1.0, "Secondary subtitle should be slightly smaller (e.g. 0.8x)");
|
||||
Assert.False(string.IsNullOrEmpty(style.TextColorHex));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate292_AudioTrackSelector_SelectsPreferredLanguage()
|
||||
{
|
||||
var tracks = new List<MobileAudioTrack>
|
||||
{
|
||||
new(0, "ja", "Japanese (Original)"),
|
||||
new(1, "en", "English (Dub)"),
|
||||
new(2, "ko", "Korean (Dub)")
|
||||
};
|
||||
|
||||
var selected = AudioTrackSelector.PickPreferredTrack(tracks, preferredLangCode: "ko");
|
||||
Assert.NotNull(selected);
|
||||
Assert.Equal(2, selected!.Index);
|
||||
Assert.Equal("ko", selected.LanguageCode);
|
||||
|
||||
// Fallback to first if preferred not found
|
||||
var fallback = AudioTrackSelector.PickPreferredTrack(tracks, preferredLangCode: "fr");
|
||||
Assert.NotNull(fallback);
|
||||
Assert.Equal(0, fallback!.Index);
|
||||
}
|
||||
}
|
||||
58
tests/Paca.Tests/Core/DynamicConcurrencyTunerGateTests.cs
Normal file
58
tests/Paca.Tests/Core/DynamicConcurrencyTunerGateTests.cs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
using System;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class DynamicConcurrencyTunerGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate237_DynamicConcurrencyTuner_Initializes_WithSensibleDefaults()
|
||||
{
|
||||
var tunerNormal = new DynamicConcurrencyTuner(isMetered: false);
|
||||
Assert.Equal(4, tunerNormal.CurrentConcurrency);
|
||||
Assert.Equal(16, tunerNormal.MaxConcurrency);
|
||||
|
||||
var tunerMetered = new DynamicConcurrencyTuner(isMetered: true);
|
||||
Assert.Equal(2, tunerMetered.CurrentConcurrency);
|
||||
Assert.Equal(3, tunerMetered.MaxConcurrency);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate238_DynamicConcurrencyTuner_ScalesUp_OnHighThroughputAndZeroErrors()
|
||||
{
|
||||
var tuner = new DynamicConcurrencyTuner(isMetered: false, initialConcurrency: 4);
|
||||
|
||||
// Record 3 fast chunk completions without error (e.g. 10MB/s each)
|
||||
tuner.RecordChunkResult(bytesDownloaded: 5_000_000, durationMs: 500, isError: false);
|
||||
tuner.RecordChunkResult(bytesDownloaded: 5_000_000, durationMs: 480, isError: false);
|
||||
tuner.RecordChunkResult(bytesDownloaded: 5_000_000, durationMs: 450, isError: false);
|
||||
|
||||
Assert.True(tuner.CurrentConcurrency > 4, $"Concurrency should scale up from 4, but is {tuner.CurrentConcurrency}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate239_DynamicConcurrencyTuner_ScalesDownImmediately_OnErrorOrRateLimit()
|
||||
{
|
||||
var tuner = new DynamicConcurrencyTuner(isMetered: false, initialConcurrency: 8);
|
||||
|
||||
// Encounter network error or rate-limit
|
||||
tuner.RecordChunkResult(bytesDownloaded: 0, durationMs: 1000, isError: true, isRateLimited: true);
|
||||
|
||||
Assert.True(tuner.CurrentConcurrency <= 4, $"Concurrency should be halved on error, but is {tuner.CurrentConcurrency}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate240_DynamicConcurrencyTuner_MeteredMode_StrictCap()
|
||||
{
|
||||
var tuner = new DynamicConcurrencyTuner(isMetered: true, initialConcurrency: 2);
|
||||
|
||||
// Record 10 fast chunks
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
tuner.RecordChunkResult(bytesDownloaded: 10_000_000, durationMs: 200, isError: false);
|
||||
}
|
||||
|
||||
Assert.True(tuner.CurrentConcurrency <= 3, $"Metered concurrency must not exceed 3, but is {tuner.CurrentConcurrency}");
|
||||
}
|
||||
}
|
||||
151
tests/Paca.Tests/Core/ExtremeResilienceAndStressGateTests.cs
Normal file
151
tests/Paca.Tests/Core/ExtremeResilienceAndStressGateTests.cs
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Paca.Core;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 데스크톱 & 모바일 융합 극한 복원성, 대규모 스트레스 및 엣지 케이스 게이트 (Gate 123 ~ Gate 130)
|
||||
/// </summary>
|
||||
public class ExtremeResilienceAndStressGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate123_MassiveQueue_Stress_And_Virtualization_Audit()
|
||||
{
|
||||
var qm = new MobileQueueManager();
|
||||
// 500개 대규모 큐 아이템 고속 등록
|
||||
for (int i = 0; i < 500; i++)
|
||||
{
|
||||
qm.Enqueue($"https://example.com/video_{i}.mp4", $"Batch Video #{i}");
|
||||
}
|
||||
Assert.Equal(500, qm.Items.Count);
|
||||
|
||||
// 첫 번째와 마지막 항목 무결성 확인
|
||||
Assert.Equal("Batch Video #0", qm.Items[0].Title);
|
||||
Assert.Equal("Batch Video #499", qm.Items[499].Title);
|
||||
|
||||
// 일괄 완료 처리 후 청소 성능
|
||||
for (int i = 0; i < 250; i++)
|
||||
{
|
||||
qm.Items[i].Status = MobileQueueStatus.Completed;
|
||||
}
|
||||
qm.ClearCompleted();
|
||||
Assert.Equal(250, qm.Items.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate124_ConcurrentDownloadLock_Audit()
|
||||
{
|
||||
var lockEngine = new ConcurrentDownloadLockEngine();
|
||||
var testUrl = "https://example.com/unique_movie.mp4";
|
||||
|
||||
// 첫 번째 세션 락 획득 성공
|
||||
Assert.True(lockEngine.TryAcquireLock(testUrl, sessionId: "desktop-client"));
|
||||
|
||||
// 두 번째 세션(모바일) 동일 URL 시도 시 락 획득 실패 (중복 방지)
|
||||
Assert.False(lockEngine.TryAcquireLock(testUrl, sessionId: "mobile-client"));
|
||||
|
||||
// 첫 번째 세션 락 반환 후 두 번째 세션 재시도 성공
|
||||
lockEngine.ReleaseLock(testUrl, sessionId: "desktop-client");
|
||||
Assert.True(lockEngine.TryAcquireLock(testUrl, sessionId: "mobile-client"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate125_DiskFull_RollbackEngine_Audit()
|
||||
{
|
||||
var rollbackEngine = new DiskFullRollbackEngine();
|
||||
var tempPartFile = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"paca_temp_{Guid.NewGuid():N}.part");
|
||||
System.IO.File.WriteAllBytes(tempPartFile, new byte[1024]);
|
||||
|
||||
Assert.True(System.IO.File.Exists(tempPartFile));
|
||||
|
||||
// 가상 디스크 풀 에러 발생 시 부분 다운로드 임시 파일 안전 롤백(제거)
|
||||
var result = rollbackEngine.HandleDiskFull(tempPartFile, requiredBytes: 50_000_000, availableBytes: 10_000);
|
||||
Assert.True(result.IsRollbackSuccessful);
|
||||
Assert.False(System.IO.File.Exists(tempPartFile));
|
||||
Assert.Contains("디스크 용량 부족", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate126_SegmentChunkHealer_CorruptChunkRetry_Audit()
|
||||
{
|
||||
var healer = new SegmentChunkHealer();
|
||||
// 헤더 매직 넘버가 손상된 4바이트 청크
|
||||
var corruptChunk = new byte[] { 0x00, 0x00, 0x00, 0x00 };
|
||||
Assert.False(healer.ValidateSegmentIntegrity(corruptChunk, expectedSegmentType: "ts"));
|
||||
|
||||
// 정상 TS 세그먼트 Sync byte (0x47)
|
||||
var validTsChunk = new byte[] { 0x47, 0x40, 0x11, 0x10 };
|
||||
Assert.True(healer.ValidateSegmentIntegrity(validTsChunk, expectedSegmentType: "ts"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate127_OAuthTokenRefresher_Audit()
|
||||
{
|
||||
var refresher = new OAuthTokenRefresher();
|
||||
var token = new AuthTokenInfo(AccessToken: "old_token", RefreshToken: "refresh_123", ExpiresAtUtc: DateTime.UtcNow.AddMinutes(-5));
|
||||
|
||||
Assert.True(refresher.IsExpired(token));
|
||||
|
||||
var newToken = refresher.RefreshToken(token);
|
||||
Assert.False(refresher.IsExpired(newToken));
|
||||
Assert.Equal("new_access_token_refresh_123", newToken.AccessToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate128_LocalizationResourceValidator_Audit()
|
||||
{
|
||||
var loc = new LocalizationResourceCatalog();
|
||||
var languages = new[] { "ko", "en", "ja", "zh" };
|
||||
|
||||
foreach (var lang in languages)
|
||||
{
|
||||
Assert.NotNull(loc.GetString("Download", lang));
|
||||
Assert.NotNull(loc.GetString("Pause", lang));
|
||||
Assert.NotNull(loc.GetString("Settings", lang));
|
||||
}
|
||||
|
||||
Assert.Equal("다운로드", loc.GetString("Download", "ko"));
|
||||
Assert.Equal("Download", loc.GetString("Download", "en"));
|
||||
Assert.Equal("ダウンロード", loc.GetString("Download", "ja"));
|
||||
Assert.Equal("下载", loc.GetString("Download", "zh"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate129_QrConfigBackupRestore_Audit()
|
||||
{
|
||||
var backup = new QrConfigBackupManager();
|
||||
var originalPayload = new ConfigBackupPayload("192.168.0.50:52100", "secret_tok_999", "dark", 5120);
|
||||
|
||||
var qrString = backup.ExportToQrString(originalPayload);
|
||||
Assert.StartsWith("PACA_CFG_V1|", qrString);
|
||||
|
||||
var restoredPayload = backup.ImportFromQrString(qrString);
|
||||
Assert.NotNull(restoredPayload);
|
||||
Assert.Equal(originalPayload.Endpoint, restoredPayload.Endpoint);
|
||||
Assert.Equal(originalPayload.AuthToken, restoredPayload.AuthToken);
|
||||
Assert.Equal(originalPayload.Theme, restoredPayload.Theme);
|
||||
Assert.Equal(originalPayload.SpeedLimitKbps, restoredPayload.SpeedLimitKbps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate130_MediaTaggingEngine_Audit()
|
||||
{
|
||||
var tagger = new MediaTaggingEngine();
|
||||
var metadata = new MediaMetadataInfo(
|
||||
Title: "My Epic Video",
|
||||
Artist: "Creative Creator",
|
||||
AlbumOrPlaylist: "2026 Hits",
|
||||
Year: 2026
|
||||
);
|
||||
|
||||
var tagDictionary = tagger.GenerateFfmpegMetadataArgs(metadata);
|
||||
Assert.Contains("-metadata title=\"My Epic Video\"", tagDictionary);
|
||||
Assert.Contains("-metadata artist=\"Creative Creator\"", tagDictionary);
|
||||
Assert.Contains("-metadata album=\"2026 Hits\"", tagDictionary);
|
||||
Assert.Contains("-metadata date=\"2026\"", tagDictionary);
|
||||
}
|
||||
}
|
||||
176
tests/Paca.Tests/Core/GpuHardwareAccelerationProfilerTests.cs
Normal file
176
tests/Paca.Tests/Core/GpuHardwareAccelerationProfilerTests.cs
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Paca.Core.Media;
|
||||
using Paca.Core.Platform;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class GpuHardwareAccelerationProfilerTests
|
||||
{
|
||||
private sealed class MockProbeProcessRunner : IProcessRunner
|
||||
{
|
||||
private readonly Func<ProcessSpec, ProcessResult> _handler;
|
||||
|
||||
public MockProbeProcessRunner(Func<ProcessSpec, ProcessResult> handler)
|
||||
{
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
public Task<ProcessResult> RunAsync(ProcessSpec spec, Action<string, string>? onLine, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(_handler(spec));
|
||||
}
|
||||
}
|
||||
|
||||
private const string FullRtx4090EncodersOutput =
|
||||
"Encoders:\n" +
|
||||
" V..... h264_nvenc NVIDIA NVENC H.264 encoder (codec h264)\n" +
|
||||
" V..... hevc_nvenc NVIDIA NVENC hevc encoder (codec hevc)\n" +
|
||||
" V..... av1_nvenc NVIDIA NVENC av1 encoder (codec av1)\n" +
|
||||
" V..... libx264 libx264 H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10 (codec h264)\n" +
|
||||
" V..... libx265 libx265 H.265 / HEVC (codec hevc)\n" +
|
||||
" V..... libsvtav1 SVT-AV1(Scalable Video Technology for AV1) encoder (codec av1)\n";
|
||||
|
||||
private const string IntelArcEncodersOutput =
|
||||
"Encoders:\n" +
|
||||
" V..... h264_qsv H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10 (Intel Quick Sync Video acceleration) (codec h264)\n" +
|
||||
" V..... hevc_qsv HEVC (Intel Quick Sync Video acceleration) (codec hevc)\n" +
|
||||
" V..... av1_qsv AV1 (Intel Quick Sync Video acceleration) (codec av1)\n" +
|
||||
" V..... libx264 libx264 H.264 (codec h264)\n";
|
||||
|
||||
private const string AmdRadeon7000EncodersOutput =
|
||||
"Encoders:\n" +
|
||||
" V..... h264_amf AMD AMF H.264 Encoder (codec h264)\n" +
|
||||
" V..... hevc_amf AMD AMF HEVC Encoder (codec hevc)\n" +
|
||||
" V..... av1_amf AMD AMF AV1 Encoder (codec av1)\n" +
|
||||
" V..... libx264 libx264 H.264 (codec h264)\n";
|
||||
|
||||
[Fact]
|
||||
public void GpuCapabilities_NVENC_H264_HEVC_AV1_전부_파싱_검증()
|
||||
{
|
||||
var caps = GpuHardwareAccelerationProfiler.ParseCapabilities(FullRtx4090EncodersOutput);
|
||||
|
||||
Assert.True(caps.Supports(GpuEncoderBackend.NvidiaNvenc, VideoCodecTarget.H264));
|
||||
Assert.True(caps.Supports(GpuEncoderBackend.NvidiaNvenc, VideoCodecTarget.HEVC_H265));
|
||||
Assert.True(caps.Supports(GpuEncoderBackend.NvidiaNvenc, VideoCodecTarget.AV1));
|
||||
Assert.False(caps.Supports(GpuEncoderBackend.IntelQsv, VideoCodecTarget.AV1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GpuCapabilities_IntelQSV_AMD_AMF_AV1_파싱_검증()
|
||||
{
|
||||
var intelCaps = GpuHardwareAccelerationProfiler.ParseCapabilities(IntelArcEncodersOutput);
|
||||
Assert.True(intelCaps.Supports(GpuEncoderBackend.IntelQsv, VideoCodecTarget.AV1));
|
||||
Assert.True(intelCaps.Supports(GpuEncoderBackend.IntelQsv, VideoCodecTarget.HEVC_H265));
|
||||
Assert.False(intelCaps.Supports(GpuEncoderBackend.NvidiaNvenc, VideoCodecTarget.AV1));
|
||||
|
||||
var amdCaps = GpuHardwareAccelerationProfiler.ParseCapabilities(AmdRadeon7000EncodersOutput);
|
||||
Assert.True(amdCaps.Supports(GpuEncoderBackend.AmdAmf, VideoCodecTarget.AV1));
|
||||
Assert.True(amdCaps.Supports(GpuEncoderBackend.AmdAmf, VideoCodecTarget.HEVC_H265));
|
||||
Assert.False(amdCaps.Supports(GpuEncoderBackend.IntelQsv, VideoCodecTarget.AV1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GpuCapabilities_최적_인코더_자동선택_GPU_우선_및_CPU_폴백()
|
||||
{
|
||||
var rtxCaps = GpuHardwareAccelerationProfiler.ParseCapabilities(FullRtx4090EncodersOutput);
|
||||
Assert.Equal("av1_nvenc", rtxCaps.GetOptimalEncoder(VideoCodecTarget.AV1));
|
||||
Assert.Equal("hevc_nvenc", rtxCaps.GetOptimalEncoder(VideoCodecTarget.HEVC_H265));
|
||||
Assert.Equal("h264_nvenc", rtxCaps.GetOptimalEncoder(VideoCodecTarget.H264));
|
||||
|
||||
var cpuOnlyCaps = GpuHardwareAccelerationProfiler.ParseCapabilities(
|
||||
"Encoders:\n V..... libx264\n V..... libx265\n V..... libsvtav1\n");
|
||||
Assert.Equal("libsvtav1", cpuOnlyCaps.GetOptimalEncoder(VideoCodecTarget.AV1));
|
||||
Assert.Equal("libx265", cpuOnlyCaps.GetOptimalEncoder(VideoCodecTarget.HEVC_H265));
|
||||
Assert.Equal("libx264", cpuOnlyCaps.GetOptimalEncoder(VideoCodecTarget.H264));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GpuProfiler_실제_런타임_프로빙_미지원시_폴백_검증()
|
||||
{
|
||||
var runner = new MockProbeProcessRunner(spec =>
|
||||
{
|
||||
if (spec.Args.Contains("-encoders"))
|
||||
{
|
||||
return new ProcessResult(0, FullRtx4090EncodersOutput, "");
|
||||
}
|
||||
|
||||
if (spec.Args.Contains("av1_nvenc"))
|
||||
{
|
||||
return new ProcessResult(1, "", "[av1_nvenc @ 000001] Driver does not support the required nvenc features\nError initializing output stream");
|
||||
}
|
||||
|
||||
if (spec.Args.Contains("hevc_nvenc") || spec.Args.Contains("h264_nvenc"))
|
||||
{
|
||||
return new ProcessResult(0, "frame= 1 fps=0.0 q=0.0 Lsize=N/A video:0KiB", "");
|
||||
}
|
||||
|
||||
return new ProcessResult(0, "", "");
|
||||
});
|
||||
|
||||
var profiler = new GpuHardwareAccelerationProfiler(runner);
|
||||
var report = await profiler.ProfileAllCodecsAsync("ffmpeg.exe", CancellationToken.None);
|
||||
|
||||
Assert.True(report.IsProbeVerified);
|
||||
Assert.Equal("libsvtav1", report.GetBestEncoder(VideoCodecTarget.AV1));
|
||||
Assert.Equal("hevc_nvenc", report.GetBestEncoder(VideoCodecTarget.HEVC_H265));
|
||||
Assert.Equal("h264_nvenc", report.GetBestEncoder(VideoCodecTarget.H264));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GpuProfiler_인코더별_최적_인수_생성_검증()
|
||||
{
|
||||
var nvencAv1Args = GpuHardwareAccelerationProfiler.BuildEncodingArgs("av1_nvenc", crfOrQuality: 24, preset: "p4");
|
||||
Assert.Contains("-c:v", nvencAv1Args);
|
||||
Assert.Contains("av1_nvenc", nvencAv1Args);
|
||||
Assert.Contains("-cq", nvencAv1Args);
|
||||
Assert.Contains("24", nvencAv1Args);
|
||||
Assert.Contains("-preset", nvencAv1Args);
|
||||
Assert.Contains("p4", nvencAv1Args);
|
||||
|
||||
var qsvAv1Args = GpuHardwareAccelerationProfiler.BuildEncodingArgs("av1_qsv", crfOrQuality: 26);
|
||||
Assert.Contains("av1_qsv", qsvAv1Args);
|
||||
Assert.Contains("-global_quality", qsvAv1Args);
|
||||
Assert.Contains("26", qsvAv1Args);
|
||||
|
||||
var svtAv1Args = GpuHardwareAccelerationProfiler.BuildEncodingArgs("libsvtav1", crfOrQuality: 28, preset: "6");
|
||||
Assert.Contains("libsvtav1", svtAv1Args);
|
||||
Assert.Contains("-crf", svtAv1Args);
|
||||
Assert.Contains("28", svtAv1Args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HardwareEncoderDetector_기존_하위호환_및_AV1_확장_검증()
|
||||
{
|
||||
var sample = FullRtx4090EncodersOutput;
|
||||
var caps = HardwareEncoderDetector.ParseCapabilities(sample);
|
||||
|
||||
Assert.True(caps.HasNvidiaNvenc);
|
||||
Assert.True(caps.HasNvencAv1);
|
||||
Assert.True(caps.HasNvencHevc);
|
||||
|
||||
var runner = new MockProbeProcessRunner(_ => new ProcessResult(0, sample, ""));
|
||||
var detector = new HardwareEncoderDetector(runner);
|
||||
|
||||
Assert.Equal("av1_nvenc", detector.GetBestAv1Encoder(caps));
|
||||
Assert.Equal("hevc_nvenc", detector.GetBestHevcEncoder(caps));
|
||||
Assert.Equal("h264_nvenc", detector.GetBestH264Encoder(caps));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HardwareTranscoderStudio_Auto_Backend와_Capabilities_결합시_GPU_인코더_자동선택()
|
||||
{
|
||||
var caps = GpuHardwareAccelerationProfiler.ParseCapabilities(FullRtx4090EncodersOutput);
|
||||
var spec = new TranscodePresetSpec
|
||||
{
|
||||
Backend = HardwareEncoderBackend.Auto,
|
||||
Codec = VideoCodecTarget.AV1
|
||||
};
|
||||
|
||||
var args = HardwareTranscoderStudio.BuildFfmpegTranscodeCommand("in.mp4", "out.mp4", spec, caps);
|
||||
Assert.Contains("av1_nvenc", args);
|
||||
}
|
||||
}
|
||||
90
tests/Paca.Tests/Core/HlsManifestValidatorGateTests.cs
Normal file
90
tests/Paca.Tests/Core/HlsManifestValidatorGateTests.cs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Paca.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class HlsManifestValidatorGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate441_HlsManifestValidator_DetectsMasterPlaylist_AndVariants()
|
||||
{
|
||||
var manifest = @"#EXTM3U
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=1280x720
|
||||
720p.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=1920x1080
|
||||
1080p.m3u8";
|
||||
|
||||
var result = HlsManifestValidator.Analyze(manifest);
|
||||
|
||||
Assert.True(result.IsMasterPlaylist);
|
||||
Assert.Equal(2, result.Variants.Count);
|
||||
Assert.Equal(5000000, result.Variants[0].Bandwidth); // highest first
|
||||
Assert.Equal("1080p.m3u8", result.Variants[0].Uri);
|
||||
Assert.Equal("1920x1080", result.Variants[0].Resolution);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate442_HlsManifestValidator_ParsesMediaPlaylist_Accurately()
|
||||
{
|
||||
var manifest = @"#EXTM3U
|
||||
#EXT-X-TARGETDURATION:10
|
||||
#EXTINF:10.0,
|
||||
seg1.ts
|
||||
#EXTINF:10.0,
|
||||
seg2.ts
|
||||
#EXTINF:5.5,
|
||||
seg3.ts
|
||||
#EXT-X-ENDLIST";
|
||||
|
||||
var result = HlsManifestValidator.Analyze(manifest);
|
||||
|
||||
Assert.False(result.IsMasterPlaylist);
|
||||
Assert.True(result.IsVod);
|
||||
Assert.False(result.IsLive);
|
||||
Assert.Equal(3, result.SegmentCount);
|
||||
Assert.Equal(25.5, result.TotalDurationSec, 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate443_HlsManifestValidator_DetectsDiscontinuities_AndLiveStream()
|
||||
{
|
||||
var manifest = @"#EXTM3U
|
||||
#EXT-X-TARGETDURATION:6
|
||||
#EXTINF:6.0,
|
||||
live1.ts
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXTINF:6.0,
|
||||
live2.ts";
|
||||
|
||||
var result = HlsManifestValidator.Analyze(manifest);
|
||||
|
||||
Assert.True(result.IsLive);
|
||||
Assert.False(result.IsVod);
|
||||
Assert.Equal(1, result.DiscontinuityCount);
|
||||
Assert.Equal(2, result.SegmentCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate444_HlsManifestValidator_StripsAdDiscontinuityBlocks()
|
||||
{
|
||||
var manifest = @"#EXTM3U
|
||||
#EXTINF:10.0,
|
||||
main1.ts
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXTINF:15.0,
|
||||
ad1.ts
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXTINF:10.0,
|
||||
main2.ts
|
||||
#EXT-X-ENDLIST";
|
||||
|
||||
// Ad duration 15.0s between discontinuities stripped
|
||||
var cleaned = HlsManifestValidator.StripAdBlocks(manifest, maxAdDurationSec: 30.0);
|
||||
|
||||
Assert.Contains("main1.ts", cleaned);
|
||||
Assert.Contains("main2.ts", cleaned);
|
||||
Assert.DoesNotContain("ad1.ts", cleaned);
|
||||
}
|
||||
}
|
||||
55
tests/Paca.Tests/Core/MediaBitrateEstimatorGateTests.cs
Normal file
55
tests/Paca.Tests/Core/MediaBitrateEstimatorGateTests.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
using System;
|
||||
using Paca.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class MediaBitrateEstimatorGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate425_MediaBitrateEstimator_CalculatesTargetBitrates_Accurately()
|
||||
{
|
||||
// 100s, 25MB target, 128k audio, 3% container overhead
|
||||
long targetBytes = 25L * 1024 * 1024;
|
||||
var result = MediaBitrateEstimator.CalculateBitrate(100.0, targetBytes, audioBitrateKbps: 128, overheadPercent: 3.0);
|
||||
|
||||
Assert.True(result.IsPossible);
|
||||
Assert.InRange(result.VideoBitrateKbps, 1850, 1950);
|
||||
Assert.Equal(128, result.AudioBitrateKbps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate426_MediaBitrateEstimator_HandlesImpossibleConstraint_Gracefully()
|
||||
{
|
||||
// 1000s, 1MB target (impossible with 128k audio)
|
||||
long targetBytes = 1L * 1024 * 1024;
|
||||
var result = MediaBitrateEstimator.CalculateBitrate(1000.0, targetBytes, audioBitrateKbps: 128);
|
||||
|
||||
Assert.False(result.IsPossible);
|
||||
Assert.True(result.VideoBitrateKbps <= 64); // Fallback minimum
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate427_MediaBitrateEstimator_ProvidesPlatformPresets()
|
||||
{
|
||||
Assert.Equal(25L * 1024 * 1024, MediaBitrateEstimator.GetPlatformLimitBytes(TargetPlatformLimit.DiscordFree));
|
||||
Assert.Equal(16L * 1024 * 1024, MediaBitrateEstimator.GetPlatformLimitBytes(TargetPlatformLimit.WhatsAppVideo));
|
||||
Assert.Equal(20L * 1024 * 1024, MediaBitrateEstimator.GetPlatformLimitBytes(TargetPlatformLimit.EmailAttachment));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate428_MediaBitrateEstimator_BuildsTwoPassFfmpegCommands()
|
||||
{
|
||||
var plan = new BitrateEstimationResult(true, 1800, 128, 25L * 1024 * 1024);
|
||||
var (pass1, pass2) = MediaBitrateEstimator.BuildTwoPassCommands("in.mp4", "out.mp4", plan);
|
||||
|
||||
Assert.Contains("-b:v 1800k", pass1);
|
||||
Assert.Contains("-pass 1", pass1);
|
||||
Assert.Contains("-an", pass1);
|
||||
|
||||
Assert.Contains("-b:v 1800k", pass2);
|
||||
Assert.Contains("-pass 2", pass2);
|
||||
Assert.Contains("-b:a 128k", pass2);
|
||||
Assert.Contains("\"out.mp4\"", pass2);
|
||||
}
|
||||
}
|
||||
77
tests/Paca.Tests/Core/MediaBookmarkAnnotationGateTests.cs
Normal file
77
tests/Paca.Tests/Core/MediaBookmarkAnnotationGateTests.cs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class MediaBookmarkAnnotationGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate349_MediaBookmarkAnnotation_AddsAndSortsByTimestamp()
|
||||
{
|
||||
var doc = new MediaBookmarkDocument("vid-1", "lecture.mp4");
|
||||
|
||||
MediaBookmarkAnnotationEngine.AddOrUpdateBookmark(doc, new MediaBookmark("b1", 120.0, "Key Takeaway 2", null, "#FF0000", DateTime.UtcNow));
|
||||
MediaBookmarkAnnotationEngine.AddOrUpdateBookmark(doc, new MediaBookmark("b2", 30.0, "Introduction", null, "#00FF00", DateTime.UtcNow));
|
||||
MediaBookmarkAnnotationEngine.AddOrUpdateBookmark(doc, new MediaBookmark("b3", 300.0, "Q&A Session", null, "#0000FF", DateTime.UtcNow));
|
||||
|
||||
Assert.Equal(3, doc.Bookmarks.Count);
|
||||
Assert.Equal("Introduction", doc.Bookmarks[0].Title);
|
||||
Assert.Equal(30.0, doc.Bookmarks[0].TimestampSec);
|
||||
Assert.Equal("Key Takeaway 2", doc.Bookmarks[1].Title);
|
||||
Assert.Equal(120.0, doc.Bookmarks[1].TimestampSec);
|
||||
Assert.Equal("Q&A Session", doc.Bookmarks[2].Title);
|
||||
Assert.Equal(300.0, doc.Bookmarks[2].TimestampSec);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate350_MediaBookmarkAnnotation_MergesCloseTimestamps()
|
||||
{
|
||||
var doc = new MediaBookmarkDocument("vid-1", "lecture.mp4");
|
||||
|
||||
MediaBookmarkAnnotationEngine.AddOrUpdateBookmark(doc, new MediaBookmark("b1", 60.0, "Point A", "Original note", "#FFF", DateTime.UtcNow));
|
||||
// Add another bookmark at 60.3s (within 0.5s tolerance) -> Should update Point A
|
||||
MediaBookmarkAnnotationEngine.AddOrUpdateBookmark(doc, new MediaBookmark("b2", 60.3, "Point A Updated", "New note", "#FFF", DateTime.UtcNow));
|
||||
|
||||
Assert.Single(doc.Bookmarks);
|
||||
Assert.Equal("Point A Updated", doc.Bookmarks[0].Title);
|
||||
Assert.Equal("New note", doc.Bookmarks[0].Note);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate351_MediaBookmarkAnnotation_FindsClosestBookmark()
|
||||
{
|
||||
var doc = new MediaBookmarkDocument("vid-1", "lecture.mp4");
|
||||
MediaBookmarkAnnotationEngine.AddOrUpdateBookmark(doc, new MediaBookmark("b1", 100.0, "Intro", null, "#FFF", DateTime.UtcNow));
|
||||
MediaBookmarkAnnotationEngine.AddOrUpdateBookmark(doc, new MediaBookmark("b2", 500.0, "Outro", null, "#FFF", DateTime.UtcNow));
|
||||
|
||||
// Current position at 480.0s -> Closest is b2 (500s, distance 20s)
|
||||
var closest = MediaBookmarkAnnotationEngine.FindClosestBookmark(doc, 480.0);
|
||||
Assert.NotNull(closest);
|
||||
Assert.Equal("b2", closest!.Id);
|
||||
|
||||
// Current position at 150.0s -> Closest is b1 (100s, distance 50s)
|
||||
var closest2 = MediaBookmarkAnnotationEngine.FindClosestBookmark(doc, 150.0);
|
||||
Assert.NotNull(closest2);
|
||||
Assert.Equal("b1", closest2!.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate352_MediaBookmarkAnnotation_SerializesAndDeserializesJson()
|
||||
{
|
||||
var doc = new MediaBookmarkDocument("vid-100", "conference.mp4");
|
||||
MediaBookmarkAnnotationEngine.AddOrUpdateBookmark(doc, new MediaBookmark("b1", 15.5, "Opening Speech", "Important", "#123456", new DateTime(2026, 9, 4, 12, 0, 0, DateTimeKind.Utc)));
|
||||
|
||||
var json = MediaBookmarkAnnotationEngine.Serialize(doc);
|
||||
Assert.Contains("conference.mp4", json);
|
||||
Assert.Contains("Opening Speech", json);
|
||||
|
||||
var restored = MediaBookmarkAnnotationEngine.Deserialize(json);
|
||||
Assert.NotNull(restored);
|
||||
Assert.Equal("vid-100", restored!.MediaId);
|
||||
Assert.Single(restored.Bookmarks);
|
||||
Assert.Equal("Opening Speech", restored.Bookmarks[0].Title);
|
||||
Assert.Equal(15.5, restored.Bookmarks[0].TimestampSec);
|
||||
}
|
||||
}
|
||||
85
tests/Paca.Tests/Core/MediaChapterAndSubtitleGateTests.cs
Normal file
85
tests/Paca.Tests/Core/MediaChapterAndSubtitleGateTests.cs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Paca.Mobile.Core;
|
||||
using Paca.Tests.Infrastructure;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Gate 187 ~ 190: 미디어 인텔리전스 챕터 네비게이션 & 자막 동기화 트랙 기계적 검증
|
||||
/// </summary>
|
||||
public class MediaChapterAndSubtitleGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate187_ChapterMetadataParser_Extracts_Timestamps()
|
||||
{
|
||||
var description = """
|
||||
영상 타임라인 안내:
|
||||
00:00 오프닝 및 소개
|
||||
02:15 데스크톱 게이트웨이 서비스 설명
|
||||
10:45 모바일 원클릭 LAN 자동 발견 데모
|
||||
25:30 질의응답 및 마무리
|
||||
""";
|
||||
|
||||
var chapters = ChapterMetadataParser.ParseFromDescription(description);
|
||||
|
||||
Assert.Equal(4, chapters.Count);
|
||||
Assert.Equal("오프닝 및 소개", chapters[0].Title);
|
||||
Assert.Equal(TimeSpan.Zero, chapters[0].StartTime);
|
||||
Assert.Equal(TimeSpan.FromMinutes(2) + TimeSpan.FromSeconds(15), chapters[0].EndTime);
|
||||
|
||||
Assert.Equal("데스크톱 게이트웨이 서비스 설명", chapters[1].Title);
|
||||
Assert.Equal(TimeSpan.FromMinutes(2) + TimeSpan.FromSeconds(15), chapters[1].StartTime);
|
||||
Assert.Equal(TimeSpan.FromMinutes(10) + TimeSpan.FromSeconds(45), chapters[1].EndTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate188_ChapterMetadataParser_Finds_Active_Chapter()
|
||||
{
|
||||
var description = """
|
||||
00:00 파트 A
|
||||
05:00 파트 B
|
||||
15:00 파트 C
|
||||
""";
|
||||
|
||||
var chapters = ChapterMetadataParser.ParseFromDescription(description);
|
||||
|
||||
// 03:30 시청 중 -> 파트 A 활성
|
||||
var active1 = ChapterMetadataParser.GetActiveChapter(chapters, TimeSpan.FromMinutes(3.5));
|
||||
Assert.NotNull(active1);
|
||||
Assert.Equal("파트 A", active1.Title);
|
||||
|
||||
// 07:00 시청 중 -> 파트 B 활성
|
||||
var active2 = ChapterMetadataParser.GetActiveChapter(chapters, TimeSpan.FromMinutes(7));
|
||||
Assert.NotNull(active2);
|
||||
Assert.Equal("파트 B", active2.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate189_SubtitleStreamTrack_Formatting()
|
||||
{
|
||||
var track = new SubtitleStreamTrack(
|
||||
LanguageCode: "ko",
|
||||
LanguageName: "한국어 (자동 생성)",
|
||||
VttUrl: "http://localhost:52100/api/subtitles/video123?lang=ko",
|
||||
IsDefault: true
|
||||
);
|
||||
|
||||
Assert.Equal("ko", track.LanguageCode);
|
||||
Assert.True(track.IsDefault);
|
||||
Assert.Contains("lang=ko", track.VttUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate190_ServerHost_Subtitles_Endpoint_Audit()
|
||||
{
|
||||
var serverHostPath = Path.Combine(SourceTree.Root, "src", "Paca.Server", "ServerHost.cs");
|
||||
Assert.True(File.Exists(serverHostPath), "ServerHost.cs must exist");
|
||||
|
||||
var code = File.ReadAllText(serverHostPath);
|
||||
|
||||
Assert.Contains("/api/subtitles/{id}", code);
|
||||
}
|
||||
}
|
||||
61
tests/Paca.Tests/Core/MediaChecksumVerifierGateTests.cs
Normal file
61
tests/Paca.Tests/Core/MediaChecksumVerifierGateTests.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class MediaChecksumVerifierGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate369_MediaChecksumVerifier_ComputesSha256StreamHash_Correctly()
|
||||
{
|
||||
// "Hello Paca Media!" in UTF-8
|
||||
var bytes = Encoding.UTF8.GetBytes("Hello Paca Media!");
|
||||
using var stream = new MemoryStream(bytes);
|
||||
|
||||
var sha256 = MediaChecksumVerifier.ComputeHash(stream, ChecksumAlgorithm.Sha256);
|
||||
|
||||
// Known SHA-256 for "Hello Paca Media!"
|
||||
Assert.StartsWith("f14b916b33d6270011bcd23995bf4d4a2ae0199ec", sha256.ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate370_MediaChecksumVerifier_ComputesMd5StreamHash_Correctly()
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes("PacaDownloadEngine");
|
||||
using var stream = new MemoryStream(bytes);
|
||||
|
||||
var md5 = MediaChecksumVerifier.ComputeHash(stream, ChecksumAlgorithm.Md5);
|
||||
|
||||
// Known MD5 for "PacaDownloadEngine"
|
||||
Assert.Equal("1d95ac097022b5c556252b43a2f5929a", md5.ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate371_MediaChecksumVerifier_VerifiesEtagWithQuotesAndWeakPrefix()
|
||||
{
|
||||
var computed = "93700ce818c3937ec89f2a00908eb82f";
|
||||
|
||||
// Quoted ETag: "\"93700ce818c3937ec89f2a00908eb82f\""
|
||||
Assert.True(MediaChecksumVerifier.VerifyHash(computed, "\"93700ce818c3937ec89f2a00908eb82f\""));
|
||||
|
||||
// Weak ETag: "W/\"93700CE818C3937EC89F2A00908EB82F\""
|
||||
Assert.True(MediaChecksumVerifier.VerifyHash(computed, "W/\"93700CE818C3937EC89F2A00908EB82F\""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate372_MediaChecksumVerifier_DetectsMismatchAndGeneratesReport()
|
||||
{
|
||||
var computed = "aaaa1111";
|
||||
var expected = "bbbb2222";
|
||||
|
||||
var result = MediaChecksumVerifier.EvaluateIntegrity("file.mp4", computed, expected);
|
||||
|
||||
Assert.False(result.IsIntact);
|
||||
Assert.Equal("aaaa1111", result.ComputedHash);
|
||||
Assert.Equal("bbbb2222", result.ExpectedHash);
|
||||
Assert.Contains("해시 불일치", result.StatusMessage);
|
||||
}
|
||||
}
|
||||
1551
tests/Paca.Tests/Core/MediaEnginePipelineTests.cs
Normal file
1551
tests/Paca.Tests/Core/MediaEnginePipelineTests.cs
Normal file
File diff suppressed because it is too large
Load diff
73
tests/Paca.Tests/Core/MediaFileNameTemplateGateTests.cs
Normal file
73
tests/Paca.Tests/Core/MediaFileNameTemplateGateTests.cs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
using System;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class MediaFileNameTemplateGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate361_MediaFileNameTemplate_SubstitutesTokens_Accurately()
|
||||
{
|
||||
var ctx = new MediaTemplateContext
|
||||
{
|
||||
Title = "Rolling in the Deep",
|
||||
Artist = "Adele",
|
||||
Channel = "AdeleVEVO",
|
||||
Domain = "youtube.com",
|
||||
Resolution = "1080p",
|
||||
UploadDate = new DateTime(2010, 11, 30),
|
||||
TrackNumber = 1
|
||||
};
|
||||
|
||||
var template = "[{domain}] {artist} - {title} ({resolution})";
|
||||
var result = MediaFileNameTemplateEngine.FormatFileName(template, ctx);
|
||||
|
||||
Assert.Equal("[youtube.com] Adele - Rolling in the Deep (1080p)", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate362_MediaFileNameTemplate_HandlesNullTokens_Gracefully()
|
||||
{
|
||||
var ctx = new MediaTemplateContext
|
||||
{
|
||||
Title = "Solo Track",
|
||||
Artist = null,
|
||||
Resolution = null
|
||||
};
|
||||
|
||||
var template = "{artist} - {title} [{resolution}]";
|
||||
var result = MediaFileNameTemplateEngine.FormatFileName(template, ctx);
|
||||
|
||||
// Null tokens are omitted or stripped cleanly
|
||||
Assert.Equal("Solo Track", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate363_MediaFileNameTemplate_SanitizesIllegalCharacters()
|
||||
{
|
||||
var dirty = "Who: Are / You * In <Wonderland>? | Check \"This\"";
|
||||
var clean = MediaFileNameTemplateEngine.SanitizePathSegment(dirty);
|
||||
|
||||
Assert.DoesNotContain(":", clean);
|
||||
Assert.DoesNotContain("/", clean);
|
||||
Assert.DoesNotContain("*", clean);
|
||||
Assert.DoesNotContain("<", clean);
|
||||
Assert.DoesNotContain(">", clean);
|
||||
Assert.DoesNotContain("?", clean);
|
||||
Assert.DoesNotContain("|", clean);
|
||||
Assert.DoesNotContain("\"", clean);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate364_MediaFileNameTemplate_TruncatesExcessiveLength_PreservingFormat()
|
||||
{
|
||||
var longTitle = new string('A', 300);
|
||||
var ctx = new MediaTemplateContext { Title = longTitle };
|
||||
|
||||
var formatted = MediaFileNameTemplateEngine.FormatFileName("{title}", ctx, maxLength: 50);
|
||||
|
||||
Assert.Equal(50, formatted.Length);
|
||||
Assert.StartsWith("AAAA", formatted);
|
||||
}
|
||||
}
|
||||
70
tests/Paca.Tests/Core/MediaIntegrityVerifierGateTests.cs
Normal file
70
tests/Paca.Tests/Core/MediaIntegrityVerifierGateTests.cs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class MediaIntegrityVerifierGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate273_MediaIntegrityVerifier_IdentifiesValidMp4()
|
||||
{
|
||||
// MP4 signature: 00 00 00 18 66 74 79 70 (ftyp) 69 73 6F 6D (isom)
|
||||
var mp4Bytes = new byte[] {
|
||||
0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70,
|
||||
0x69, 0x73, 0x6F, 0x6D, 0x00, 0x00, 0x02, 0x00,
|
||||
0x69, 0x73, 0x6F, 0x6D, 0x69, 0x73, 0x6F, 0x32,
|
||||
0x00, 0x00, 0x00, 0x08, 0x6D, 0x6F, 0x6F, 0x76 // moov
|
||||
};
|
||||
|
||||
var result = MediaIntegrityVerifier.Verify(mp4Bytes);
|
||||
|
||||
Assert.Equal(MediaIntegrityStatus.Healthy, result.Status);
|
||||
Assert.Equal("mp4", result.DetectedFormat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate274_MediaIntegrityVerifier_IdentifiesValidWebmAndMp3()
|
||||
{
|
||||
// EBML (WebM/MKV): 1A 45 DF A3
|
||||
var webmBytes = new byte[] { 0x1A, 0x45, 0xDF, 0xA3, 0x9F, 0x42, 0x86, 0x81 };
|
||||
var webmResult = MediaIntegrityVerifier.Verify(webmBytes);
|
||||
Assert.Equal(MediaIntegrityStatus.Healthy, webmResult.Status);
|
||||
Assert.Equal("webm", webmResult.DetectedFormat);
|
||||
|
||||
// MP3 ID3: 49 44 33 (ID3)
|
||||
var mp3Bytes = new byte[] { 0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00 };
|
||||
var mp3Result = MediaIntegrityVerifier.Verify(mp3Bytes);
|
||||
Assert.Equal(MediaIntegrityStatus.Healthy, mp3Result.Status);
|
||||
Assert.Equal("mp3", mp3Result.DetectedFormat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate275_MediaIntegrityVerifier_DetectsZeroByteAndTruncatedFiles()
|
||||
{
|
||||
var zeroResult = MediaIntegrityVerifier.Verify(Array.Empty<byte>());
|
||||
Assert.Equal(MediaIntegrityStatus.FatalCorruption, zeroResult.Status);
|
||||
Assert.Equal(CorruptionReason.ZeroByteFile, zeroResult.Reason);
|
||||
|
||||
var truncated = new byte[] { 0x00, 0x00, 0x00 };
|
||||
var truncResult = MediaIntegrityVerifier.Verify(truncated);
|
||||
Assert.Equal(MediaIntegrityStatus.FatalCorruption, truncResult.Status);
|
||||
Assert.Equal(CorruptionReason.TruncatedHeader, truncResult.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate276_MediaIntegrityVerifier_SuggestsRemuxRecovery_ForMissingMoovAtom()
|
||||
{
|
||||
// MP4 with ftyp and mdat but no moov atom (interrupted recording/mux)
|
||||
var brokenMp4 = new byte[] {
|
||||
0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70,
|
||||
0x69, 0x73, 0x6F, 0x6D, 0x00, 0x00, 0x02, 0x00,
|
||||
0x00, 0x00, 0x00, 0x08, 0x6D, 0x64, 0x61, 0x74 // mdat (no moov)
|
||||
};
|
||||
|
||||
var result = MediaIntegrityVerifier.Verify(brokenMp4);
|
||||
Assert.Equal(MediaIntegrityStatus.RecoverableByRemux, result.Status);
|
||||
Assert.Equal(CorruptionReason.MissingMoovAtom, result.Reason);
|
||||
}
|
||||
}
|
||||
89
tests/Paca.Tests/Core/MediaMetadataTagWriterGateTests.cs
Normal file
89
tests/Paca.Tests/Core/MediaMetadataTagWriterGateTests.cs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
using System;
|
||||
using Paca.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class MediaMetadataTagWriterGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate293_MediaMetadataTagWriter_GuessesArtistAndCleanTitle_FromRawString()
|
||||
{
|
||||
// YouTube/Social video titles often come as "Artist - Title [Official Video]" or "(Official Audio)"
|
||||
var raw1 = "Adele - Rolling in the Deep [Official Music Video]";
|
||||
var (artist1, title1) = MediaMetadataTagWriter.GuessMetadataFromTitle(raw1);
|
||||
|
||||
Assert.Equal("Adele", artist1);
|
||||
Assert.Equal("Rolling in the Deep", title1);
|
||||
|
||||
var raw2 = "Queen - Bohemian Rhapsody (Live at Wembley 1986) [Remastered HD]";
|
||||
var (artist2, title2) = MediaMetadataTagWriter.GuessMetadataFromTitle(raw2);
|
||||
|
||||
Assert.Equal("Queen", artist2);
|
||||
Assert.Equal("Bohemian Rhapsody", title2);
|
||||
|
||||
var raw3 = "Just a Plain Video Title";
|
||||
var (artist3, title3) = MediaMetadataTagWriter.GuessMetadataFromTitle(raw3);
|
||||
|
||||
Assert.Null(artist3);
|
||||
Assert.Equal("Just a Plain Video Title", title3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate294_MediaMetadataTagWriter_SanitizesTagValues_AgainstInjectionsAndControlChars()
|
||||
{
|
||||
var dirty = "Track \"Title\" with \n newlines and \r\t tabs & special chars";
|
||||
var clean = MediaMetadataTagWriter.SanitizeTagValue(dirty);
|
||||
|
||||
Assert.DoesNotContain("\n", clean);
|
||||
Assert.DoesNotContain("\r", clean);
|
||||
Assert.DoesNotContain("\t", clean);
|
||||
Assert.DoesNotContain("\"", clean);
|
||||
Assert.Equal("Track Title with newlines and tabs & special chars", clean);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate295_MediaMetadataTagWriter_BuildsFfmpegArgs_WithId3AndMetadata()
|
||||
{
|
||||
var tag = new MediaMetadataTag
|
||||
{
|
||||
Title = "Hotel California",
|
||||
Artist = "Eagles",
|
||||
Album = "Hotel California (2013 Remaster)",
|
||||
Year = 1976,
|
||||
Genre = "Classic Rock",
|
||||
TrackNumber = 1,
|
||||
TotalTracks = 9
|
||||
};
|
||||
|
||||
var args = MediaMetadataTagWriter.BuildFfmpegArgs(tag, "C:\\music\\input.mp3", "C:\\music\\output.mp3");
|
||||
|
||||
Assert.Contains("-i \"C:\\music\\input.mp3\"", args);
|
||||
Assert.Contains("-metadata title=\"Hotel California\"", args);
|
||||
Assert.Contains("-metadata artist=\"Eagles\"", args);
|
||||
Assert.Contains("-metadata album=\"Hotel California (2013 Remaster)\"", args);
|
||||
Assert.Contains("-metadata date=\"1976\"", args);
|
||||
Assert.Contains("-metadata genre=\"Classic Rock\"", args);
|
||||
Assert.Contains("-metadata track=\"1/9\"", args);
|
||||
Assert.Contains("-id3v2_version 3", args);
|
||||
Assert.EndsWith("\"C:\\music\\output.mp3\" -y", args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate296_MediaMetadataTagWriter_AttachesAlbumCover_WhenArtworkProvided()
|
||||
{
|
||||
var tag = new MediaMetadataTag
|
||||
{
|
||||
Title = "Song with Cover",
|
||||
Artist = "Cover Artist",
|
||||
CoverArtworkPath = "C:\\music\\cover.jpg"
|
||||
};
|
||||
|
||||
var args = MediaMetadataTagWriter.BuildFfmpegArgs(tag, "C:\\music\\track.m4a", "C:\\music\\track_tagged.m4a");
|
||||
|
||||
Assert.Contains("-i \"C:\\music\\cover.jpg\"", args);
|
||||
Assert.Contains("-map 0 -map 1", args);
|
||||
Assert.Contains("-c copy", args);
|
||||
Assert.Contains("-disposition:v:1 attached_pic", args);
|
||||
}
|
||||
}
|
||||
70
tests/Paca.Tests/Core/MediaPlaybackSpeedPitchGateTests.cs
Normal file
70
tests/Paca.Tests/Core/MediaPlaybackSpeedPitchGateTests.cs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
using System;
|
||||
using Paca.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class MediaPlaybackSpeedPitchGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate341_MediaPlaybackSpeedPitch_BuildsSingleAtempo_ForNormalRange()
|
||||
{
|
||||
var filter15 = MediaPlaybackSpeedPitchEngine.BuildAtempoFilterChain(1.5);
|
||||
Assert.Equal("atempo=1.50", filter15);
|
||||
|
||||
var filter075 = MediaPlaybackSpeedPitchEngine.BuildAtempoFilterChain(0.75);
|
||||
Assert.Equal("atempo=0.75", filter075);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate342_MediaPlaybackSpeedPitch_ChainsAtempo_ForSpeedsExceedingTwo()
|
||||
{
|
||||
// 3.0x -> atempo=2.0,atempo=1.5
|
||||
var filter30 = MediaPlaybackSpeedPitchEngine.BuildAtempoFilterChain(3.0);
|
||||
Assert.Equal("atempo=2.00,atempo=1.50", filter30);
|
||||
|
||||
// 4.0x -> atempo=2.0,atempo=2.0
|
||||
var filter40 = MediaPlaybackSpeedPitchEngine.BuildAtempoFilterChain(4.0);
|
||||
Assert.Equal("atempo=2.00,atempo=2.00", filter40);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate343_MediaPlaybackSpeedPitch_ClampsExtremeSpeeds()
|
||||
{
|
||||
// Min 0.25
|
||||
var min = MediaPlaybackSpeedPitchEngine.ClampSpeed(0.1);
|
||||
Assert.Equal(0.25, min);
|
||||
|
||||
// Max 4.0
|
||||
var max = MediaPlaybackSpeedPitchEngine.ClampSpeed(10.0);
|
||||
Assert.Equal(4.0, max);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate344_MediaPlaybackSpeedPitch_BuildsFullCommandLine_ForVideoAndAudio()
|
||||
{
|
||||
// Video 1.5x
|
||||
var videoCmd = MediaPlaybackSpeedPitchEngine.BuildSpeedCommandLine(
|
||||
inputPath: "C:\\lecture.mp4",
|
||||
outputPath: "C:\\lecture_1.5x.mp4",
|
||||
speed: 1.5,
|
||||
isAudioOnly: false
|
||||
);
|
||||
|
||||
Assert.Contains("-i \"C:\\lecture.mp4\"", videoCmd);
|
||||
Assert.Contains("-filter:v \"setpts=0.6667*PTS\"", videoCmd);
|
||||
Assert.Contains("-filter:a \"atempo=1.50\"", videoCmd);
|
||||
Assert.EndsWith("\"C:\\lecture_1.5x.mp4\" -y", videoCmd);
|
||||
|
||||
// Audio only 2.0x
|
||||
var audioCmd = MediaPlaybackSpeedPitchEngine.BuildSpeedCommandLine(
|
||||
inputPath: "C:\\podcast.mp3",
|
||||
outputPath: "C:\\podcast_2x.mp3",
|
||||
speed: 2.0,
|
||||
isAudioOnly: true
|
||||
);
|
||||
|
||||
Assert.DoesNotContain("-filter:v", audioCmd);
|
||||
Assert.Contains("-filter:a \"atempo=2.00\"", audioCmd);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class MediaStorageGarbageCollectorGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate321_MediaStorageGarbageCollector_ProtectsActiveFiles_AndSelectsStaleOnly()
|
||||
{
|
||||
var now = new DateTime(2026, 9, 4, 20, 30, 0);
|
||||
|
||||
var candidateFiles = new List<CandidateFileInfo>
|
||||
{
|
||||
// Stale part file from 3 days ago (150 MB)
|
||||
new("C:\\downloads\\movie.mp4.part", 150 * 1024 * 1024, now.AddDays(-3)),
|
||||
// Active part file created 10 minutes ago (50 MB) -> MUST BE PROTECTED!
|
||||
new("C:\\downloads\\active.mp4.part", 50 * 1024 * 1024, now.AddMinutes(-10)),
|
||||
// Normal finished mp4 from 2 days ago -> MUST NOT BE TOUCHED!
|
||||
new("C:\\downloads\\finished.mp4", 500 * 1024 * 1024, now.AddDays(-2)),
|
||||
// Stale tmp fragment from 40 hours ago (80 MB)
|
||||
new("C:\\downloads\\stream_frag.tmp", 80 * 1024 * 1024, now.AddHours(-40))
|
||||
};
|
||||
|
||||
var minAge = TimeSpan.FromHours(24);
|
||||
var orphans = MediaStorageGarbageCollector.FilterOrphanFiles(candidateFiles, minAge, now);
|
||||
|
||||
Assert.Equal(2, orphans.Count);
|
||||
Assert.Contains(orphans, x => x.Path.EndsWith("movie.mp4.part"));
|
||||
Assert.Contains(orphans, x => x.Path.EndsWith("stream_frag.tmp"));
|
||||
Assert.DoesNotContain(orphans, x => x.Path.EndsWith("active.mp4.part"));
|
||||
Assert.DoesNotContain(orphans, x => x.Path.EndsWith("finished.mp4"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate322_MediaStorageGarbageCollector_CalculatesReclaimableSpace_Correctly()
|
||||
{
|
||||
var now = new DateTime(2026, 9, 4, 20, 30, 0);
|
||||
var candidateFiles = new List<CandidateFileInfo>
|
||||
{
|
||||
new("C:\\temp\\a.part", 200 * 1024 * 1024, now.AddDays(-2)),
|
||||
new("C:\\temp\\b.frag", 300 * 1024 * 1024, now.AddDays(-5))
|
||||
};
|
||||
|
||||
var result = MediaStorageGarbageCollector.AnalyzeReclaimableSpace(candidateFiles, TimeSpan.FromHours(24), now);
|
||||
|
||||
Assert.Equal(2, result.OrphanCount);
|
||||
Assert.Equal(500 * 1024 * 1024, result.TotalReclaimableBytes);
|
||||
Assert.Equal(500.0, result.TotalReclaimableMb);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate323_MediaStorageGarbageCollector_RecognizesAllTemporaryExtensions()
|
||||
{
|
||||
var tempExts = new[] { ".part", ".tmp", ".frag", ".ytdl", ".download" };
|
||||
|
||||
foreach (var ext in tempExts)
|
||||
{
|
||||
Assert.True(MediaStorageGarbageCollector.IsTemporaryExtension($"file{ext}"));
|
||||
}
|
||||
|
||||
Assert.False(MediaStorageGarbageCollector.IsTemporaryExtension("video.mp4"));
|
||||
Assert.False(MediaStorageGarbageCollector.IsTemporaryExtension("audio.mp3"));
|
||||
Assert.False(MediaStorageGarbageCollector.IsTemporaryExtension("subtitles.vtt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate324_MediaStorageGarbageCollector_PerformsBatchDeletion_Gracefully()
|
||||
{
|
||||
var filesToDelete = new List<string> { "file1.part", "file2.part", "file3.part" };
|
||||
var deletedList = new List<string>();
|
||||
|
||||
var result = MediaStorageGarbageCollector.ExecuteCleanUpWithDeleter(
|
||||
filesToDelete,
|
||||
deleter: path =>
|
||||
{
|
||||
if (path == "file2.part") throw new System.IO.IOException("File in use");
|
||||
deletedList.Add(path);
|
||||
}
|
||||
);
|
||||
|
||||
Assert.Equal(2, result.SuccessCount);
|
||||
Assert.Equal(1, result.FailedCount);
|
||||
Assert.Equal(2, deletedList.Count);
|
||||
Assert.Contains("file2.part", result.FailedFiles[0].Path);
|
||||
}
|
||||
}
|
||||
74
tests/Paca.Tests/Core/MediaStreamingTransferGateTests.cs
Normal file
74
tests/Paca.Tests/Core/MediaStreamingTransferGateTests.cs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
using System;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class MediaStreamingTransferGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate353_MediaStreamingTransfer_ParsesExplicitRange_Correctly()
|
||||
{
|
||||
var totalSize = 10_000L;
|
||||
var rangeHeader = "bytes=1000-1999";
|
||||
|
||||
var range = MediaStreamingTransferEngine.ParseRangeHeader(rangeHeader, totalSize);
|
||||
|
||||
Assert.True(range.IsPartial);
|
||||
Assert.Equal(1000L, range.Start);
|
||||
Assert.Equal(1999L, range.End);
|
||||
Assert.Equal(1000L, range.ContentLength);
|
||||
Assert.Equal("bytes 1000-1999/10000", range.ContentRangeHeader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate354_MediaStreamingTransfer_ParsesOpenEndedRange_ToEOF()
|
||||
{
|
||||
var totalSize = 5_000L;
|
||||
var rangeHeader = "bytes=2000-";
|
||||
|
||||
var range = MediaStreamingTransferEngine.ParseRangeHeader(rangeHeader, totalSize);
|
||||
|
||||
Assert.True(range.IsPartial);
|
||||
Assert.Equal(2000L, range.Start);
|
||||
Assert.Equal(4999L, range.End);
|
||||
Assert.Equal(3000L, range.ContentLength);
|
||||
Assert.Equal("bytes 2000-4999/5000", range.ContentRangeHeader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate355_MediaStreamingTransfer_ComputesProgressAndSpeed_Accurately()
|
||||
{
|
||||
// 50 MB transferred out of 100 MB in 5.0 seconds
|
||||
var total = 100 * 1024 * 1024L;
|
||||
var transferred = 50 * 1024 * 1024L;
|
||||
var elapsed = 5.0;
|
||||
|
||||
var progress = MediaStreamingTransferEngine.ComputeProgress("stream-1", transferred, total, elapsed);
|
||||
|
||||
Assert.Equal("stream-1", progress.TransferId);
|
||||
Assert.Equal(50.0, progress.ProgressPercent);
|
||||
Assert.Equal(10 * 1024 * 1024.0, progress.TransferRateBytesPerSec); // 10 MB/s
|
||||
Assert.Equal(10.0, progress.TransferRateMbPerSec);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate356_MediaStreamingTransfer_HandlesInvalidRange_WithFullFallback()
|
||||
{
|
||||
var totalSize = 2_000L;
|
||||
|
||||
// Invalid range exceeding total size
|
||||
var invalidRange = "bytes=5000-6000";
|
||||
var result = MediaStreamingTransferEngine.ParseRangeHeader(invalidRange, totalSize);
|
||||
|
||||
Assert.False(result.IsPartial);
|
||||
Assert.Equal(0L, result.Start);
|
||||
Assert.Equal(1999L, result.End);
|
||||
Assert.Equal(2000L, result.ContentLength);
|
||||
|
||||
// Null or empty range
|
||||
var nullResult = MediaStreamingTransferEngine.ParseRangeHeader(null, totalSize);
|
||||
Assert.False(nullResult.IsPartial);
|
||||
Assert.Equal(2000L, nullResult.ContentLength);
|
||||
}
|
||||
}
|
||||
101
tests/Paca.Tests/Core/MultiTrackMediaMuxerGateTests.cs
Normal file
101
tests/Paca.Tests/Core/MultiTrackMediaMuxerGateTests.cs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Paca.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class MultiTrackMediaMuxerGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate377_MultiTrackMediaMuxer_MapsMultipleAudioTracks_Correctly()
|
||||
{
|
||||
var job = new MuxJobSpecification
|
||||
{
|
||||
VideoTrack = new TrackSpec("C:\\media\\video.mp4", "copy"),
|
||||
AudioTracks = new List<TrackSpec>
|
||||
{
|
||||
new("C:\\media\\audio_kor.m4a", "copy", Language: "kor", Title: "한국어 더빙", IsDefault: true),
|
||||
new("C:\\media\\audio_eng.m4a", "copy", Language: "eng", Title: "English Original", IsDefault: false)
|
||||
},
|
||||
OutputPath = "C:\\media\\output_multitrack.mkv"
|
||||
};
|
||||
|
||||
var cmd = MultiTrackMediaMuxer.BuildMuxCommandLine(job);
|
||||
|
||||
Assert.Contains("-i \"C:\\media\\video.mp4\"", cmd);
|
||||
Assert.Contains("-i \"C:\\media\\audio_kor.m4a\"", cmd);
|
||||
Assert.Contains("-i \"C:\\media\\audio_eng.m4a\"", cmd);
|
||||
Assert.Contains("-map 0:v:0", cmd);
|
||||
Assert.Contains("-map 1:a:0", cmd);
|
||||
Assert.Contains("-map 2:a:0", cmd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate378_MultiTrackMediaMuxer_InjectsAudioMetadataTags()
|
||||
{
|
||||
var job = new MuxJobSpecification
|
||||
{
|
||||
VideoTrack = new TrackSpec("video.mp4", "copy"),
|
||||
AudioTracks = new List<TrackSpec>
|
||||
{
|
||||
new("audio_kor.m4a", "copy", "kor", "한국어 더빙", true),
|
||||
new("audio_eng.m4a", "copy", "eng", "English Original", false)
|
||||
},
|
||||
OutputPath = "output.mkv"
|
||||
};
|
||||
|
||||
var cmd = MultiTrackMediaMuxer.BuildMuxCommandLine(job);
|
||||
|
||||
Assert.Contains("-metadata:s:a:0 language=kor", cmd);
|
||||
Assert.Contains("-metadata:s:a:0 title=\"한국어 더빙\"", cmd);
|
||||
Assert.Contains("-metadata:s:a:1 language=eng", cmd);
|
||||
Assert.Contains("-metadata:s:a:1 title=\"English Original\"", cmd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate379_MultiTrackMediaMuxer_SetsDefaultDisposition_ForPrimaryTrack()
|
||||
{
|
||||
var job = new MuxJobSpecification
|
||||
{
|
||||
VideoTrack = new TrackSpec("video.mp4", "copy"),
|
||||
AudioTracks = new List<TrackSpec>
|
||||
{
|
||||
new("audio_kor.m4a", "copy", "kor", "한국어", true),
|
||||
new("audio_eng.m4a", "copy", "eng", "English", false)
|
||||
},
|
||||
OutputPath = "output.mkv"
|
||||
};
|
||||
|
||||
var cmd = MultiTrackMediaMuxer.BuildMuxCommandLine(job);
|
||||
|
||||
Assert.Contains("-disposition:a:0 default", cmd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate380_MultiTrackMediaMuxer_IncludesSubtitlesAndBuildsFullCommand()
|
||||
{
|
||||
var job = new MuxJobSpecification
|
||||
{
|
||||
VideoTrack = new TrackSpec("C:\\in\\video.mp4", "copy"),
|
||||
AudioTracks = new List<TrackSpec>
|
||||
{
|
||||
new("C:\\in\\audio.aac", "copy", "kor", "Main Audio", true)
|
||||
},
|
||||
SubtitleTracks = new List<TrackSpec>
|
||||
{
|
||||
new("C:\\in\\subs_kr.srt", "copy", "kor", "한국어 자막", true)
|
||||
},
|
||||
OutputPath = "C:\\out\\final.mkv"
|
||||
};
|
||||
|
||||
var cmd = MultiTrackMediaMuxer.BuildMuxCommandLine(job);
|
||||
|
||||
Assert.Contains("-i \"C:\\in\\subs_kr.srt\"", cmd);
|
||||
Assert.Contains("-map 2:s:0", cmd);
|
||||
Assert.Contains("-metadata:s:s:0 language=kor", cmd);
|
||||
Assert.Contains("-metadata:s:s:0 title=\"한국어 자막\"", cmd);
|
||||
Assert.Contains("-c copy", cmd);
|
||||
Assert.EndsWith("\"C:\\out\\final.mkv\" -y", cmd);
|
||||
}
|
||||
}
|
||||
84
tests/Paca.Tests/Core/NetworkLatencyJitterGateTests.cs
Normal file
84
tests/Paca.Tests/Core/NetworkLatencyJitterGateTests.cs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class NetworkLatencyJitterGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate365_NetworkLatencyJitter_EvaluatesExcellentHealth_OnLowLatency()
|
||||
{
|
||||
var samples = new List<LatencySample>
|
||||
{
|
||||
new(15.0, true),
|
||||
new(18.0, true),
|
||||
new(16.0, true),
|
||||
new(17.0, true),
|
||||
new(15.0, true)
|
||||
};
|
||||
|
||||
var report = NetworkLatencyJitterProber.AnalyzeSamples(samples);
|
||||
|
||||
Assert.Equal(NetworkHealthRating.Excellent, report.Rating);
|
||||
Assert.InRange(report.AverageRttMs, 16.0, 16.5);
|
||||
Assert.True(report.JitterMs < 5.0);
|
||||
Assert.Equal(0.0, report.PacketLossPercent);
|
||||
Assert.Contains("초고속", report.Recommendation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate366_NetworkLatencyJitter_DetectsPacketLoss_AndDegradesRating()
|
||||
{
|
||||
var samples = new List<LatencySample>
|
||||
{
|
||||
new(30.0, true),
|
||||
new(0.0, false), // Lost
|
||||
new(35.0, true),
|
||||
new(40.0, true),
|
||||
new(0.0, false) // Lost
|
||||
};
|
||||
|
||||
// 2 out of 5 lost = 40% loss
|
||||
var report = NetworkLatencyJitterProber.AnalyzeSamples(samples);
|
||||
|
||||
Assert.True(report.Rating == NetworkHealthRating.Degraded || report.Rating == NetworkHealthRating.Poor);
|
||||
Assert.Equal(40.0, report.PacketLossPercent);
|
||||
Assert.Contains("패킷 손실", report.Recommendation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate367_NetworkLatencyJitter_EvaluatesPoor_OnHighJitterAndLatency()
|
||||
{
|
||||
var samples = new List<LatencySample>
|
||||
{
|
||||
new(450.0, true),
|
||||
new(200.0, true),
|
||||
new(550.0, true),
|
||||
new(180.0, true)
|
||||
};
|
||||
|
||||
var report = NetworkLatencyJitterProber.AnalyzeSamples(samples);
|
||||
|
||||
Assert.Equal(NetworkHealthRating.Poor, report.Rating);
|
||||
Assert.True(report.AverageRttMs > 300.0);
|
||||
Assert.True(report.JitterMs > 100.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate368_NetworkLatencyJitter_HandlesEmptyOrTotalLossGracefully()
|
||||
{
|
||||
var empty = NetworkLatencyJitterProber.AnalyzeSamples(new List<LatencySample>());
|
||||
Assert.Equal(NetworkHealthRating.Poor, empty.Rating);
|
||||
Assert.Equal(100.0, empty.PacketLossPercent);
|
||||
|
||||
var totalLoss = NetworkLatencyJitterProber.AnalyzeSamples(new List<LatencySample>
|
||||
{
|
||||
new(0.0, false),
|
||||
new(0.0, false)
|
||||
});
|
||||
Assert.Equal(NetworkHealthRating.Poor, totalLoss.Rating);
|
||||
Assert.Equal(100.0, totalLoss.PacketLossPercent);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class NetworkResilienceSupervisorGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate265_NetworkResilienceSupervisor_PausesActiveDownloads_OnNetworkLoss()
|
||||
{
|
||||
var supervisor = new NetworkResilienceSupervisor();
|
||||
var item1 = new ResilientDownloadItem("id-1", "video1", ResilientDownloadState.Downloading);
|
||||
var item2 = new ResilientDownloadItem("id-2", "video2", ResilientDownloadState.Downloading);
|
||||
|
||||
supervisor.RegisterItems(new[] { item1, item2 });
|
||||
|
||||
// Network goes down
|
||||
supervisor.OnNetworkStatusChanged(isConnected: false);
|
||||
|
||||
Assert.Equal(ResilientDownloadState.NetworkPaused, item1.State);
|
||||
Assert.Equal(ResilientDownloadState.NetworkPaused, item2.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate266_NetworkResilienceSupervisor_AutomaticallyResumes_OnNetworkRestored()
|
||||
{
|
||||
var supervisor = new NetworkResilienceSupervisor();
|
||||
var item = new ResilientDownloadItem("id-1", "video1", ResilientDownloadState.Downloading);
|
||||
supervisor.RegisterItems(new[] { item });
|
||||
|
||||
supervisor.OnNetworkStatusChanged(isConnected: false);
|
||||
Assert.Equal(ResilientDownloadState.NetworkPaused, item.State);
|
||||
|
||||
// Network comes back up
|
||||
var resumedItems = supervisor.OnNetworkStatusChanged(isConnected: true);
|
||||
|
||||
Assert.Equal(ResilientDownloadState.Downloading, item.State);
|
||||
Assert.Single(resumedItems);
|
||||
Assert.Equal("id-1", resumedItems[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate267_NetworkResilienceSupervisor_DoesNotResume_UserPausedItems()
|
||||
{
|
||||
var supervisor = new NetworkResilienceSupervisor();
|
||||
var userPausedItem = new ResilientDownloadItem("id-user", "video_paused", ResilientDownloadState.UserPaused);
|
||||
supervisor.RegisterItems(new[] { userPausedItem });
|
||||
|
||||
// Network disconnect then reconnect
|
||||
supervisor.OnNetworkStatusChanged(isConnected: false);
|
||||
var resumed = supervisor.OnNetworkStatusChanged(isConnected: true);
|
||||
|
||||
Assert.Equal(ResilientDownloadState.UserPaused, userPausedItem.State);
|
||||
Assert.Empty(resumed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate268_NetworkResilienceSupervisor_JitteredBackoff_CalculatesDelays()
|
||||
{
|
||||
var supervisor = new NetworkResilienceSupervisor();
|
||||
|
||||
var delay1 = supervisor.GetJitteredDelayMs(itemIndex: 0, baseDelayMs: 500);
|
||||
var delay2 = supervisor.GetJitteredDelayMs(itemIndex: 1, baseDelayMs: 500);
|
||||
var delay3 = supervisor.GetJitteredDelayMs(itemIndex: 2, baseDelayMs: 500);
|
||||
|
||||
Assert.True(delay1 >= 500);
|
||||
Assert.True(delay2 > delay1);
|
||||
Assert.True(delay3 > delay2);
|
||||
}
|
||||
}
|
||||
78
tests/Paca.Tests/Core/OfflineMediaPrefetcherGateTests.cs
Normal file
78
tests/Paca.Tests/Core/OfflineMediaPrefetcherGateTests.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class OfflineMediaPrefetcherGateTests
|
||||
{
|
||||
private readonly List<string> _seriesPlaylist = new()
|
||||
{
|
||||
"ep-1", "ep-2", "ep-3", "ep-4", "ep-5"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Gate389_OfflineMediaPrefetcher_DecidesToPrefetch_WhenThresholdAndConditionsMet()
|
||||
{
|
||||
var policy = new PrefetchPolicy(RequireWifi: true, MinBatteryPercent: 50, PlaybackProgressThresholdPercent: 70.0, MaxPrefetchCount: 2);
|
||||
var env = new DeviceEnvironmentStatus(IsWifiConnected: true, BatteryPercent: 80);
|
||||
|
||||
// Currently watching ep-2 at 75% progress
|
||||
var decision = OfflineMediaPrefetcher.EvaluatePrefetchDecision(
|
||||
_seriesPlaylist, currentPlayingId: "ep-2", currentProgressPercent: 75.0, env, policy);
|
||||
|
||||
Assert.True(decision.ShouldPrefetch);
|
||||
Assert.Equal(2, decision.ItemsToPrefetch.Count);
|
||||
Assert.Equal("ep-3", decision.ItemsToPrefetch[0]);
|
||||
Assert.Equal("ep-4", decision.ItemsToPrefetch[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate390_OfflineMediaPrefetcher_Waits_WhenProgressBelowThreshold()
|
||||
{
|
||||
var policy = new PrefetchPolicy(true, 50, 70.0, 2);
|
||||
var env = new DeviceEnvironmentStatus(true, 85);
|
||||
|
||||
// Currently watching ep-1 at 40% progress (below 70%)
|
||||
var decision = OfflineMediaPrefetcher.EvaluatePrefetchDecision(
|
||||
_seriesPlaylist, "ep-1", 40.0, env, policy);
|
||||
|
||||
Assert.False(decision.ShouldPrefetch);
|
||||
Assert.Empty(decision.ItemsToPrefetch);
|
||||
Assert.Contains("진행률", decision.ReasonMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate391_OfflineMediaPrefetcher_Blocks_OnCellularOrLowBattery()
|
||||
{
|
||||
var policy = new PrefetchPolicy(RequireWifi: true, MinBatteryPercent: 50, PlaybackProgressThresholdPercent: 70.0, MaxPrefetchCount: 2);
|
||||
|
||||
// On cellular network
|
||||
var cellularEnv = new DeviceEnvironmentStatus(IsWifiConnected: false, BatteryPercent: 90);
|
||||
var decCellular = OfflineMediaPrefetcher.EvaluatePrefetchDecision(_seriesPlaylist, "ep-2", 80.0, cellularEnv, policy);
|
||||
Assert.False(decCellular.ShouldPrefetch);
|
||||
Assert.Contains("Wi-Fi", decCellular.ReasonMessage);
|
||||
|
||||
// Low battery (30%)
|
||||
var lowBatteryEnv = new DeviceEnvironmentStatus(IsWifiConnected: true, BatteryPercent: 30);
|
||||
var decBattery = OfflineMediaPrefetcher.EvaluatePrefetchDecision(_seriesPlaylist, "ep-2", 80.0, lowBatteryEnv, policy);
|
||||
Assert.False(decBattery.ShouldPrefetch);
|
||||
Assert.Contains("배터리", decBattery.ReasonMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate392_OfflineMediaPrefetcher_HandlesEndOfPlaylistGracefully()
|
||||
{
|
||||
var policy = new PrefetchPolicy(true, 50, 70.0, 2);
|
||||
var env = new DeviceEnvironmentStatus(true, 90);
|
||||
|
||||
// Watching the final episode (ep-5)
|
||||
var decision = OfflineMediaPrefetcher.EvaluatePrefetchDecision(
|
||||
_seriesPlaylist, "ep-5", 85.0, env, policy);
|
||||
|
||||
Assert.False(decision.ShouldPrefetch);
|
||||
Assert.Empty(decision.ItemsToPrefetch);
|
||||
Assert.Contains("마지막", decision.ReasonMessage);
|
||||
}
|
||||
}
|
||||
86
tests/Paca.Tests/Core/PacaDeepLinkGateTests.cs
Normal file
86
tests/Paca.Tests/Core/PacaDeepLinkGateTests.cs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
using System;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class PacaDeepLinkGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate215_PacaDeepLinkParser_ParsesDownloadAction_WithValidParameters()
|
||||
{
|
||||
// Arrange
|
||||
var uri = "paca://download?url=https%3A%2F%2Fexample.com%2Fvideo%2F123&format=mp4&quality=1080p";
|
||||
|
||||
// Act
|
||||
var success = PacaDeepLinkParser.TryParse(uri, out var action);
|
||||
|
||||
// Assert
|
||||
Assert.True(success);
|
||||
Assert.NotNull(action);
|
||||
Assert.Equal(PacaDeepLinkActionType.Download, action!.ActionType);
|
||||
|
||||
var downloadAction = Assert.IsType<PacaDownloadDeepLinkAction>(action);
|
||||
Assert.Equal("https://example.com/video/123", downloadAction.TargetUrl);
|
||||
Assert.Equal("mp4", downloadAction.Format);
|
||||
Assert.Equal("1080p", downloadAction.Quality);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate216_PacaDeepLinkParser_ParsesPairingAction_WithConnectCode()
|
||||
{
|
||||
// Arrange
|
||||
var uri = "paca://pair?code=PACA-982341&host=192.168.1.100&port=5000";
|
||||
|
||||
// Act
|
||||
var success = PacaDeepLinkParser.TryParse(uri, out var action);
|
||||
|
||||
// Assert
|
||||
Assert.True(success);
|
||||
Assert.NotNull(action);
|
||||
Assert.Equal(PacaDeepLinkActionType.Pair, action!.ActionType);
|
||||
|
||||
var pairAction = Assert.IsType<PacaPairDeepLinkAction>(action);
|
||||
Assert.Equal("PACA-982341", pairAction.ConnectCode);
|
||||
Assert.Equal("192.168.1.100", pairAction.Host);
|
||||
Assert.Equal(5000, pairAction.Port);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate217_PacaDeepLinkParser_ParsesPlaybackAction_WithSeekTime()
|
||||
{
|
||||
// Arrange
|
||||
var uri = "paca://play?id=media-abc-123&t=145";
|
||||
|
||||
// Act
|
||||
var success = PacaDeepLinkParser.TryParse(uri, out var action);
|
||||
|
||||
// Assert
|
||||
Assert.True(success);
|
||||
Assert.NotNull(action);
|
||||
Assert.Equal(PacaDeepLinkActionType.Play, action!.ActionType);
|
||||
|
||||
var playAction = Assert.IsType<PacaPlayDeepLinkAction>(action);
|
||||
Assert.Equal("media-abc-123", playAction.MediaId);
|
||||
Assert.Equal(145, playAction.SeekSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate218_PacaDeepLinkParser_RejectsMaliciousOrInvalidSchemes()
|
||||
{
|
||||
// 1. Invalid scheme
|
||||
Assert.False(PacaDeepLinkParser.TryParse("https://example.com/download", out _));
|
||||
Assert.False(PacaDeepLinkParser.TryParse("javascript:alert(1)", out _));
|
||||
|
||||
// 2. Local file exploit attempt inside download URL
|
||||
Assert.False(PacaDeepLinkParser.TryParse("paca://download?url=file%3A%2F%2F%2FC%3A%2Fsecret.txt", out _));
|
||||
|
||||
// 3. Unknown or dangerous action
|
||||
Assert.False(PacaDeepLinkParser.TryParse("paca://system?cmd=shutdown", out _));
|
||||
Assert.False(PacaDeepLinkParser.TryParse("paca://invalid_action", out _));
|
||||
|
||||
// 4. Null or empty
|
||||
Assert.False(PacaDeepLinkParser.TryParse("", out _));
|
||||
Assert.False(PacaDeepLinkParser.TryParse(null!, out _));
|
||||
}
|
||||
}
|
||||
77
tests/Paca.Tests/Core/PacaVaultBackupRestoreGateTests.cs
Normal file
77
tests/Paca.Tests/Core/PacaVaultBackupRestoreGateTests.cs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Paca.Mobile.Core;
|
||||
using Paca.Tests.Infrastructure;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Gate 171 ~ 174: 기기 교체 및 무결성 보장을 위한 Paca Vault 스냅샷 백업 & 복원 엔진 기계적 검증
|
||||
/// </summary>
|
||||
public class PacaVaultBackupRestoreGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate171_VaultSnapshot_Creation_And_Integrity()
|
||||
{
|
||||
var favorites = new List<string> { "https://youtu.be/abc", "https://x.com/def" };
|
||||
var queueUrls = new List<string> { "https://chzzk.naver.com/video/1" };
|
||||
|
||||
var snapshot = PacaVaultSnapshot.Create("device-alpha", favorites, queueUrls);
|
||||
|
||||
Assert.Equal("device-alpha", snapshot.SourceDeviceId);
|
||||
Assert.Equal(2, snapshot.Favorites.Count);
|
||||
Assert.Single(snapshot.QueueUrls);
|
||||
Assert.True(snapshot.VerifyChecksum());
|
||||
|
||||
// 조작된 스냅샷 체크섬 검증 실패
|
||||
var tampered = snapshot with { Favorites = new List<string> { "https://malicious.com" } };
|
||||
Assert.False(tampered.VerifyChecksum());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate172_VaultSnapshot_Corrupted_ThrowsException()
|
||||
{
|
||||
var invalidJson = "{\"SourceDeviceId\":\"x\",\"Checksum\":\"invalid-hash\"}";
|
||||
|
||||
Assert.ThrowsAny<Exception>(() =>
|
||||
{
|
||||
var snapshot = JsonSerializer.Deserialize<PacaVaultSnapshot>(invalidJson);
|
||||
if (snapshot == null || !snapshot.VerifyChecksum())
|
||||
{
|
||||
throw new InvalidDataException("Checksum verification failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate173_VaultRestorer_Merge_Strategy()
|
||||
{
|
||||
var currentFavorites = new List<string> { "https://youtu.be/1", "https://youtu.be/2" };
|
||||
var backupFavorites = new List<string> { "https://youtu.be/2", "https://youtu.be/3" };
|
||||
|
||||
var merged = VaultRestorer.MergeLists(currentFavorites, backupFavorites);
|
||||
|
||||
// 중복 제거되어 1, 2, 3 총 3개 존재 확인
|
||||
Assert.Equal(3, merged.Count);
|
||||
Assert.Contains("https://youtu.be/1", merged);
|
||||
Assert.Contains("https://youtu.be/2", merged);
|
||||
Assert.Contains("https://youtu.be/3", merged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate174_ServerHost_VaultEndpoints_Registered()
|
||||
{
|
||||
var serverHostPath = Path.Combine(SourceTree.Root, "src", "Paca.Server", "ServerHost.cs");
|
||||
Assert.True(File.Exists(serverHostPath), "ServerHost.cs must exist");
|
||||
|
||||
var code = File.ReadAllText(serverHostPath);
|
||||
|
||||
Assert.Contains("/api/vault/backup", code);
|
||||
Assert.Contains("/api/vault/restore", code);
|
||||
}
|
||||
}
|
||||
169
tests/Paca.Tests/Core/PartialChunkIntegrityCacheGateTests.cs
Normal file
169
tests/Paca.Tests/Core/PartialChunkIntegrityCacheGateTests.cs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Paca.Core.Config;
|
||||
using Paca.Core.Hls;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class PartialChunkIntegrityCacheGateTests : IDisposable
|
||||
{
|
||||
private readonly string _testDir;
|
||||
|
||||
public PartialChunkIntegrityCacheGateTests()
|
||||
{
|
||||
_testDir = Path.Combine(Path.GetTempPath(), "paca_chunk_test_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_testDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { if (Directory.Exists(_testDir)) Directory.Delete(_testDir, recursive: true); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate457_PartialChunkIntegrityCache_ComputesSha256_Correctly()
|
||||
{
|
||||
var cache = new PartialChunkIntegrityCache();
|
||||
var bytes = Encoding.UTF8.GetBytes("PACA-STREAM-CHUNK-INTEGRITY-2026");
|
||||
|
||||
var hash = cache.ComputeSha256(bytes);
|
||||
|
||||
Assert.NotNull(hash);
|
||||
Assert.Equal(64, hash.Length);
|
||||
Assert.Matches("^[0-9a-f]{64}$", hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate458_PartialChunkIntegrityCache_VerifiesIntactChunk_Correctly()
|
||||
{
|
||||
var cache = new PartialChunkIntegrityCache();
|
||||
var chunkPath = Path.Combine(_testDir, "000001.seg");
|
||||
var testData = Encoding.UTF8.GetBytes("VALID-CHUNK-DATA-123456789");
|
||||
File.WriteAllBytes(chunkPath, testData);
|
||||
|
||||
var expectedHash = cache.ComputeSha256(testData);
|
||||
|
||||
var status = cache.VerifyChunkFile(chunkPath, expectedBytes: testData.Length, expectedSha256: expectedHash);
|
||||
|
||||
Assert.Equal(ChunkIntegrityState.Verified, status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate459_PartialChunkIntegrityCache_DetectsZeroByteOrTruncatedChunk_AsCorrupt()
|
||||
{
|
||||
var cache = new PartialChunkIntegrityCache();
|
||||
|
||||
// 1. Zero-byte chunk
|
||||
var zeroPath = Path.Combine(_testDir, "zero.seg");
|
||||
File.WriteAllBytes(zeroPath, Array.Empty<byte>());
|
||||
var statusZero = cache.VerifyChunkFile(zeroPath, expectedBytes: 100);
|
||||
Assert.Equal(ChunkIntegrityState.Corrupt, statusZero);
|
||||
|
||||
// 2. Truncated chunk (file is 10 bytes, expected 50)
|
||||
var truncPath = Path.Combine(_testDir, "trunc.seg");
|
||||
File.WriteAllBytes(truncPath, new byte[10]);
|
||||
var statusTrunc = cache.VerifyChunkFile(truncPath, expectedBytes: 50);
|
||||
Assert.Equal(ChunkIntegrityState.Corrupt, statusTrunc);
|
||||
|
||||
// 3. Missing chunk
|
||||
var missingPath = Path.Combine(_testDir, "nonexistent.seg");
|
||||
var statusMissing = cache.VerifyChunkFile(missingPath);
|
||||
Assert.Equal(ChunkIntegrityState.Missing, statusMissing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate460_PartialChunkIntegrityCache_DetectsSha256Mismatch_AsCorrupt()
|
||||
{
|
||||
var cache = new PartialChunkIntegrityCache();
|
||||
var chunkPath = Path.Combine(_testDir, "tampered.seg");
|
||||
File.WriteAllBytes(chunkPath, Encoding.UTF8.GetBytes("Original Content"));
|
||||
|
||||
var status = cache.VerifyChunkFile(chunkPath, expectedSha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef");
|
||||
|
||||
Assert.Equal(ChunkIntegrityState.Corrupt, status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate461_PartialChunkIntegrityCache_PersistsAndRestores_LedgerJson()
|
||||
{
|
||||
var cache = new PartialChunkIntegrityCache();
|
||||
var chunkName = "000000.seg";
|
||||
var chunkPath = Path.Combine(_testDir, chunkName);
|
||||
var data = Encoding.UTF8.GetBytes("CHUNK-CONTENT-0");
|
||||
File.WriteAllBytes(chunkPath, data);
|
||||
var hash = cache.ComputeSha256(data);
|
||||
|
||||
cache.RecordChunk(_testDir, index: 0, chunkName: chunkName, bytes: data.Length, sha256: hash);
|
||||
cache.SaveLedger(_testDir);
|
||||
|
||||
// New cache instance reading same directory
|
||||
var cache2 = new PartialChunkIntegrityCache();
|
||||
cache2.LoadLedger(_testDir);
|
||||
|
||||
var entry = cache2.GetChunk(_testDir, 0);
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(0, entry!.Index);
|
||||
Assert.Equal(chunkName, entry.ChunkName);
|
||||
Assert.Equal(data.Length, entry.ActualBytes);
|
||||
Assert.Equal(hash, entry.Sha256Hash);
|
||||
Assert.Equal(ChunkIntegrityState.Verified, entry.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate462_PartialChunkIntegrityCache_ReconcileAndClean_PurgesCorrupted_AndReturnsMissingIndices()
|
||||
{
|
||||
var cache = new PartialChunkIntegrityCache();
|
||||
|
||||
// Prepare 4 segments in _testDir:
|
||||
// seg 0: Valid, 20 bytes
|
||||
var seg0Path = Path.Combine(_testDir, "000000.seg");
|
||||
var seg0Data = Encoding.UTF8.GetBytes("Valid 20-byte seg 0!");
|
||||
File.WriteAllBytes(seg0Path, seg0Data);
|
||||
|
||||
// seg 1: Corrupted (0 bytes)
|
||||
var seg1Path = Path.Combine(_testDir, "000001.seg");
|
||||
File.WriteAllBytes(seg1Path, Array.Empty<byte>());
|
||||
|
||||
// seg 2: Missing (file doesn't exist)
|
||||
var seg2Path = Path.Combine(_testDir, "000002.seg");
|
||||
|
||||
// seg 3: Valid, 15 bytes
|
||||
var seg3Path = Path.Combine(_testDir, "000003.seg");
|
||||
var seg3Data = Encoding.UTF8.GetBytes("Valid seg 3 data");
|
||||
File.WriteAllBytes(seg3Path, seg3Data);
|
||||
|
||||
// Reconcile 4 total chunks
|
||||
var report = cache.ReconcileAndClean(_testDir, totalSegments: 4, index => $"{index:000000}.seg");
|
||||
|
||||
Assert.Equal(4, report.TotalChunks);
|
||||
Assert.Equal(2, report.VerifiedCount);
|
||||
Assert.Equal(1, report.CorruptedOrTruncatedCount);
|
||||
Assert.Equal(1, report.MissingCount);
|
||||
Assert.False(report.IsFullyComplete);
|
||||
|
||||
// Corrupted 0-byte file must have been purged from disk
|
||||
Assert.False(File.Exists(seg1Path));
|
||||
// Valid files must remain
|
||||
Assert.True(File.Exists(seg0Path));
|
||||
Assert.True(File.Exists(seg3Path));
|
||||
|
||||
// Segments needed for download are index 1 and 2
|
||||
Assert.Equal(new[] { 1, 2 }, report.SegmentsToDownload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate463_HlsDownloader_IntegratesWithPartialChunkIntegrityCache_ExposesCache()
|
||||
{
|
||||
var config = new AppConfig();
|
||||
var cache = new PartialChunkIntegrityCache();
|
||||
using var http = new System.Net.Http.HttpClient();
|
||||
|
||||
var downloader = new HlsDownloader(http, config, Paca.Core.Platform.DesktopMediaMuxer.Default, null, cache);
|
||||
|
||||
Assert.Same(cache, downloader.ChunkCache);
|
||||
}
|
||||
}
|
||||
65
tests/Paca.Tests/Core/PlaybackHistorySyncManagerGateTests.cs
Normal file
65
tests/Paca.Tests/Core/PlaybackHistorySyncManagerGateTests.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class PlaybackHistorySyncManagerGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate449_PlaybackHistorySyncManager_RecordsProgress_AndCompletion()
|
||||
{
|
||||
// 96% completion -> marks finished, reset resume position
|
||||
var itemDone = PlaybackHistorySyncManager.CreateRecord("v1", "movie.mp4", 960.0, 1000.0, DateTime.UtcNow);
|
||||
Assert.True(itemDone.IsFinished);
|
||||
Assert.Equal(0.0, itemDone.ResumePositionSec);
|
||||
Assert.Equal(96.0, itemDone.CompletionPercent, 1);
|
||||
|
||||
// 50% completion -> keeps resume position
|
||||
var itemMid = PlaybackHistorySyncManager.CreateRecord("v2", "show.mp4", 500.0, 1000.0, DateTime.UtcNow);
|
||||
Assert.False(itemMid.IsFinished);
|
||||
Assert.Equal(500.0, itemMid.ResumePositionSec);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate440_PlaybackHistorySyncManager_IgnoresNearZero_Resumes()
|
||||
{
|
||||
var item = PlaybackHistorySyncManager.CreateRecord("v3", "clip.mp4", 3.0, 100.0, DateTime.UtcNow);
|
||||
Assert.Equal(0.0, item.ResumePositionSec);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate451_PlaybackHistorySyncManager_MergesCrossDeviceRecords_ConflictResolution()
|
||||
{
|
||||
var dtDesktop = new DateTime(2026, 9, 4, 10, 0, 0, DateTimeKind.Utc);
|
||||
var dtMobile = new DateTime(2026, 9, 4, 11, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var rDesktop = PlaybackHistorySyncManager.CreateRecord("v1", "doc.mp4", 300.0, 1000.0, dtDesktop);
|
||||
var rMobile = PlaybackHistorySyncManager.CreateRecord("v1", "doc.mp4", 600.0, 1000.0, dtMobile);
|
||||
|
||||
var merged = PlaybackHistorySyncManager.Merge(rDesktop, rMobile);
|
||||
|
||||
Assert.Equal(600.0, merged.ResumePositionSec);
|
||||
Assert.Equal(dtMobile, merged.LastPlayedAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate452_PlaybackHistorySyncManager_SerializesAndRestores_HistoryDatabase()
|
||||
{
|
||||
var list = new List<PlaybackHistoryRecord>
|
||||
{
|
||||
PlaybackHistorySyncManager.CreateRecord("v1", "a.mp4", 100.0, 500.0, DateTime.UtcNow),
|
||||
PlaybackHistorySyncManager.CreateRecord("v2", "b.mp4", 980.0, 1000.0, DateTime.UtcNow)
|
||||
};
|
||||
|
||||
var json = PlaybackHistorySyncManager.ToJson(list);
|
||||
var restored = PlaybackHistorySyncManager.FromJson(json);
|
||||
|
||||
Assert.NotNull(restored);
|
||||
Assert.Equal(2, restored.Count);
|
||||
Assert.Equal("v1", restored[0].MediaId);
|
||||
Assert.Equal("v2", restored[1].MediaId);
|
||||
Assert.True(restored[1].IsFinished);
|
||||
}
|
||||
}
|
||||
61
tests/Paca.Tests/Core/PlaybackSleepTimerEngineGateTests.cs
Normal file
61
tests/Paca.Tests/Core/PlaybackSleepTimerEngineGateTests.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
using System;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class PlaybackSleepTimerEngineGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate445_PlaybackSleepTimerEngine_CalculatesVolume_BeforeFade()
|
||||
{
|
||||
var total = TimeSpan.FromMinutes(30);
|
||||
var fade = TimeSpan.FromMinutes(5);
|
||||
var elapsed = TimeSpan.FromMinutes(10);
|
||||
|
||||
var status = PlaybackSleepTimerEngine.Evaluate(elapsed, total, fade, FadeVolumeCurve.Linear);
|
||||
|
||||
Assert.False(status.IsExpired);
|
||||
Assert.False(status.IsFading);
|
||||
Assert.Equal(1.0, status.VolumeFactor, 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate446_PlaybackSleepTimerEngine_CalculatesVolume_DuringFade()
|
||||
{
|
||||
var total = TimeSpan.FromMinutes(30);
|
||||
var fade = TimeSpan.FromMinutes(10); // fade starts at 20m
|
||||
var elapsed = TimeSpan.FromMinutes(25); // halfway through 10m fade
|
||||
|
||||
var statusLinear = PlaybackSleepTimerEngine.Evaluate(elapsed, total, fade, FadeVolumeCurve.Linear);
|
||||
Assert.True(statusLinear.IsFading);
|
||||
Assert.False(statusLinear.IsExpired);
|
||||
Assert.Equal(0.5, statusLinear.VolumeFactor, 2);
|
||||
|
||||
var statusExp = PlaybackSleepTimerEngine.Evaluate(elapsed, total, fade, FadeVolumeCurve.Exponential);
|
||||
Assert.True(statusExp.VolumeFactor < 0.5); // exponential falls faster
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate447_PlaybackSleepTimerEngine_TriggersExpiry_AtOrPastTotal()
|
||||
{
|
||||
var total = TimeSpan.FromMinutes(30);
|
||||
var fade = TimeSpan.FromMinutes(5);
|
||||
|
||||
var statusExact = PlaybackSleepTimerEngine.Evaluate(TimeSpan.FromMinutes(30), total, fade, FadeVolumeCurve.Linear);
|
||||
Assert.True(statusExact.IsExpired);
|
||||
Assert.Equal(0.0, statusExact.VolumeFactor);
|
||||
|
||||
var statusPast = PlaybackSleepTimerEngine.Evaluate(TimeSpan.FromMinutes(35), total, fade, FadeVolumeCurve.Linear);
|
||||
Assert.True(statusPast.IsExpired);
|
||||
Assert.Equal(0.0, statusPast.VolumeFactor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate448_PlaybackSleepTimerEngine_BuildsFfmpegAfadeFilter()
|
||||
{
|
||||
var filter = PlaybackSleepTimerEngine.BuildFfmpegFadeOutFilter(totalSec: 1800, fadeDurationSec: 60);
|
||||
|
||||
Assert.Equal("afade=t=out:st=1740:d=60", filter);
|
||||
}
|
||||
}
|
||||
76
tests/Paca.Tests/Core/ProxyAutoConfigGateTests.cs
Normal file
76
tests/Paca.Tests/Core/ProxyAutoConfigGateTests.cs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class ProxyAutoConfigGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate397_ProxyAutoConfig_GeneratesStandardJavaScriptPac()
|
||||
{
|
||||
var config = new PacConfig
|
||||
{
|
||||
DefaultAction = "DIRECT",
|
||||
Rules = new List<PacRoutingRule>
|
||||
{
|
||||
new("*.youtube.com", "PROXY 127.0.0.1:1080; DIRECT"),
|
||||
new("*.googlevideo.com", "PROXY 127.0.0.1:1080; DIRECT")
|
||||
}
|
||||
};
|
||||
|
||||
var pacScript = ProxyAutoConfigEngine.GeneratePacScript(config);
|
||||
|
||||
Assert.Contains("function FindProxyForURL(url, host)", pacScript);
|
||||
Assert.Contains("shExpMatch(host, \"*.youtube.com\")", pacScript);
|
||||
Assert.Contains("return \"PROXY 127.0.0.1:1080; DIRECT\";", pacScript);
|
||||
Assert.Contains("return \"DIRECT\";", pacScript);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate398_ProxyAutoConfig_EvaluatesHostRoute_ForMatchingDomain()
|
||||
{
|
||||
var config = new PacConfig
|
||||
{
|
||||
DefaultAction = "DIRECT",
|
||||
Rules = new List<PacRoutingRule>
|
||||
{
|
||||
new("*.instagram.com", "PROXY proxy.corp:8080"),
|
||||
new("*.tiktok.com", "SOCKS5 127.0.0.1:9050")
|
||||
}
|
||||
};
|
||||
|
||||
var routeInstagram = ProxyAutoConfigEngine.EvaluateHostRoute(config, "www.instagram.com");
|
||||
Assert.Equal("PROXY proxy.corp:8080", routeInstagram);
|
||||
|
||||
var routeTiktok = ProxyAutoConfigEngine.EvaluateHostRoute(config, "v16.tiktok.com");
|
||||
Assert.Equal("SOCKS5 127.0.0.1:9050", routeTiktok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate399_ProxyAutoConfig_FallsBackToDirect_ForUnmatchedDomain()
|
||||
{
|
||||
var config = new PacConfig
|
||||
{
|
||||
DefaultAction = "DIRECT",
|
||||
Rules = new List<PacRoutingRule>
|
||||
{
|
||||
new("*.twitter.com", "PROXY 127.0.0.1:8080")
|
||||
}
|
||||
};
|
||||
|
||||
var route = ProxyAutoConfigEngine.EvaluateHostRoute(config, "github.com");
|
||||
Assert.Equal("DIRECT", route);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate400_ProxyAutoConfig_HandlesEmptyRulesGracefully()
|
||||
{
|
||||
var emptyConfig = new PacConfig { DefaultAction = "DIRECT" };
|
||||
var script = ProxyAutoConfigEngine.GeneratePacScript(emptyConfig);
|
||||
|
||||
Assert.Contains("return \"DIRECT\";", script);
|
||||
Assert.Equal("DIRECT", ProxyAutoConfigEngine.EvaluateHostRoute(emptyConfig, "example.com"));
|
||||
}
|
||||
}
|
||||
53
tests/Paca.Tests/Core/ProxyConfigurationManagerGateTests.cs
Normal file
53
tests/Paca.Tests/Core/ProxyConfigurationManagerGateTests.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
using System;
|
||||
using System.Net;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class ProxyConfigurationManagerGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate285_ProxyConfigurationManager_ParsesHttpProxy_WithCredentials()
|
||||
{
|
||||
var raw = "http://myuser:mypass123@192.168.1.50:8080";
|
||||
var config = ProxyConfigurationManager.Parse(raw);
|
||||
|
||||
Assert.NotNull(config);
|
||||
Assert.Equal("192.168.1.50", config!.Host);
|
||||
Assert.Equal(8080, config.Port);
|
||||
Assert.Equal("http", config.Scheme);
|
||||
Assert.Equal("myuser", config.Username);
|
||||
Assert.Equal("mypass123", config.Password);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate286_ProxyConfigurationManager_BuildsWebProxy_WithBypassList()
|
||||
{
|
||||
var raw = "http://127.0.0.1:8888";
|
||||
var bypassList = new[] { "localhost", "127.0.0.1", "*.local" };
|
||||
var proxy = ProxyConfigurationManager.CreateWebProxy(raw, bypassList);
|
||||
|
||||
Assert.NotNull(proxy);
|
||||
Assert.True(proxy!.IsBypassed(new Uri("http://localhost:5000/api")));
|
||||
Assert.False(proxy.IsBypassed(new Uri("https://youtube.com/watch?v=123")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate287_ProxyConfigurationManager_BuildsYtDlpArguments()
|
||||
{
|
||||
var raw = "http://127.0.0.1:8888";
|
||||
var args = ProxyConfigurationManager.GetYtDlpProxyArg(raw);
|
||||
|
||||
Assert.Equal("--proxy \"http://127.0.0.1:8888\"", args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate288_ProxyConfigurationManager_HandlesDisabledOrEmptyProxy()
|
||||
{
|
||||
Assert.Null(ProxyConfigurationManager.Parse(""));
|
||||
Assert.Null(ProxyConfigurationManager.Parse(null));
|
||||
Assert.Null(ProxyConfigurationManager.CreateWebProxy("", Array.Empty<string>()));
|
||||
Assert.Equal(string.Empty, ProxyConfigurationManager.GetYtDlpProxyArg(""));
|
||||
}
|
||||
}
|
||||
97
tests/Paca.Tests/Core/QuietHoursNotificationGateTests.cs
Normal file
97
tests/Paca.Tests/Core/QuietHoursNotificationGateTests.cs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class QuietHoursNotificationGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate297_QuietHoursNotificationManager_AccuratelyDetectsOvernightRange()
|
||||
{
|
||||
var settings = new QuietHoursSettings
|
||||
{
|
||||
IsEnabled = true,
|
||||
StartHour = TimeSpan.FromHours(22), // 22:00 (10 PM)
|
||||
EndHour = TimeSpan.FromHours(7) // 07:00 (7 AM)
|
||||
};
|
||||
|
||||
var nightTime = new DateTime(2026, 9, 4, 23, 30, 0);
|
||||
var earlyMorning = new DateTime(2026, 9, 5, 4, 15, 0);
|
||||
var dayTime = new DateTime(2026, 9, 5, 14, 0, 0);
|
||||
|
||||
Assert.True(QuietHoursNotificationManager.IsInQuietHours(nightTime, settings));
|
||||
Assert.True(QuietHoursNotificationManager.IsInQuietHours(earlyMorning, settings));
|
||||
Assert.False(QuietHoursNotificationManager.IsInQuietHours(dayTime, settings));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate298_QuietHoursNotificationManager_MutesSoundAndVibration_DuringQuietHours()
|
||||
{
|
||||
var settings = new QuietHoursSettings
|
||||
{
|
||||
IsEnabled = true,
|
||||
StartHour = TimeSpan.FromHours(23),
|
||||
EndHour = TimeSpan.FromHours(6),
|
||||
MuteSoundAndVibration = true
|
||||
};
|
||||
|
||||
var manager = new QuietHoursNotificationManager(settings);
|
||||
var evt = new DownloadNotificationItem("video-1", "Nature 4K", IsSuccess: true, FileSizeMb: 350.0);
|
||||
|
||||
var action = manager.EvaluateNotification(evt, new DateTime(2026, 9, 5, 1, 0, 0));
|
||||
|
||||
Assert.False(action.PlaySound);
|
||||
Assert.False(action.Vibrate);
|
||||
Assert.True(action.IsSuppressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate299_QuietHoursNotificationManager_BundlesOvernightDownloads_IntoPendingList()
|
||||
{
|
||||
var settings = new QuietHoursSettings
|
||||
{
|
||||
IsEnabled = true,
|
||||
StartHour = TimeSpan.FromHours(22),
|
||||
EndHour = TimeSpan.FromHours(7),
|
||||
BundleSummary = true
|
||||
};
|
||||
|
||||
var manager = new QuietHoursNotificationManager(settings);
|
||||
|
||||
manager.EvaluateNotification(new DownloadNotificationItem("1", "Track 1", true, 50.0), new DateTime(2026, 9, 4, 23, 0, 0));
|
||||
manager.EvaluateNotification(new DownloadNotificationItem("2", "Track 2", true, 60.0), new DateTime(2026, 9, 5, 0, 30, 0));
|
||||
manager.EvaluateNotification(new DownloadNotificationItem("3", "Track 3", false, 0.0), new DateTime(2026, 9, 5, 2, 0, 0));
|
||||
|
||||
Assert.Equal(3, manager.PendingCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate300_QuietHoursNotificationManager_GeneratesMorningDigestBriefing_Correctly()
|
||||
{
|
||||
var settings = new QuietHoursSettings
|
||||
{
|
||||
IsEnabled = true,
|
||||
StartHour = TimeSpan.FromHours(22),
|
||||
EndHour = TimeSpan.FromHours(7),
|
||||
BundleSummary = true
|
||||
};
|
||||
|
||||
var manager = new QuietHoursNotificationManager(settings);
|
||||
|
||||
manager.EvaluateNotification(new DownloadNotificationItem("1", "Video 1", true, 200.0), new DateTime(2026, 9, 4, 23, 0, 0));
|
||||
manager.EvaluateNotification(new DownloadNotificationItem("2", "Video 2", true, 800.0), new DateTime(2026, 9, 5, 1, 0, 0));
|
||||
manager.EvaluateNotification(new DownloadNotificationItem("3", "Video 3", false, 0.0), new DateTime(2026, 9, 5, 3, 0, 0));
|
||||
|
||||
// In morning at 07:15, generate digest
|
||||
var digest = manager.FlushMorningDigest(new DateTime(2026, 9, 5, 7, 15, 0));
|
||||
|
||||
Assert.NotNull(digest);
|
||||
Assert.Equal(2, digest!.SuccessCount);
|
||||
Assert.Equal(1, digest.FailureCount);
|
||||
Assert.Equal(1000.0, digest.TotalSizeMb);
|
||||
Assert.Contains("밤사이 다운로드 2건 완료, 1건 실패", digest.SummaryMessage);
|
||||
Assert.Equal(0, manager.PendingCount); // Flushed
|
||||
}
|
||||
}
|
||||
88
tests/Paca.Tests/Core/RemoteClipboardBridgeGateTests.cs
Normal file
88
tests/Paca.Tests/Core/RemoteClipboardBridgeGateTests.cs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
using System;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class RemoteClipboardBridgeGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate309_RemoteClipboardBridge_SanitizesAndBlocksDangerousSchemes()
|
||||
{
|
||||
// Allowed HTTP / HTTPS
|
||||
var validHttps = "https://www.youtube.com/watch?v=remote123";
|
||||
Assert.True(RemoteClipboardBridge.IsValidMediaUrl(validHttps, out var cleanUrl1));
|
||||
Assert.Equal(validHttps, cleanUrl1);
|
||||
|
||||
// Block javascript: injection
|
||||
var dangerous1 = "javascript:alert(1)";
|
||||
Assert.False(RemoteClipboardBridge.IsValidMediaUrl(dangerous1, out _));
|
||||
|
||||
// Block local file scheme
|
||||
var dangerous2 = "file:///C:/Windows/System32/calc.exe";
|
||||
Assert.False(RemoteClipboardBridge.IsValidMediaUrl(dangerous2, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate310_RemoteClipboardBridge_SerializesAndDeserializes_Correctly()
|
||||
{
|
||||
var payload = new RemoteClipboardPayload(
|
||||
Url: "https://instagram.com/reel/abc999",
|
||||
SourceDeviceName: "Galaxy S24",
|
||||
TargetDeviceName: "Paca Desktop",
|
||||
AutoStartDownload: true,
|
||||
SentAt: new DateTime(2026, 9, 4, 20, 30, 0, DateTimeKind.Utc)
|
||||
);
|
||||
|
||||
var json = RemoteClipboardBridge.Serialize(payload);
|
||||
Assert.Contains("instagram.com", json);
|
||||
Assert.Contains("Galaxy S24", json);
|
||||
|
||||
var restored = RemoteClipboardBridge.Deserialize(json);
|
||||
Assert.NotNull(restored);
|
||||
Assert.Equal(payload.Url, restored!.Url);
|
||||
Assert.Equal(payload.SourceDeviceName, restored.SourceDeviceName);
|
||||
Assert.True(restored.AutoStartDownload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate311_RemoteClipboardBridge_BuildsPushRequest_WithGatewayEndpoint()
|
||||
{
|
||||
var payload = new RemoteClipboardPayload(
|
||||
Url: "https://tiktok.com/@creator/video/111",
|
||||
SourceDeviceName: "iPhone 15",
|
||||
TargetDeviceName: "Office PC",
|
||||
AutoStartDownload: true,
|
||||
SentAt: DateTime.UtcNow
|
||||
);
|
||||
|
||||
var targetGateway = "http://192.168.1.100:52100";
|
||||
var pairingToken = "PACA-SEC-998877";
|
||||
|
||||
var (requestUri, bodyJson, authHeader) = RemoteClipboardBridge.BuildPushRequest(
|
||||
payload, targetGateway, pairingToken);
|
||||
|
||||
Assert.Equal("http://192.168.1.100:52100/api/remote-clipboard", requestUri);
|
||||
Assert.Equal("Bearer PACA-SEC-998877", authHeader);
|
||||
Assert.Contains("tiktok.com", bodyJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate312_RemoteClipboardBridge_HandlesIncomingPayload_AndDeterminesAutoDownload()
|
||||
{
|
||||
var payload = new RemoteClipboardPayload(
|
||||
Url: "https://www.youtube.com/watch?v=goodvideo",
|
||||
SourceDeviceName: "Mobile",
|
||||
TargetDeviceName: "Desktop",
|
||||
AutoStartDownload: true,
|
||||
SentAt: DateTime.UtcNow
|
||||
);
|
||||
|
||||
var result = RemoteClipboardBridge.ProcessIncomingPayload(payload);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.ShouldTriggerDownload);
|
||||
Assert.Equal("https://www.youtube.com/watch?v=goodvideo", result.MediaUrl);
|
||||
Assert.Equal("Mobile 기기에서 보낸 링크를 즉시 다운로드합니다.", result.UserStatusMessage);
|
||||
}
|
||||
}
|
||||
76
tests/Paca.Tests/Core/SmartBandwidthSchedulerGateTests.cs
Normal file
76
tests/Paca.Tests/Core/SmartBandwidthSchedulerGateTests.cs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using Paca.Mobile.Core;
|
||||
using Paca.Tests.Infrastructure;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Gate 179 ~ 182: 스마트 대역폭 스케줄러 & 야간 오프피크 자동 큐잉 엔진 기계적 검증
|
||||
/// </summary>
|
||||
public class SmartBandwidthSchedulerGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate179_BandwidthSchedulePolicy_Immediate_AlwaysAllowed()
|
||||
{
|
||||
var policy = BandwidthSchedulePolicy.ImmediateUncapped;
|
||||
var now = new DateTimeOffset(2026, 9, 4, 14, 0, 0, TimeSpan.FromHours(9)); // 오후 2시
|
||||
|
||||
Assert.True(policy.IsDownloadAllowed(now));
|
||||
Assert.Equal(0, policy.GetCurrentRateLimitBytesPerSec(now)); // 0 = 무제한
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate180_BandwidthSchedulePolicy_NightOwl_TimeWindow()
|
||||
{
|
||||
// 01:00 ~ 06:00 야간 정책
|
||||
var policy = new BandwidthSchedulePolicy(
|
||||
ScheduleMode.NightOwlOffPeak,
|
||||
StartHour: 1,
|
||||
EndHour: 6,
|
||||
ThrottledBytesPerSec: 0
|
||||
);
|
||||
|
||||
// 오후 14:00 -> 다운로드 대기(false)
|
||||
var afternoon = new DateTimeOffset(2026, 9, 4, 14, 30, 0, TimeSpan.FromHours(9));
|
||||
Assert.False(policy.IsDownloadAllowed(afternoon));
|
||||
|
||||
// 새벽 03:00 -> 다운로드 허용(true)
|
||||
var midnight = new DateTimeOffset(2026, 9, 4, 3, 0, 0, TimeSpan.FromHours(9));
|
||||
Assert.True(policy.IsDownloadAllowed(midnight));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate181_BandwidthSchedulePolicy_BusinessHours_Throttling()
|
||||
{
|
||||
// 09:00 ~ 18:00 업무 시간 1MB/s 제한, 그 외 무제한
|
||||
var policy = new BandwidthSchedulePolicy(
|
||||
ScheduleMode.BusinessHoursThrottled,
|
||||
StartHour: 9,
|
||||
EndHour: 18,
|
||||
ThrottledBytesPerSec: 1_048_576 // 1 MB/s
|
||||
);
|
||||
|
||||
// 오전 11:00 -> 스로틀 적용 (1MB/s)
|
||||
var workingTime = new DateTimeOffset(2026, 9, 4, 11, 0, 0, TimeSpan.FromHours(9));
|
||||
Assert.True(policy.IsDownloadAllowed(workingTime));
|
||||
Assert.Equal(1_048_576, policy.GetCurrentRateLimitBytesPerSec(workingTime));
|
||||
|
||||
// 밤 21:00 -> 무제한 (0)
|
||||
var evening = new DateTimeOffset(2026, 9, 4, 21, 0, 0, TimeSpan.FromHours(9));
|
||||
Assert.True(policy.IsDownloadAllowed(evening));
|
||||
Assert.Equal(0, policy.GetCurrentRateLimitBytesPerSec(evening));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate182_ServerHost_SchedulePolicy_Endpoint_Audit()
|
||||
{
|
||||
var serverHostPath = Path.Combine(SourceTree.Root, "src", "Paca.Server", "ServerHost.cs");
|
||||
Assert.True(File.Exists(serverHostPath), "ServerHost.cs must exist");
|
||||
|
||||
var code = File.ReadAllText(serverHostPath);
|
||||
|
||||
Assert.Contains("/api/config/schedule", code);
|
||||
}
|
||||
}
|
||||
53
tests/Paca.Tests/Core/SmartCategoryClassifierGateTests.cs
Normal file
53
tests/Paca.Tests/Core/SmartCategoryClassifierGateTests.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Gate 207 ~ 210: 스마트 미디어 카테고리 자동 분류 및 키워드 태깅 엔진 기계적 검증
|
||||
/// </summary>
|
||||
public class SmartCategoryClassifierGateTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("NewJeans - Hype Boy Official MV", MediaCategory.Music)]
|
||||
[InlineData("IU (아이유) - 밤편지 Live Clip Cover", MediaCategory.Music)]
|
||||
[InlineData("Interstellar Main Theme OST (Official)", MediaCategory.Music)]
|
||||
public void Gate207_Classifier_Detects_Music_Category(string title, MediaCategory expectedCategory)
|
||||
{
|
||||
var category = SmartCategoryClassifier.Classify(title);
|
||||
Assert.Equal(expectedCategory, category);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("C# 12 and .NET 8 Full Tutorial for Beginners", MediaCategory.TechDev)]
|
||||
[InlineData("Python FastApi & Docker 배포 완벽 가이드", MediaCategory.TechDev)]
|
||||
[InlineData("React 19 Hooks and Architecture Review", MediaCategory.TechDev)]
|
||||
public void Gate208_Classifier_Detects_TechDev_Category(string title, MediaCategory expectedCategory)
|
||||
{
|
||||
var category = SmartCategoryClassifier.Classify(title);
|
||||
Assert.Equal(expectedCategory, category);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("마인크래프트 하드코어 100일 생존기 1화", MediaCategory.Gaming)]
|
||||
[InlineData("League of Legends Worlds Grand Final Highlights", MediaCategory.Gaming)]
|
||||
[InlineData("Valorant Radiant Rank Montage", MediaCategory.Gaming)]
|
||||
public void Gate209_Classifier_Detects_Gaming_Category(string title, MediaCategory expectedCategory)
|
||||
{
|
||||
var category = SmartCategoryClassifier.Classify(title);
|
||||
Assert.Equal(expectedCategory, category);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate210_Classifier_Generates_AutoTags()
|
||||
{
|
||||
var title = "[4K] Tokyo Night Walk in Shinjuku #Tokyo #Walk #4K 힐링 영상";
|
||||
var tags = SmartCategoryClassifier.ExtractTags(title);
|
||||
|
||||
Assert.Contains("Tokyo", tags);
|
||||
Assert.Contains("Walk", tags);
|
||||
Assert.Contains("4K", tags);
|
||||
}
|
||||
}
|
||||
83
tests/Paca.Tests/Core/SmartCollectionPlaylistGateTests.cs
Normal file
83
tests/Paca.Tests/Core/SmartCollectionPlaylistGateTests.cs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class SmartCollectionPlaylistGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate231_SmartCollectionEngine_Categorizes_Shorts_And_LongForm_Correctly()
|
||||
{
|
||||
var items = new List<MediaItemMetadata>
|
||||
{
|
||||
new("id-1", "YouTube Short Dance", "https://youtube.com/shorts/xyz", 45, 15_000_000, "mp4"),
|
||||
new("id-2", "Full University Lecture", "https://university.edu/lecture.mp4", 3600, 800_000_000, "mp4")
|
||||
};
|
||||
|
||||
var collections = SmartCollectionEngine.GroupIntoCollections(items);
|
||||
|
||||
Assert.True(collections.ContainsKey("Shorts & Reels"));
|
||||
Assert.Contains(collections["Shorts & Reels"], i => i.Id == "id-1");
|
||||
|
||||
Assert.True(collections.ContainsKey("Long-form & Lectures"));
|
||||
Assert.Contains(collections["Long-form & Lectures"], i => i.Id == "id-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate232_SmartCollectionEngine_Categorizes_Audio_And_Social()
|
||||
{
|
||||
var items = new List<MediaItemMetadata>
|
||||
{
|
||||
new("id-3", "Pop Song Audio", "https://music.com/song", 180, 5_000_000, "mp3"),
|
||||
new("id-4", "Twitter Funny Clip", "https://twitter.com/i/status/123", 15, 2_000_000, "mp4")
|
||||
};
|
||||
|
||||
var collections = SmartCollectionEngine.GroupIntoCollections(items);
|
||||
|
||||
Assert.True(collections.ContainsKey("Music & Podcasts"));
|
||||
Assert.Contains(collections["Music & Podcasts"], i => i.Id == "id-3");
|
||||
|
||||
Assert.True(collections.ContainsKey("Social Clips"));
|
||||
Assert.Contains(collections["Social Clips"], i => i.Id == "id-4");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate233_SmartCollectionEngine_M3uPlaylistGenerator_GeneratesValidM3u8()
|
||||
{
|
||||
var items = new List<MediaItemMetadata>
|
||||
{
|
||||
new("id-1", "Short Video", "https://example.com/video1.mp4", 60, 10_000_000, "mp4", LocalPath: @"C:\Downloads\video1.mp4"),
|
||||
new("id-2", "Audio Track", "https://example.com/audio.mp3", 120, 4_000_000, "mp3", LocalPath: @"C:\Downloads\audio.mp3")
|
||||
};
|
||||
|
||||
var m3uContent = SmartCollectionEngine.GenerateM3u8Playlist("My Smart Playlist", items);
|
||||
|
||||
Assert.StartsWith("#EXTM3U", m3uContent.Trim());
|
||||
Assert.Contains("#PLAYLIST:My Smart Playlist", m3uContent);
|
||||
Assert.Contains("#EXTINF:60,Short Video", m3uContent);
|
||||
Assert.Contains(@"C:\Downloads\video1.mp4", m3uContent);
|
||||
Assert.Contains("#EXTINF:120,Audio Track", m3uContent);
|
||||
Assert.Contains(@"C:\Downloads\audio.mp3", m3uContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate234_SmartCollectionEngine_HandlesEmptyAndInvalidDurations()
|
||||
{
|
||||
var items = new List<MediaItemMetadata>
|
||||
{
|
||||
new("id-unknown", "Live Stream Chunk", "https://live.com/stream", -1, 10_000, "ts", LocalPath: @"C:\Downloads\live.ts")
|
||||
};
|
||||
|
||||
var m3uContent = SmartCollectionEngine.GenerateM3u8Playlist("Live", items);
|
||||
|
||||
Assert.Contains("#EXTINF:-1,Live Stream Chunk", m3uContent);
|
||||
Assert.Contains(@"C:\Downloads\live.ts", m3uContent);
|
||||
|
||||
// Empty list
|
||||
var emptyM3u = SmartCollectionEngine.GenerateM3u8Playlist("Empty", Array.Empty<MediaItemMetadata>());
|
||||
Assert.StartsWith("#EXTM3U", emptyM3u.Trim());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Gate 211 ~ 214: 스마트 중복 다운로드 방지 및 파일명 충돌 해소 엔진 기계적 검증
|
||||
/// </summary>
|
||||
public class SmartDeduplicationAndCollisionGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate211_Deduplication_Identifies_Same_Video_Id()
|
||||
{
|
||||
var existingLibrary = new List<DeduplicationRecord>
|
||||
{
|
||||
new("dQw4w9WgXcQ", "Rick Astley - Never Gonna Give You Up.mp4", "/media/rick.mp4", 50_000_000)
|
||||
};
|
||||
|
||||
var engine = new MediaDeduplicationEngine(existingLibrary);
|
||||
|
||||
// 다른 URL 형식이지만 동일한 YouTube 비디오 ID
|
||||
var result = engine.CheckDuplicate("https://youtu.be/dQw4w9WgXcQ?si=trackingToken");
|
||||
|
||||
Assert.True(result.IsDuplicate);
|
||||
Assert.Equal("Rick Astley - Never Gonna Give You Up.mp4", result.ExistingFileName);
|
||||
Assert.True(result.CanReuseExisting);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate212_Deduplication_Allows_New_Video()
|
||||
{
|
||||
var existingLibrary = new List<DeduplicationRecord>
|
||||
{
|
||||
new("dQw4w9WgXcQ", "Rick.mp4", "/media/rick.mp4", 50_000_000)
|
||||
};
|
||||
|
||||
var engine = new MediaDeduplicationEngine(existingLibrary);
|
||||
var result = engine.CheckDuplicate("https://www.youtube.com/watch?v=newVideoId999");
|
||||
|
||||
Assert.False(result.IsDuplicate);
|
||||
Assert.Null(result.ExistingFileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate213_CollisionResolver_Generates_Numbered_Suffix()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "Paca_Collision_Test_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
// 원본 파일 생성
|
||||
var originalPath = Path.Combine(tempDir, "Nature_4K.mp4");
|
||||
File.WriteAllText(originalPath, "original");
|
||||
|
||||
// 1차 충돌 시 -> Nature_4K (1).mp4
|
||||
var safePath1 = FileCollisionResolver.ResolveSafePath(tempDir, "Nature_4K.mp4");
|
||||
Assert.Equal(Path.Combine(tempDir, "Nature_4K (1).mp4"), safePath1);
|
||||
|
||||
// Nature_4K (1).mp4도 생성해둠
|
||||
File.WriteAllText(safePath1, "existing (1)");
|
||||
|
||||
// 2차 충돌 시 -> Nature_4K (2).mp4
|
||||
var safePath2 = FileCollisionResolver.ResolveSafePath(tempDir, "Nature_4K.mp4");
|
||||
Assert.Equal(Path.Combine(tempDir, "Nature_4K (2).mp4"), safePath2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempDir)) Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate214_CollisionResolver_Preserves_When_No_Conflict()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "Paca_NoCollision_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
var safePath = FileCollisionResolver.ResolveSafePath(tempDir, "FreshVideo.mp4");
|
||||
Assert.Equal(Path.Combine(tempDir, "FreshVideo.mp4"), safePath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempDir)) Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
134
tests/Paca.Tests/Core/SmartMediaFilterEngineGateTests.cs
Normal file
134
tests/Paca.Tests/Core/SmartMediaFilterEngineGateTests.cs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class SmartMediaFilterEngineGateTests
|
||||
{
|
||||
private readonly List<MediaLibraryItem> _sampleLibrary = new()
|
||||
{
|
||||
new MediaLibraryItem(
|
||||
Id: "1",
|
||||
Title: "Epic 4K Wildlife Documentary",
|
||||
SourceUrl: "https://www.youtube.com/watch?v=wild4k",
|
||||
Domain: "youtube.com",
|
||||
DurationSeconds: 1800,
|
||||
FileSizeMb: 2400.0,
|
||||
ResolutionHeight: 2160,
|
||||
BitrateKbps: 15000,
|
||||
HasSubtitles: true,
|
||||
DownloadedAt: new DateTime(2026, 9, 4, 18, 0, 0)
|
||||
),
|
||||
new MediaLibraryItem(
|
||||
Id: "2",
|
||||
Title: "Funny Cat Dance #shorts",
|
||||
SourceUrl: "https://www.youtube.com/shorts/cat123",
|
||||
Domain: "youtube.com",
|
||||
DurationSeconds: 45,
|
||||
FileSizeMb: 15.0,
|
||||
ResolutionHeight: 1080,
|
||||
BitrateKbps: 3000,
|
||||
HasSubtitles: false,
|
||||
DownloadedAt: new DateTime(2026, 9, 4, 19, 30, 0)
|
||||
),
|
||||
new MediaLibraryItem(
|
||||
Id: "3",
|
||||
Title: "Symphony No. 9 in D Minor (FLAC Lossless)",
|
||||
SourceUrl: "https://soundcloud.com/classical/beethoven9",
|
||||
Domain: "soundcloud.com",
|
||||
DurationSeconds: 3900,
|
||||
FileSizeMb: 450.0,
|
||||
ResolutionHeight: 0, // Audio only
|
||||
BitrateKbps: 1411,
|
||||
HasSubtitles: false,
|
||||
DownloadedAt: new DateTime(2026, 9, 1, 10, 0, 0) // 3 days ago
|
||||
),
|
||||
new MediaLibraryItem(
|
||||
Id: "4",
|
||||
Title: "Instagram Reel Delicious Ramen Recipe",
|
||||
SourceUrl: "https://www.instagram.com/reel/ramen99",
|
||||
Domain: "instagram.com",
|
||||
DurationSeconds: 30,
|
||||
FileSizeMb: 8.5,
|
||||
ResolutionHeight: 720,
|
||||
BitrateKbps: 2000,
|
||||
HasSubtitles: true,
|
||||
DownloadedAt: new DateTime(2026, 9, 4, 15, 0, 0)
|
||||
)
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Gate305_SmartMediaFilterEngine_FiltersShortsAnd4K_Correctly()
|
||||
{
|
||||
var now = new DateTime(2026, 9, 4, 20, 0, 0);
|
||||
|
||||
// Filter Shorts (<= 60s)
|
||||
var shorts = SmartMediaFilterEngine.FilterAndSort(_sampleLibrary, SmartFilterPresets.ShortsAndReels, now);
|
||||
Assert.Equal(2, shorts.Count);
|
||||
Assert.All(shorts, x => Assert.True(x.DurationSeconds <= 60));
|
||||
|
||||
// Filter 4K (>= 2160p)
|
||||
var uhd = SmartMediaFilterEngine.FilterAndSort(_sampleLibrary, SmartFilterPresets.UltraHd4K, now);
|
||||
Assert.Single(uhd);
|
||||
Assert.Equal("1", uhd[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate306_SmartMediaFilterEngine_FiltersAudioOnlyHiRes()
|
||||
{
|
||||
var now = new DateTime(2026, 9, 4, 20, 0, 0);
|
||||
|
||||
var query = new SmartFilterQuery
|
||||
{
|
||||
IsAudioOnly = true,
|
||||
MinBitrateKbps = 320
|
||||
};
|
||||
|
||||
var audio = SmartMediaFilterEngine.FilterAndSort(_sampleLibrary, query, now);
|
||||
|
||||
Assert.Single(audio);
|
||||
Assert.Equal("3", audio[0].Id);
|
||||
Assert.Equal(0, audio[0].ResolutionHeight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate307_SmartMediaFilterEngine_FiltersSubtitlesAndKeywordMatch()
|
||||
{
|
||||
var now = new DateTime(2026, 9, 4, 20, 0, 0);
|
||||
|
||||
var query = new SmartFilterQuery
|
||||
{
|
||||
RequiresSubtitles = true,
|
||||
SearchKeyword = "Ramen"
|
||||
};
|
||||
|
||||
var result = SmartMediaFilterEngine.FilterAndSort(_sampleLibrary, query, now);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal("4", result[0].Id);
|
||||
Assert.True(result[0].HasSubtitles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate308_SmartMediaFilterEngine_FiltersRecentDownloads_AndSortsByFileSize()
|
||||
{
|
||||
var now = new DateTime(2026, 9, 4, 20, 0, 0);
|
||||
|
||||
var query = new SmartFilterQuery
|
||||
{
|
||||
DownloadedWithinHours = 24,
|
||||
SortBy = SmartMediaSortOrder.FileSizeDescending
|
||||
};
|
||||
|
||||
var result = SmartMediaFilterEngine.FilterAndSort(_sampleLibrary, query, now);
|
||||
|
||||
// Item 1, 2, 4 are within 24h. Item 3 is 3 days ago.
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal("1", result[0].Id); // 2400 MB
|
||||
Assert.Equal("2", result[1].Id); // 15 MB
|
||||
Assert.Equal("4", result[2].Id); // 8.5 MB
|
||||
}
|
||||
}
|
||||
100
tests/Paca.Tests/Core/SmartMediaNamingAndThumbnailGateTests.cs
Normal file
100
tests/Paca.Tests/Core/SmartMediaNamingAndThumbnailGateTests.cs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using Paca.Core.Config;
|
||||
using Paca.Core.Detection;
|
||||
using Paca.Core.Platform;
|
||||
using Paca.Core.Social;
|
||||
using Paca.Core.Util;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class SmartMediaNamingAndThumbnailGateTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("1a099dac52a596d88", true)]
|
||||
[InlineData("1a0984368a2930f5", true)]
|
||||
[InlineData("e3b0c44298fc1c149afbf4c8996fb924", true)]
|
||||
[InlineData("550e8400-e29b-41d4-a716-446655440000", true)]
|
||||
[InlineData("code-1_desktop-6d44c7cb5f84", true)]
|
||||
[InlineData("my_dog_playing", false)]
|
||||
[InlineData("trailer_2024_1080p", false)]
|
||||
[InlineData("lecture_part1", false)]
|
||||
[InlineData("video", false)]
|
||||
[InlineData("cat", false)]
|
||||
public void Gate_FileNameUtil_IsObfuscatedHash(string name, bool expected)
|
||||
{
|
||||
bool actual = FileNameUtil.IsObfuscatedHash(name);
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("[유머] 오늘의 귀여운 고양이 모음 | 루리웹 유머 게시판", "[유머] 오늘의 귀여운 고양이 모음")]
|
||||
[InlineData("[잡담] 질문 하나 드립니다. - 루리웹", "[잡담] 질문 하나 드립니다.")]
|
||||
[InlineData("Issue #42: Bug in video player · paca/paca · GitHub", "Issue #42: Bug in video player")]
|
||||
[InlineData("신규 기능 릴리즈 안내 - YouTube", "신규 기능 릴리즈 안내")]
|
||||
[InlineData("PACA - Media Browser", "PACA")]
|
||||
public void Gate_FileNameUtil_CleanWebTitle(string rawTitle, string expectedClean)
|
||||
{
|
||||
string actual = FileNameUtil.CleanWebTitle(rawTitle);
|
||||
Assert.Equal(expectedClean, actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate_FileNameUtil_DeriveSmartTitle_PrioritizesPageTitleForObfuscatedHash()
|
||||
{
|
||||
var mediaUri = new Uri("https://img.ruliweb.com/2024/09/13/1a099dac52a596d88.mp4");
|
||||
var pageTitle = "[유머] 오늘의 귀여운 고양이 모음 | 루리웹 유머 게시판";
|
||||
|
||||
// 단일 미디어
|
||||
var singleTitle = FileNameUtil.DeriveSmartTitle(pageTitle, mediaUri, index: 0);
|
||||
Assert.Equal("[유머] 오늘의 귀여운 고양이 모음", singleTitle);
|
||||
|
||||
// 복수 미디어 중 1번째, 2번째
|
||||
var multi1 = FileNameUtil.DeriveSmartTitle(pageTitle, mediaUri, index: 1);
|
||||
var multi2 = FileNameUtil.DeriveSmartTitle(pageTitle, mediaUri, index: 2);
|
||||
Assert.Equal("[유머] 오늘의 귀여운 고양이 모음 #1", multi1);
|
||||
Assert.Equal("[유머] 오늘의 귀여운 고양이 모음 #2", multi2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate_FileNameUtil_DeriveSmartTitle_KeepsMeaningfulFileNameWhenNoPageTitle()
|
||||
{
|
||||
var mediaUri = new Uri("https://example.com/assets/game_trailer_final.mp4");
|
||||
var title = FileNameUtil.DeriveSmartTitle(null, mediaUri, index: 0);
|
||||
Assert.Equal("game_trailer_final", title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate_DetectionContext_Supports_Thumbnail_And_Index()
|
||||
{
|
||||
var ctx = new DetectionContext(
|
||||
Url: "https://example.com/video.mp4",
|
||||
PageUrl: "https://example.com",
|
||||
Kind: MediaKind.Direct,
|
||||
ContentType: "video/mp4",
|
||||
Title: "Test Video",
|
||||
Tag: "Direct",
|
||||
ThumbnailUrl: "https://example.com/poster.jpg",
|
||||
MediaIndex: 2
|
||||
);
|
||||
|
||||
Assert.Equal("https://example.com/poster.jpg", ctx.ThumbnailUrl);
|
||||
Assert.Equal(2, ctx.MediaIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate_MediaMuxer_ResolveFfmpeg_Discovers_ToolsDir()
|
||||
{
|
||||
var muxer = DesktopMediaMuxer.Default;
|
||||
var config = new AppConfig(); // FfmpegPath 비워둠
|
||||
|
||||
var resolved = muxer.ResolveFfmpeg(config);
|
||||
// 사용자 PC의 AppData/Paca/tools/ffmpeg.exe 가 존재하므로 이를 자동 발견해야 함
|
||||
if (File.Exists(Path.Combine(YtDlpRunner.ToolsDir, OperatingSystem.IsWindows() ? "ffmpeg.exe" : "ffmpeg")))
|
||||
{
|
||||
Assert.NotNull(resolved);
|
||||
Assert.True(File.Exists(resolved));
|
||||
}
|
||||
}
|
||||
}
|
||||
75
tests/Paca.Tests/Core/SmartPriorityQueueGateTests.cs
Normal file
75
tests/Paca.Tests/Core/SmartPriorityQueueGateTests.cs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Paca.Mobile.Core;
|
||||
using Paca.Tests.Infrastructure;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Gate 203 ~ 206: 스마트 대기열 우선순위 큐 및 동적 동시성 할당 엔진 기계적 검증
|
||||
/// </summary>
|
||||
public class SmartPriorityQueueGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate203_SmartPriorityQueue_Orders_By_Priority_Then_Time()
|
||||
{
|
||||
var queue = new SmartPriorityQueueManager();
|
||||
|
||||
queue.Enqueue("job-1", "Normal Job 1", DownloadPriorityLevel.Normal, DateTimeOffset.UtcNow.AddMinutes(-5));
|
||||
queue.Enqueue("job-2", "Low Job", DownloadPriorityLevel.Low, DateTimeOffset.UtcNow.AddMinutes(-4));
|
||||
queue.Enqueue("job-3", "Urgent Job", DownloadPriorityLevel.Urgent, DateTimeOffset.UtcNow); // 가장 나중에 추가됨
|
||||
|
||||
var list = queue.GetScheduledJobs();
|
||||
|
||||
Assert.Equal("job-3", list[0].Id); // Urgent가 최상단
|
||||
Assert.Equal("job-1", list[1].Id); // Normal
|
||||
Assert.Equal("job-2", list[2].Id); // Low
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate204_SmartPriorityQueue_BumpToTop()
|
||||
{
|
||||
var queue = new SmartPriorityQueueManager();
|
||||
|
||||
queue.Enqueue("job-1", "Job 1", DownloadPriorityLevel.Normal);
|
||||
queue.Enqueue("job-2", "Job 2", DownloadPriorityLevel.Normal);
|
||||
queue.Enqueue("job-3", "Job 3", DownloadPriorityLevel.Normal);
|
||||
|
||||
// job-3을 맨 위로 올리기(Bump)
|
||||
var bumped = queue.BumpToTop("job-3");
|
||||
Assert.True(bumped);
|
||||
|
||||
var list = queue.GetScheduledJobs();
|
||||
Assert.Equal("job-3", list[0].Id);
|
||||
Assert.Equal(DownloadPriorityLevel.Urgent, list[0].Priority);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(NetworkProfileType.WiFi, 4)]
|
||||
[InlineData(NetworkProfileType.Cellular5G, 3)]
|
||||
[InlineData(NetworkProfileType.CellularLTE, 2)]
|
||||
[InlineData(NetworkProfileType.Cellular3G, 1)]
|
||||
public void Gate205_DynamicConcurrencyAllocator_Adjusts_By_Network(NetworkProfileType network, int expectedConcurrency)
|
||||
{
|
||||
var concurrency = DynamicConcurrencyAllocator.DetermineMaxConcurrentDownloads(network, isBatterySavingMode: false);
|
||||
Assert.Equal(expectedConcurrency, concurrency);
|
||||
|
||||
// 배터리 절약 모드 켜지면 무조건 1개로 제한
|
||||
var batterySavingConcurrency = DynamicConcurrencyAllocator.DetermineMaxConcurrentDownloads(network, isBatterySavingMode: true);
|
||||
Assert.Equal(1, batterySavingConcurrency);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate206_ServerHost_QueuePriority_Endpoint_Audit()
|
||||
{
|
||||
var serverHostPath = Path.Combine(SourceTree.Root, "src", "Paca.Server", "ServerHost.cs");
|
||||
Assert.True(File.Exists(serverHostPath), "ServerHost.cs must exist");
|
||||
|
||||
var code = File.ReadAllText(serverHostPath);
|
||||
|
||||
Assert.Contains("/api/queue/{id}/priority", code);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Gate 199 ~ 202: 오디오 파형 시각화 및 스마트 무음 건너뛰기(Smart Silence Skipper) 기계적 검증
|
||||
/// </summary>
|
||||
public class SmartSilenceSkipperAndWaveformGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate199_AudioWaveform_Downsampling_And_Peaks()
|
||||
{
|
||||
// 100개의 가상 PCM 오디오 샘플 (0.0 ~ 1.0)
|
||||
var samples = new float[100];
|
||||
for (int i = 0; i < 50; i++) samples[i] = 0.01f; // 전반부 무음
|
||||
for (int i = 50; i < 100; i++) samples[i] = 0.8f; // 후반부 강한 음성
|
||||
|
||||
var peaks = AudioWaveformGenerator.GeneratePeaks(samples, targetBarCount: 10);
|
||||
|
||||
Assert.Equal(10, peaks.Count);
|
||||
// 전반부 피크는 낮고
|
||||
Assert.True(peaks[0] < 0.1f);
|
||||
// 후반부 피크는 높음
|
||||
Assert.True(peaks[9] > 0.7f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate200_SmartSilenceSkipper_Detects_Silence_Interval()
|
||||
{
|
||||
var intervals = new List<AudioInterval>
|
||||
{
|
||||
new(TimeSpan.Zero, TimeSpan.FromSeconds(10), IsSilence: false),
|
||||
new(TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(15), IsSilence: true), // 5초 무음
|
||||
new(TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(30), IsSilence: false)
|
||||
};
|
||||
|
||||
var skipper = new SmartSilenceSkipper(intervals);
|
||||
|
||||
// 현재 11초 위치(무음 구간) -> 15초(다음 음성)로 즉각 스킵 제안
|
||||
var skipTarget = skipper.GetNextSpeechPosition(TimeSpan.FromSeconds(11));
|
||||
Assert.Equal(TimeSpan.FromSeconds(15), skipTarget);
|
||||
|
||||
// 현재 5초 위치(음성 구간) -> 스킵 불필요(원위치 유지)
|
||||
var normalTarget = skipper.GetNextSpeechPosition(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(TimeSpan.FromSeconds(5), normalTarget);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate201_SmartSilenceSkipper_Accumulates_SavedTime()
|
||||
{
|
||||
var intervals = new List<AudioInterval>
|
||||
{
|
||||
new(TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(15), IsSilence: true), // 5초
|
||||
new(TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(38), IsSilence: true) // 8초
|
||||
};
|
||||
|
||||
var skipper = new SmartSilenceSkipper(intervals);
|
||||
|
||||
skipper.RecordSkip(TimeSpan.FromSeconds(5));
|
||||
skipper.RecordSkip(TimeSpan.FromSeconds(8));
|
||||
|
||||
Assert.Equal(TimeSpan.FromSeconds(13), skipper.TotalSavedTime);
|
||||
Assert.Equal("13초 절약됨", skipper.FormatSavedTime());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate202_AudioWaveform_Serialization_Integrity()
|
||||
{
|
||||
var original = new AudioWaveformData(new float[] { 0.1f, 0.5f, 0.9f, 0.3f });
|
||||
var encoded = original.ToBase64String();
|
||||
var restored = AudioWaveformData.FromBase64String(encoded);
|
||||
|
||||
Assert.Equal(original.Peaks.Count, restored.Peaks.Count);
|
||||
for (int i = 0; i < original.Peaks.Count; i++)
|
||||
{
|
||||
Assert.Equal(original.Peaks[i], restored.Peaks[i], precision: 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class StorageQuotaEvictionPlannerGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate437_StorageQuotaEvictionPlanner_CalculatesRequiredSpace_Correctly()
|
||||
{
|
||||
// 5GB free, 8GB requested, 1GB headroom -> 4GB deficit
|
||||
long deficit = StorageQuotaEvictionPlanner.CalculateDeficit(
|
||||
freeSpaceBytes: 5L * 1024 * 1024 * 1024,
|
||||
requestedBytes: 8L * 1024 * 1024 * 1024,
|
||||
headroomBytes: 1L * 1024 * 1024 * 1024
|
||||
);
|
||||
Assert.Equal(4L * 1024 * 1024 * 1024, deficit);
|
||||
|
||||
// 10GB free, 8GB requested, 1GB headroom -> 0 deficit
|
||||
long noDeficit = StorageQuotaEvictionPlanner.CalculateDeficit(
|
||||
freeSpaceBytes: 10L * 1024 * 1024 * 1024,
|
||||
requestedBytes: 8L * 1024 * 1024 * 1024,
|
||||
headroomBytes: 1L * 1024 * 1024 * 1024
|
||||
);
|
||||
Assert.Equal(0, noDeficit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate438_StorageQuotaEvictionPlanner_EvictsByLru_WhilePreservingPinned()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var candidates = new List<EvictionCandidate>
|
||||
{
|
||||
new("file1.mp4", 1000, now.AddDays(-10), IsPinned: true), // pinned!
|
||||
new("file2.mp4", 1000, now.AddDays(-5), IsPinned: false),
|
||||
new("file3.mp4", 1000, now.AddDays(-20), IsPinned: false), // oldest unpinned!
|
||||
new("file4.mp4", 1000, now.AddDays(-1), IsPinned: false)
|
||||
};
|
||||
|
||||
var plan = StorageQuotaEvictionPlanner.PlanEviction(candidates, deficitBytes: 1500, EvictionStrategy.LRU);
|
||||
|
||||
Assert.True(plan.IsSatisfiable);
|
||||
Assert.Equal(2, plan.FilesToEvict.Count);
|
||||
Assert.Equal("file3.mp4", plan.FilesToEvict[0].FilePath); // oldest
|
||||
Assert.Equal("file2.mp4", plan.FilesToEvict[1].FilePath); // second oldest
|
||||
Assert.DoesNotContain(plan.FilesToEvict, f => f.FilePath == "file1.mp4"); // preserved!
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate439_StorageQuotaEvictionPlanner_EvictsByLargestFirst()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var candidates = new List<EvictionCandidate>
|
||||
{
|
||||
new("small.mp4", 500, now, false),
|
||||
new("huge.mp4", 3000, now, false),
|
||||
new("medium.mp4", 1000, now, false)
|
||||
};
|
||||
|
||||
var plan = StorageQuotaEvictionPlanner.PlanEviction(candidates, deficitBytes: 2500, EvictionStrategy.LargestFirst);
|
||||
|
||||
Assert.True(plan.IsSatisfiable);
|
||||
Assert.Single(plan.FilesToEvict);
|
||||
Assert.Equal("huge.mp4", plan.FilesToEvict[0].FilePath);
|
||||
Assert.Equal(3000, plan.TotalFreedBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate440_StorageQuotaEvictionPlanner_DeterminesFeasibility()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var candidates = new List<EvictionCandidate>
|
||||
{
|
||||
new("file1.mp4", 1000, now, false)
|
||||
};
|
||||
|
||||
var plan = StorageQuotaEvictionPlanner.PlanEviction(candidates, deficitBytes: 5000, EvictionStrategy.LRU);
|
||||
|
||||
Assert.False(plan.IsSatisfiable);
|
||||
Assert.Equal(1000, plan.TotalFreedBytes);
|
||||
}
|
||||
}
|
||||
51
tests/Paca.Tests/Core/StreamHighlightClipperGateTests.cs
Normal file
51
tests/Paca.Tests/Core/StreamHighlightClipperGateTests.cs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
using System;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class StreamHighlightClipperGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate281_StreamHighlightClipper_BuildsLosslessCutArgs()
|
||||
{
|
||||
var args = StreamHighlightClipper.BuildFfmpegArgs("input.mp4", "output_clip.mp4", startSec: 10.5, durationSec: 30.0, ClipOutputFormat.Mp4Lossless);
|
||||
|
||||
Assert.Contains("-ss 10.5", args);
|
||||
Assert.Contains("-t 30", args);
|
||||
Assert.Contains("-c copy", args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate282_StreamHighlightClipper_BuildsHighQualityGifArgs()
|
||||
{
|
||||
var args = StreamHighlightClipper.BuildFfmpegArgs("input.mp4", "output.gif", startSec: 5.0, durationSec: 8.0, ClipOutputFormat.AnimatedGif);
|
||||
|
||||
Assert.Contains("-ss 5", args);
|
||||
Assert.Contains("-t 8", args);
|
||||
Assert.Contains("palettegen", args);
|
||||
Assert.Contains("paletteuse", args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate283_StreamHighlightClipper_ValidatesClipBounds()
|
||||
{
|
||||
// Negative start clamped to 0
|
||||
var args = StreamHighlightClipper.BuildFfmpegArgs("input.mp4", "out.mp4", startSec: -5.0, durationSec: 10.0, ClipOutputFormat.Mp4Lossless);
|
||||
Assert.Contains("-ss 0", args);
|
||||
|
||||
// Zero or negative duration throws
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
StreamHighlightClipper.BuildFfmpegArgs("input.mp4", "out.mp4", startSec: 10, durationSec: 0, ClipOutputFormat.Mp4Lossless));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate284_StreamHighlightClipper_CalculatesOutputName()
|
||||
{
|
||||
var outMp4 = StreamHighlightClipper.GenerateOutputFileName("MyStream.mp4", startSec: 45.0, durationSec: 15.0, ClipOutputFormat.Mp4Lossless);
|
||||
Assert.Equal("MyStream_clip_45s_15s.mp4", outMp4);
|
||||
|
||||
var outGif = StreamHighlightClipper.GenerateOutputFileName("MyStream.mp4", startSec: 120.0, durationSec: 5.0, ClipOutputFormat.AnimatedGif);
|
||||
Assert.Equal("MyStream_clip_120s_5s.gif", outGif);
|
||||
}
|
||||
}
|
||||
68
tests/Paca.Tests/Core/SubtitleBurnInEngineGateTests.cs
Normal file
68
tests/Paca.Tests/Core/SubtitleBurnInEngineGateTests.cs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
using System;
|
||||
using Paca.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class SubtitleBurnInEngineGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate357_SubtitleBurnInEngine_BuildsForceStyleString_Correctly()
|
||||
{
|
||||
var style = new SubtitleBurnInStyle
|
||||
{
|
||||
FontName = "Arial",
|
||||
FontSize = 24,
|
||||
PrimaryColor = "&H00FFFFFF",
|
||||
OutlineColor = "&H00000000",
|
||||
OutlineWidth = 2,
|
||||
MarginV = 40,
|
||||
Alignment = 2 // Bottom center
|
||||
};
|
||||
|
||||
var forceStyle = SubtitleBurnInEngine.BuildForceStyleString(style);
|
||||
|
||||
Assert.Contains("FontName=Arial", forceStyle);
|
||||
Assert.Contains("FontSize=24", forceStyle);
|
||||
Assert.Contains("PrimaryColour=&H00FFFFFF", forceStyle);
|
||||
Assert.Contains("Outline=2", forceStyle);
|
||||
Assert.Contains("MarginV=40", forceStyle);
|
||||
Assert.Contains("Alignment=2", forceStyle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate358_SubtitleBurnInEngine_EscapesWindowsPath_ForFfmpegFilter()
|
||||
{
|
||||
var winPath = "C:\\subtitles\\video_track.srt";
|
||||
var escaped = SubtitleBurnInEngine.EscapeSubtitlePathForFfmpeg(winPath);
|
||||
|
||||
// In ffmpeg filtergraph, colon must be escaped as '\:' and backslashes replaced with forward slashes
|
||||
Assert.Equal("C\\:/subtitles/video_track.srt", escaped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate359_SubtitleBurnInEngine_ProvidesDefaultReadableStyle()
|
||||
{
|
||||
var def = SubtitleBurnInStyle.Default;
|
||||
|
||||
Assert.Equal(22, def.FontSize);
|
||||
Assert.Equal(2, def.Alignment); // Bottom center
|
||||
Assert.True(def.OutlineWidth >= 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate360_SubtitleBurnInEngine_BuildsFullCommandLine_WithAudioCopy()
|
||||
{
|
||||
var cmd = SubtitleBurnInEngine.BuildBurnInCommandLine(
|
||||
inputVideoPath: "C:\\videos\\clip.mp4",
|
||||
subtitlePath: "C:\\videos\\clip.srt",
|
||||
outputVideoPath: "C:\\videos\\clip_subbed.mp4"
|
||||
);
|
||||
|
||||
Assert.Contains("-i \"C:\\videos\\clip.mp4\"", cmd);
|
||||
Assert.Contains("-vf \"subtitles='C\\:/videos/clip.srt'", cmd);
|
||||
Assert.Contains("force_style=", cmd);
|
||||
Assert.Contains("-c:v libx264 -crf 20 -c:a copy", cmd);
|
||||
Assert.EndsWith("\"C:\\videos\\clip_subbed.mp4\" -y", cmd);
|
||||
}
|
||||
}
|
||||
63
tests/Paca.Tests/Core/SubtitleFormatConverterGateTests.cs
Normal file
63
tests/Paca.Tests/Core/SubtitleFormatConverterGateTests.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
using System;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class SubtitleFormatConverterGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate345_SubtitleFormatConverter_ConvertsSrtToVtt_Correctly()
|
||||
{
|
||||
var srt = @"1
|
||||
00:00:01,234 --> 00:00:04,567
|
||||
Hello world!
|
||||
|
||||
2
|
||||
00:00:05,000 --> 00:00:08,100
|
||||
Welcome to Paca.";
|
||||
|
||||
var vtt = SubtitleFormatConverter.ConvertSrtToVtt(srt);
|
||||
|
||||
Assert.StartsWith("WEBVTT", vtt.TrimStart());
|
||||
Assert.Contains("00:00:01.234 --> 00:00:04.567", vtt);
|
||||
Assert.Contains("00:00:05.000 --> 00:00:08.100", vtt);
|
||||
Assert.DoesNotContain(",234", vtt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate346_SubtitleFormatConverter_ConvertsVttToSrt_Correctly()
|
||||
{
|
||||
var vtt = @"WEBVTT
|
||||
|
||||
00:00:01.234 --> 00:00:04.567
|
||||
Hello world!
|
||||
|
||||
00:00:05.000 --> 00:00:08.100
|
||||
Welcome to Paca.";
|
||||
|
||||
var srt = SubtitleFormatConverter.ConvertVttToSrt(vtt);
|
||||
|
||||
Assert.DoesNotContain("WEBVTT", srt);
|
||||
Assert.Contains("1\n00:00:01,234 --> 00:00:04,567", srt.Replace("\r\n", "\n"));
|
||||
Assert.Contains("00:00:01,234 --> 00:00:04,567", srt);
|
||||
Assert.Contains("00:00:05,000 --> 00:00:08,100", srt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate347_SubtitleFormatConverter_StripsFormattingTags_Cleanly()
|
||||
{
|
||||
var formatted = "<b>Hello</b> <i><font color=\"#ff0000\">World</font></i> & enjoy!";
|
||||
var clean = SubtitleFormatConverter.StripFormattingTags(formatted);
|
||||
|
||||
Assert.Equal("Hello World & enjoy!", clean);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate348_SubtitleFormatConverter_HandlesEmptyAndWhitespaceGracefully()
|
||||
{
|
||||
Assert.Equal(string.Empty, SubtitleFormatConverter.ConvertSrtToVtt(string.Empty));
|
||||
Assert.Equal(string.Empty, SubtitleFormatConverter.ConvertVttToSrt(string.Empty));
|
||||
Assert.Equal(string.Empty, SubtitleFormatConverter.StripFormattingTags(string.Empty));
|
||||
}
|
||||
}
|
||||
64
tests/Paca.Tests/Core/SubtitleSyncDrifterGateTests.cs
Normal file
64
tests/Paca.Tests/Core/SubtitleSyncDrifterGateTests.cs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
using System;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class SubtitleSyncDrifterGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate433_SubtitleSyncDrifter_ShiftsConstantOffset_Accurately()
|
||||
{
|
||||
var srt = @"1
|
||||
00:01:20,000 --> 00:01:25,500
|
||||
Hello World";
|
||||
|
||||
var shifted = SubtitleSyncDrifter.ShiftOffsetSrt(srt, shiftSeconds: 2.5);
|
||||
|
||||
Assert.Contains("00:01:22,500 --> 00:01:28,000", shifted);
|
||||
Assert.Contains("Hello World", shifted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate434_SubtitleSyncDrifter_PreventsNegativeTimestamps()
|
||||
{
|
||||
var srt = @"1
|
||||
00:00:05,000 --> 00:00:08,000
|
||||
Early cue";
|
||||
|
||||
var shifted = SubtitleSyncDrifter.ShiftOffsetSrt(srt, shiftSeconds: -10.0);
|
||||
|
||||
Assert.Contains("00:00:00,000 --> 00:00:00,000", shifted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate435_SubtitleSyncDrifter_RescalesTimeByFpsRatio()
|
||||
{
|
||||
var srt = @"1
|
||||
00:01:40,000 --> 00:01:50,000
|
||||
Pacing test"; // 100s to 110s
|
||||
|
||||
// Speedup by 10% (speed factor 1.10 -> duration scales by 1/1.10 = 0.90909)
|
||||
var rescaled = SubtitleSyncDrifter.RescaleTimeSrt(srt, speedFactor: 1.10);
|
||||
|
||||
// 100 / 1.10 = 90.909s = 00:01:30,909
|
||||
Assert.Contains("00:01:30,909", rescaled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate436_SubtitleSyncDrifter_ResyncsByTwoAnchorPoints()
|
||||
{
|
||||
// Anchor 1: at 100s, video speech is at 105s (+5s)
|
||||
// Anchor 2: at 1000s, video speech is at 1050s (+50s)
|
||||
// m = (1050 - 105) / (1000 - 100) = 945 / 900 = 1.05
|
||||
// b = 105 - (1.05 * 100) = 0
|
||||
var srt = @"1
|
||||
00:08:20,000 --> 00:08:25,000
|
||||
Middle dialogue"; // 500s -> 500 * 1.05 = 525s = 00:08:45,000
|
||||
|
||||
var resynced = SubtitleSyncDrifter.ResyncByAnchorsSrt(srt, orig1: 100.0, target1: 105.0, orig2: 1000.0, target2: 1050.0);
|
||||
|
||||
Assert.Contains("00:08:45,000", resynced);
|
||||
Assert.Contains("Middle dialogue", resynced);
|
||||
}
|
||||
}
|
||||
88
tests/Paca.Tests/Core/SubtitleSyncEngineGateTests.cs
Normal file
88
tests/Paca.Tests/Core/SubtitleSyncEngineGateTests.cs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class SubtitleSyncEngineGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate261_SubtitleSyncEngine_DetectsSubtitleFiles_InMediaDirectory()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "paca_sub_test_" + Guid.NewGuid());
|
||||
Directory.CreateDirectory(tempDir);
|
||||
try
|
||||
{
|
||||
var videoPath = Path.Combine(tempDir, "video1.mp4");
|
||||
File.WriteAllText(videoPath, "dummy video");
|
||||
File.WriteAllText(Path.Combine(tempDir, "video1.ko.srt"), "1\n00:00:01,000 --> 00:00:04,000\n안녕하세요");
|
||||
File.WriteAllText(Path.Combine(tempDir, "video1.en.vtt"), "WEBVTT\n\n00:00:01.000 --> 00:00:04.000\nHello");
|
||||
File.WriteAllText(Path.Combine(tempDir, "other.srt"), "1\n00:00:01,000 --> 00:00:04,000\nOther");
|
||||
|
||||
var tracks = SubtitleSyncEngine.FindAssociatedSubtitles(videoPath);
|
||||
|
||||
Assert.Equal(2, tracks.Count);
|
||||
Assert.Contains(tracks, t => t.LanguageCode == "ko" && t.Format == "srt");
|
||||
Assert.Contains(tracks, t => t.LanguageCode == "en" && t.Format == "vtt");
|
||||
Assert.DoesNotContain(tracks, t => t.FileName.Contains("other"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(tempDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate262_SubtitleSyncEngine_ParsesVttAndSrtCues()
|
||||
{
|
||||
var srtContent = @"1
|
||||
00:01:10,500 --> 00:01:14,200
|
||||
안녕하세요 반갑습니다
|
||||
|
||||
2
|
||||
00:01:15,000 --> 00:01:18,500
|
||||
다음 대사입니다";
|
||||
|
||||
var cues = SubtitleSyncEngine.ParseCues(srtContent, "srt");
|
||||
|
||||
Assert.Equal(2, cues.Count);
|
||||
Assert.Equal(70.5, cues[0].StartSec);
|
||||
Assert.Equal(74.2, cues[0].EndSec);
|
||||
Assert.Equal("안녕하세요 반갑습니다", cues[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate263_SubtitleSyncEngine_FindsActiveCue_AtCurrentPosition()
|
||||
{
|
||||
var cues = new List<SubtitleCue>
|
||||
{
|
||||
new(10.0, 15.0, "Active Subtitle Text")
|
||||
};
|
||||
|
||||
var active12 = SubtitleSyncEngine.GetActiveCue(cues, currentPosSec: 12.5);
|
||||
Assert.NotNull(active12);
|
||||
Assert.Equal("Active Subtitle Text", active12!.Text);
|
||||
|
||||
var active20 = SubtitleSyncEngine.GetActiveCue(cues, currentPosSec: 20.0);
|
||||
Assert.Null(active20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate264_SubtitleSyncEngine_AppliesTimeOffsetAdjustment()
|
||||
{
|
||||
var cues = new List<SubtitleCue>
|
||||
{
|
||||
new(10.0, 15.0, "Adjusted Subtitle")
|
||||
};
|
||||
|
||||
// Offset +2.0s means original 10.0s now displays at 12.0s to 17.0s
|
||||
var active11 = SubtitleSyncEngine.GetActiveCue(cues, currentPosSec: 11.0, offsetSec: 2.0);
|
||||
Assert.Null(active11); // Not active yet at 11s
|
||||
|
||||
var active13 = SubtitleSyncEngine.GetActiveCue(cues, currentPosSec: 13.0, offsetSec: 2.0);
|
||||
Assert.NotNull(active13);
|
||||
Assert.Equal("Adjusted Subtitle", active13!.Text);
|
||||
}
|
||||
}
|
||||
61
tests/Paca.Tests/Core/ThumbnailScrubberPreviewGateTests.cs
Normal file
61
tests/Paca.Tests/Core/ThumbnailScrubberPreviewGateTests.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
using System;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class ThumbnailScrubberPreviewGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate241_ThumbnailScrubberPreview_CalculatesTimestamp_AndFormatsProperly()
|
||||
{
|
||||
Assert.Equal("00:45", ThumbnailScrubberPreview.FormatTimestamp(45.2));
|
||||
Assert.Equal("01:01:05", ThumbnailScrubberPreview.FormatTimestamp(3665.0));
|
||||
Assert.Equal("00:00", ThumbnailScrubberPreview.FormatTimestamp(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate242_ThumbnailScrubberPreview_CalculatesThumbnailUrl_WithSeekQuery()
|
||||
{
|
||||
var preview = new ThumbnailScrubberPreview("media-123", totalDurationSec: 200.0);
|
||||
var url = preview.GetPreviewUrlForRatio(0.5); // 100s
|
||||
|
||||
Assert.Contains("/api/library/media-123/thumb", url);
|
||||
Assert.Contains("t=100", url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate243_ThumbnailScrubberPreview_ClampsSeekPositionWithinBounds()
|
||||
{
|
||||
var preview = new ThumbnailScrubberPreview("media-999", totalDurationSec: 100.0);
|
||||
|
||||
// Underflow clamp
|
||||
var underflowTime = preview.GetClampedSeekTime(-0.1);
|
||||
Assert.Equal(0.0, underflowTime);
|
||||
|
||||
// Overflow clamp
|
||||
var overflowTime = preview.GetClampedSeekTime(1.5);
|
||||
Assert.Equal(100.0, overflowTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate244_ThumbnailScrubberPreview_LruCache_EvictsOldestWhenLimitReached()
|
||||
{
|
||||
var cache = new ScrubberPreviewCache(capacity: 3);
|
||||
|
||||
cache.Put("t=10", new byte[] { 1 });
|
||||
cache.Put("t=20", new byte[] { 2 });
|
||||
cache.Put("t=30", new byte[] { 3 });
|
||||
|
||||
Assert.True(cache.TryGet("t=10", out _));
|
||||
Assert.True(cache.TryGet("t=20", out _));
|
||||
Assert.True(cache.TryGet("t=30", out _));
|
||||
|
||||
// Add 4th item, oldest accessed was t=10 before t=20/t=30, now add t=40
|
||||
cache.Put("t=40", new byte[] { 4 });
|
||||
|
||||
Assert.Equal(3, cache.Count);
|
||||
// t=40 is in cache
|
||||
Assert.True(cache.TryGet("t=40", out _));
|
||||
}
|
||||
}
|
||||
46
tests/Paca.Tests/Core/TranscodingPresetEngineGateTests.cs
Normal file
46
tests/Paca.Tests/Core/TranscodingPresetEngineGateTests.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
using System;
|
||||
using Paca.Mobile.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class TranscodingPresetEngineGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate245_TranscodingPresetEngine_BuildsRemuxArguments_WhenCompatible()
|
||||
{
|
||||
var args = TranscodingPresetEngine.BuildFfmpegArgs("input.mkv", "output.mp4", TranscodePreset.FastRemux);
|
||||
|
||||
Assert.Contains("-c:v copy", args);
|
||||
Assert.Contains("-c:a copy", args);
|
||||
Assert.Contains("-movflags +faststart", args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate246_TranscodingPresetEngine_BuildsMobileCompatibilityArguments()
|
||||
{
|
||||
var args = TranscodingPresetEngine.BuildFfmpegArgs("input.webm", "output.mp4", TranscodePreset.MobileCompatibleH264);
|
||||
|
||||
Assert.Contains("-c:v libx264", args);
|
||||
Assert.Contains("-c:a aac", args);
|
||||
Assert.Contains("-movflags +faststart", args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate247_TranscodingPresetEngine_BuildsAudioExtractionArguments()
|
||||
{
|
||||
var args = TranscodingPresetEngine.BuildFfmpegArgs("video.mp4", "audio.mp3", TranscodePreset.AudioMp3High);
|
||||
|
||||
Assert.Contains("-vn", args);
|
||||
Assert.Contains("-c:a libmp3lame", args);
|
||||
Assert.Contains("-b:a 320k", args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate248_TranscodingPresetEngine_EstimatesOutputFilename()
|
||||
{
|
||||
Assert.Equal("clip.mp3", TranscodingPresetEngine.GetTargetFileName("clip.mkv", TranscodePreset.AudioMp3High));
|
||||
Assert.Equal("lecture.mp4", TranscodingPresetEngine.GetTargetFileName("lecture.webm", TranscodePreset.MobileCompatibleH264));
|
||||
Assert.Equal("video.mp4", TranscodingPresetEngine.GetTargetFileName("video.mkv", TranscodePreset.FastRemux));
|
||||
}
|
||||
}
|
||||
52
tests/Paca.Tests/Core/VideoAspectAutoCropperGateTests.cs
Normal file
52
tests/Paca.Tests/Core/VideoAspectAutoCropperGateTests.cs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
using System;
|
||||
using Paca.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class VideoAspectAutoCropperGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate337_VideoAspectAutoCropper_BuildsCenterCropFilter()
|
||||
{
|
||||
var filter = VideoAspectAutoCropper.BuildReframingFilterArgs(ReframingMode.CenterCrop, 1080, 1920);
|
||||
|
||||
Assert.Contains("crop=ih*9/16:ih", filter);
|
||||
Assert.Contains("scale=1080:1920", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate338_VideoAspectAutoCropper_BuildsBlurredBackgroundFilter()
|
||||
{
|
||||
var filter = VideoAspectAutoCropper.BuildReframingFilterArgs(ReframingMode.BlurredBackground, 1080, 1920);
|
||||
|
||||
Assert.Contains("split[bg][fg]", filter);
|
||||
Assert.Contains("boxblur", filter);
|
||||
Assert.Contains("overlay=(W-w)/2:(H-h)/2", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate339_VideoAspectAutoCropper_BuildsBlackPadFilter()
|
||||
{
|
||||
var filter = VideoAspectAutoCropper.BuildReframingFilterArgs(ReframingMode.BlackPad, 1080, 1920);
|
||||
|
||||
Assert.Contains("pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate340_VideoAspectAutoCropper_BuildsFullCommandLine_WithAudioCopy()
|
||||
{
|
||||
var cmd = VideoAspectAutoCropper.BuildReframingCommandLine(
|
||||
inputPath: "C:\\videos\\horizontal.mp4",
|
||||
outputPath: "C:\\videos\\vertical_shorts.mp4",
|
||||
mode: ReframingMode.CenterCrop,
|
||||
targetWidth: 1080,
|
||||
targetHeight: 1920
|
||||
);
|
||||
|
||||
Assert.Contains("-i \"C:\\videos\\horizontal.mp4\"", cmd);
|
||||
Assert.Contains("-c:v libx264 -crf 20 -preset fast", cmd);
|
||||
Assert.Contains("-c:a copy", cmd);
|
||||
Assert.EndsWith("\"C:\\videos\\vertical_shorts.mp4\" -y", cmd);
|
||||
}
|
||||
}
|
||||
49
tests/Paca.Tests/Core/VideoDenoiseEnhancerGateTests.cs
Normal file
49
tests/Paca.Tests/Core/VideoDenoiseEnhancerGateTests.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
using System;
|
||||
using Paca.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class VideoDenoiseEnhancerGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate401_VideoDenoiseEnhancer_BuildsFastHqdn3dFilter()
|
||||
{
|
||||
var filter = VideoDenoiseEnhancer.BuildEnhanceFilterChain(denoise: true, sharpen: false, DenoisePreset.FastTemporalSpatial);
|
||||
|
||||
Assert.Equal("hqdn3d=4:3:6:4.5", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate402_VideoDenoiseEnhancer_BuildsHighQualityNlmeansFilter()
|
||||
{
|
||||
var filter = VideoDenoiseEnhancer.BuildEnhanceFilterChain(denoise: true, sharpen: false, DenoisePreset.HighQualityNonLocalMeans);
|
||||
|
||||
Assert.Contains("nlmeans=s=1.0:p=7:r=15", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate403_VideoDenoiseEnhancer_ChainsDenoiseAndSharpenFilters()
|
||||
{
|
||||
var filter = VideoDenoiseEnhancer.BuildEnhanceFilterChain(denoise: true, sharpen: true, DenoisePreset.FastTemporalSpatial);
|
||||
|
||||
Assert.Contains("hqdn3d=4:3:6:4.5", filter);
|
||||
Assert.Contains("unsharp=5:5:0.8:5:5:0.4", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate404_VideoDenoiseEnhancer_BuildsFullCommandLine_WithAudioCopy()
|
||||
{
|
||||
var cmd = VideoDenoiseEnhancer.BuildDenoiseCommandLine(
|
||||
inputPath: "C:\\videos\\grainy.mp4",
|
||||
outputPath: "C:\\videos\\enhanced.mp4",
|
||||
denoise: true,
|
||||
sharpen: true
|
||||
);
|
||||
|
||||
Assert.Contains("-i \"C:\\videos\\grainy.mp4\"", cmd);
|
||||
Assert.Contains("-vf \"hqdn3d=4:3:6:4.5,unsharp=5:5:0.8:5:5:0.4\"", cmd);
|
||||
Assert.Contains("-c:v libx264 -crf 20 -preset fast -c:a copy", cmd);
|
||||
Assert.EndsWith("\"C:\\videos\\enhanced.mp4\" -y", cmd);
|
||||
}
|
||||
}
|
||||
44
tests/Paca.Tests/Core/VideoFrameRateConverterGateTests.cs
Normal file
44
tests/Paca.Tests/Core/VideoFrameRateConverterGateTests.cs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
using System;
|
||||
using Paca.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class VideoFrameRateConverterGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate421_VideoFrameRateConverter_BuildsSimpleFpsFilter()
|
||||
{
|
||||
var filter = VideoFrameRateConverter.BuildFpsFilter(60, FrameRateConversionMode.Simple);
|
||||
Assert.Equal("fps=60", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate422_VideoFrameRateConverter_BuildsMotionInterpolationFilter()
|
||||
{
|
||||
var filter = VideoFrameRateConverter.BuildFpsFilter(60, FrameRateConversionMode.MotionCompensated);
|
||||
Assert.Contains("minterpolate=", filter);
|
||||
Assert.Contains("fps=60", filter);
|
||||
Assert.Contains("mi_mode=mci", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate423_VideoFrameRateConverter_BuildsBlendFilter()
|
||||
{
|
||||
var filter = VideoFrameRateConverter.BuildFpsFilter(60, FrameRateConversionMode.FrameBlend);
|
||||
Assert.Contains("tblend=all_mode=average", filter);
|
||||
Assert.Contains("framerate=fps=60", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate424_VideoFrameRateConverter_BuildsFullFfmpegCli()
|
||||
{
|
||||
var args = VideoFrameRateConverter.BuildFfmpegArgs("in.mp4", "out.mp4", 60, FrameRateConversionMode.MotionCompensated, crf: 18);
|
||||
|
||||
Assert.Contains("-i \"in.mp4\"", args);
|
||||
Assert.Contains("-vf \"minterpolate=", args);
|
||||
Assert.Contains("-c:v libx264 -crf 18", args);
|
||||
Assert.Contains("-c:a copy", args);
|
||||
Assert.Contains("\"out.mp4\"", args);
|
||||
}
|
||||
}
|
||||
74
tests/Paca.Tests/Core/VideoIntroOutroTrimmerGateTests.cs
Normal file
74
tests/Paca.Tests/Core/VideoIntroOutroTrimmerGateTests.cs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Paca.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class VideoIntroOutroTrimmerGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate373_VideoIntroOutroTrimmer_ParsesSilenceSpans_FromFfmpegStderr()
|
||||
{
|
||||
var stderr = @"
|
||||
[silencedetect @ 0000021c1c68f140] silence_start: 0
|
||||
[silencedetect @ 0000021c1c68f140] silence_end: 4.2 | silence_duration: 4.2
|
||||
[silencedetect @ 0000021c1c68f140] silence_start: 115.8
|
||||
[silencedetect @ 0000021c1c68f140] silence_end: 120.0 | silence_duration: 4.2
|
||||
";
|
||||
|
||||
var spans = VideoIntroOutroTrimmer.ParseSilenceSpans(stderr);
|
||||
|
||||
Assert.Equal(2, spans.Count);
|
||||
Assert.Equal(0.0, spans[0].StartSec);
|
||||
Assert.Equal(4.2, spans[0].EndSec);
|
||||
Assert.Equal(4.2, spans[0].DurationSec);
|
||||
|
||||
Assert.Equal(115.8, spans[1].StartSec);
|
||||
Assert.Equal(120.0, spans[1].EndSec);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate374_VideoIntroOutroTrimmer_CalculatesPlaybackTrimWindow()
|
||||
{
|
||||
var totalDuration = 180.0; // 3 minutes
|
||||
var skipIntro = 10.0; // Skip first 10s
|
||||
var skipOutro = 20.0; // Skip last 20s
|
||||
|
||||
var window = VideoIntroOutroTrimmer.CalculateTrimWindow(totalDuration, skipIntro, skipOutro);
|
||||
|
||||
Assert.Equal(10.0, window.StartSec);
|
||||
Assert.Equal(160.0, window.EndSec);
|
||||
Assert.Equal(150.0, window.DurationSec);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate375_VideoIntroOutroTrimmer_ClampsExcessiveSkips_ToMinimumOneSecond()
|
||||
{
|
||||
var totalDuration = 30.0;
|
||||
var excessiveIntro = 25.0;
|
||||
var excessiveOutro = 20.0; // sum = 45 > 30
|
||||
|
||||
var window = VideoIntroOutroTrimmer.CalculateTrimWindow(totalDuration, excessiveIntro, excessiveOutro);
|
||||
|
||||
// Should clamp so duration is at least 1.0s
|
||||
Assert.True(window.DurationSec >= 1.0);
|
||||
Assert.True(window.EndSec <= totalDuration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate376_VideoIntroOutroTrimmer_BuildsLosslessTrimCommandLine()
|
||||
{
|
||||
var cmd = VideoIntroOutroTrimmer.BuildLosslessTrimCommandLine(
|
||||
inputPath: "C:\\videos\\full.mp4",
|
||||
outputPath: "C:\\videos\\trimmed.mp4",
|
||||
startSec: 10.5,
|
||||
durationSec: 150.0
|
||||
);
|
||||
|
||||
Assert.Contains("-ss 10.50 -i \"C:\\videos\\full.mp4\"", cmd);
|
||||
Assert.Contains("-t 150.00", cmd);
|
||||
Assert.Contains("-c copy", cmd);
|
||||
Assert.EndsWith("\"C:\\videos\\trimmed.mp4\" -y", cmd);
|
||||
}
|
||||
}
|
||||
62
tests/Paca.Tests/Core/VideoPosterExtractorGateTests.cs
Normal file
62
tests/Paca.Tests/Core/VideoPosterExtractorGateTests.cs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
using System;
|
||||
using Paca.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class VideoPosterExtractorGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate381_VideoPosterExtractor_CalculatesOptimalSeekTime()
|
||||
{
|
||||
// For short 5s clip -> 1.0s
|
||||
var shortClip = VideoPosterExtractor.CalculateOptimalSeekTime(5.0);
|
||||
Assert.Equal(1.0, shortClip);
|
||||
|
||||
// For 60s video -> 10% is 6.0s
|
||||
var midVideo = VideoPosterExtractor.CalculateOptimalSeekTime(60.0);
|
||||
Assert.Equal(6.0, midVideo);
|
||||
|
||||
// For 2-hour movie (7200s) -> Capped at 10.0s (to avoid spoilers while skipping black intro)
|
||||
var movie = VideoPosterExtractor.CalculateOptimalSeekTime(7200.0);
|
||||
Assert.Equal(10.0, movie);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate382_VideoPosterExtractor_BuildsScalingFilter_PreservingAspect()
|
||||
{
|
||||
var spec = new PosterExtractionSpec(
|
||||
SeekTimeSec: 4.5,
|
||||
Width: 640,
|
||||
Height: 360,
|
||||
Format: PosterFormat.Jpeg,
|
||||
Quality: 85
|
||||
);
|
||||
|
||||
var filter = VideoPosterExtractor.BuildScalingFilter(spec);
|
||||
Assert.Equal("scale=640:360:force_original_aspect_ratio=decrease", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate383_VideoPosterExtractor_BuildsWebpAndJpegQualityFlags()
|
||||
{
|
||||
var webpSpec = new PosterExtractionSpec(3.0, 400, 400, PosterFormat.WebP, Quality: 80);
|
||||
var webpCmd = VideoPosterExtractor.BuildPosterCommandLine("in.mp4", "out.webp", webpSpec);
|
||||
Assert.Contains("-c:v libwebp -quality 80", webpCmd);
|
||||
|
||||
var jpegSpec = new PosterExtractionSpec(3.0, 400, 400, PosterFormat.Jpeg, Quality: 90);
|
||||
var jpegCmd = VideoPosterExtractor.BuildPosterCommandLine("in.mp4", "out.jpg", jpegSpec);
|
||||
Assert.Contains("-q:v 2", jpegCmd); // High quality jpeg
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate384_VideoPosterExtractor_BuildsFullSingleFrameCommandLine()
|
||||
{
|
||||
var spec = new PosterExtractionSpec(5.0, 1280, 720, PosterFormat.Jpeg, 85);
|
||||
var cmd = VideoPosterExtractor.BuildPosterCommandLine("C:\\vid.mp4", "C:\\thumb.jpg", spec);
|
||||
|
||||
Assert.Contains("-ss 5.00 -i \"C:\\vid.mp4\"", cmd);
|
||||
Assert.Contains("-vframes 1", cmd);
|
||||
Assert.EndsWith("\"C:\\thumb.jpg\" -y", cmd);
|
||||
}
|
||||
}
|
||||
58
tests/Paca.Tests/Core/VideoSpriteSheetGeneratorGateTests.cs
Normal file
58
tests/Paca.Tests/Core/VideoSpriteSheetGeneratorGateTests.cs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
using System;
|
||||
using Paca.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class VideoSpriteSheetGeneratorGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate325_VideoSpriteSheetGenerator_ConfigComputesDimensions_Accurately()
|
||||
{
|
||||
var config = new SpriteSheetConfig(Columns: 5, Rows: 5, ThumbWidth: 160, ThumbHeight: 90, IntervalSec: 5);
|
||||
|
||||
Assert.Equal(25, config.TotalTilesPerSheet);
|
||||
Assert.Equal(800, config.SheetWidth); // 5 * 160
|
||||
Assert.Equal(450, config.SheetHeight); // 5 * 90
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate326_VideoSpriteSheetGenerator_BuildsFfmpegFilterArgs_Correctly()
|
||||
{
|
||||
var config = new SpriteSheetConfig(5, 5, 160, 90, 10);
|
||||
var cmd = VideoSpriteSheetGenerator.BuildFfmpegSpriteCommandLine("C:\\vid.mp4", "C:\\sprite.jpg", config);
|
||||
|
||||
Assert.Contains("-i \"C:\\vid.mp4\"", cmd);
|
||||
Assert.Contains("-vf \"fps=1/10,scale=160:90,tile=5x5\"", cmd);
|
||||
Assert.Contains("-frames:v 1", cmd);
|
||||
Assert.EndsWith("\"C:\\sprite.jpg\" -y", cmd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate327_VideoSpriteSheetGenerator_GeneratesValidWebVttHeaderAndTimestamps()
|
||||
{
|
||||
var config = new SpriteSheetConfig(5, 5, 160, 90, 5);
|
||||
var vtt = VideoSpriteSheetGenerator.GenerateVttMapping("sprite.jpg", totalDurationSec: 25.0, config);
|
||||
|
||||
Assert.StartsWith("WEBVTT", vtt.TrimStart());
|
||||
Assert.Contains("00:00:00.000 --> 00:00:05.000", vtt);
|
||||
Assert.Contains("00:00:05.000 --> 00:00:10.000", vtt);
|
||||
Assert.Contains("00:00:20.000 --> 00:00:25.000", vtt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate328_VideoSpriteSheetGenerator_CalculatesGridCoordinatesAndLineBreaks()
|
||||
{
|
||||
var config = new SpriteSheetConfig(Columns: 3, Rows: 3, ThumbWidth: 100, ThumbHeight: 50, IntervalSec: 1);
|
||||
var vtt = VideoSpriteSheetGenerator.GenerateVttMapping("sheet.jpg", totalDurationSec: 5.0, config);
|
||||
|
||||
// Tile 0: row 0, col 0 -> 0,0,100,50
|
||||
Assert.Contains("sheet.jpg#xywh=0,0,100,50", vtt);
|
||||
// Tile 1: row 0, col 1 -> 100,0,100,50
|
||||
Assert.Contains("sheet.jpg#xywh=100,0,100,50", vtt);
|
||||
// Tile 2: row 0, col 2 -> 200,0,100,50
|
||||
Assert.Contains("sheet.jpg#xywh=200,0,100,50", vtt);
|
||||
// Tile 3: row 1, col 0 -> 0,50,100,50 (wrapped down to row 1)
|
||||
Assert.Contains("sheet.jpg#xywh=0,50,100,50", vtt);
|
||||
}
|
||||
}
|
||||
85
tests/Paca.Tests/Core/VideoTonemapEnhancerGateTests.cs
Normal file
85
tests/Paca.Tests/Core/VideoTonemapEnhancerGateTests.cs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
using System;
|
||||
using Paca.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class VideoTonemapEnhancerGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate413_VideoTonemapEnhancer_BuildsHdrToSdrFilter_WithSelectedAlgorithm()
|
||||
{
|
||||
var filterMobius = VideoTonemapEnhancer.BuildHdrToSdrFilter(TonemapAlgorithm.Mobius);
|
||||
Assert.Contains("tonemap=mobius", filterMobius);
|
||||
Assert.Contains("zscale=transfer=bt709", filterMobius);
|
||||
|
||||
var filterHable = VideoTonemapEnhancer.BuildHdrToSdrFilter(TonemapAlgorithm.Hable);
|
||||
Assert.Contains("tonemap=hable", filterHable);
|
||||
|
||||
var filterReinhard = VideoTonemapEnhancer.BuildHdrToSdrFilter(TonemapAlgorithm.Reinhard);
|
||||
Assert.Contains("tonemap=reinhard", filterReinhard);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate414_VideoTonemapEnhancer_BuildsColorGradeFilter_WithCustomPreset()
|
||||
{
|
||||
var cinematic = VideoTonemapEnhancer.BuildColorGradeFilter(ColorGradePreset.CinematicWarm);
|
||||
Assert.Contains("eq=contrast=1.10", cinematic);
|
||||
Assert.Contains("saturation=1.15", cinematic);
|
||||
Assert.Contains("colorbalance=rs=0.05", cinematic);
|
||||
|
||||
var oled = VideoTonemapEnhancer.BuildColorGradeFilter(ColorGradePreset.OledDeepBlack);
|
||||
Assert.Contains("contrast=1.05", oled);
|
||||
Assert.Contains("gamma=0.95", oled);
|
||||
|
||||
var none = VideoTonemapEnhancer.BuildColorGradeFilter(ColorGradePreset.None);
|
||||
Assert.Empty(none);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate415_VideoTonemapEnhancer_CombinesTonemapAndColorGrade_Properly()
|
||||
{
|
||||
var combined = VideoTonemapEnhancer.BuildCombinedFilter(TonemapAlgorithm.Hable, ColorGradePreset.CinematicWarm);
|
||||
Assert.Contains("tonemap=hable", combined);
|
||||
Assert.Contains("colorbalance=rs=0.05", combined);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate416_VideoTonemapEnhancer_BuildsFfmpegCliArgs_WithPixelFormat()
|
||||
{
|
||||
var args = VideoTonemapEnhancer.BuildFfmpegArgs("hdr.mp4", "sdr.mp4", TonemapAlgorithm.Mobius, ColorGradePreset.OledDeepBlack, crf: 20);
|
||||
|
||||
Assert.Contains("-i \"hdr.mp4\"", args);
|
||||
Assert.Contains("-vf \"", args);
|
||||
Assert.Contains("tonemap=mobius", args);
|
||||
Assert.Contains("-c:v libx264 -crf 20", args);
|
||||
Assert.Contains("-pix_fmt yuv420p", args);
|
||||
Assert.Contains("-c:a copy", args);
|
||||
Assert.Contains("\"sdr.mp4\"", args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate417_VideoTonemapEnhancer_SupportsHardwareAcceleratedGpuEncoding()
|
||||
{
|
||||
var nvencArgs = VideoTonemapEnhancer.BuildFfmpegArgs(
|
||||
"hdr.mp4", "sdr.mp4",
|
||||
TonemapAlgorithm.Hable, ColorGradePreset.CinematicWarm,
|
||||
crf: 22,
|
||||
videoEncoder: "hevc_nvenc",
|
||||
presetSpeed: "p4");
|
||||
|
||||
Assert.Contains("-c:v hevc_nvenc", nvencArgs);
|
||||
Assert.Contains("-cq 22", nvencArgs);
|
||||
Assert.Contains("-preset p4", nvencArgs);
|
||||
Assert.DoesNotContain("libx264", nvencArgs);
|
||||
|
||||
var qsvArgs = VideoTonemapEnhancer.BuildFfmpegArgs(
|
||||
"hdr.mp4", "sdr.mp4",
|
||||
TonemapAlgorithm.Mobius, ColorGradePreset.Vibrant,
|
||||
crf: 24,
|
||||
videoEncoder: "av1_qsv");
|
||||
|
||||
Assert.Contains("-c:v av1_qsv", qsvArgs);
|
||||
Assert.Contains("-global_quality 24", qsvArgs);
|
||||
}
|
||||
}
|
||||
68
tests/Paca.Tests/Core/WanReachabilityProberGateTests.cs
Normal file
68
tests/Paca.Tests/Core/WanReachabilityProberGateTests.cs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using Paca.Mobile.Core;
|
||||
using Paca.Tests.Infrastructure;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Gate 183 ~ 186: 스마트 WAN 연결성 진단 및 CGNAT 자동 판정 엔진 기계적 검증
|
||||
/// </summary>
|
||||
public class WanReachabilityProberGateTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("192.168.0.15", true)]
|
||||
[InlineData("10.0.1.20", true)]
|
||||
[InlineData("172.20.5.1", true)]
|
||||
[InlineData("127.0.0.1", true)]
|
||||
[InlineData("8.8.8.8", false)]
|
||||
[InlineData("1.1.1.1", false)]
|
||||
public void Gate183_WanReachabilityProber_Detects_PrivateIp(string ipString, bool expectedPrivate)
|
||||
{
|
||||
var ip = IPAddress.Parse(ipString);
|
||||
var isPrivate = WanReachabilityProber.IsPrivateOrLoopback(ip);
|
||||
Assert.Equal(expectedPrivate, isPrivate);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("100.64.0.1", true)]
|
||||
[InlineData("100.127.255.254", true)]
|
||||
[InlineData("100.128.0.1", false)]
|
||||
[InlineData("192.168.1.1", false)]
|
||||
public void Gate184_WanReachabilityProber_Detects_Cgnat(string ipString, bool expectedCgnat)
|
||||
{
|
||||
var ip = IPAddress.Parse(ipString);
|
||||
var isCgnat = WanReachabilityProber.IsCgnatAddress(ip);
|
||||
Assert.Equal(expectedCgnat, isCgnat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate185_WanReachabilityProber_Generates_Actionable_Advice()
|
||||
{
|
||||
// CGNAT 환경이고 UPnP 미지원인 환경 시뮬레이션
|
||||
var report = WanReachabilityProber.Diagnose(
|
||||
localIp: IPAddress.Parse("100.64.5.10"),
|
||||
upnpSuccess: false,
|
||||
tunnelActive: true
|
||||
);
|
||||
|
||||
Assert.Equal(ReachabilityLevel.RelayTunnelRequired, report.Level);
|
||||
Assert.Contains("CGNAT", report.AdviceMessage);
|
||||
Assert.Contains("ConnectCode", report.AdviceMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate186_PairingWindow_WanBadge_Integration()
|
||||
{
|
||||
var pairingWindowPath = Path.Combine(SourceTree.Root, "src", "Paca.App", "Browser", "PairingWindow.cs");
|
||||
Assert.True(File.Exists(pairingWindowPath), "PairingWindow.cs must exist");
|
||||
|
||||
var code = File.ReadAllText(pairingWindowPath);
|
||||
|
||||
// 3G/4G/5G WAN 배지 및 ConnectCode UI 확인
|
||||
Assert.Contains("3G / 4G / 5G", code);
|
||||
Assert.Contains("ConnectCode", code);
|
||||
}
|
||||
}
|
||||
76
tests/Paca.Tests/Core/WorkspaceEncodingSanitizerTests.cs
Normal file
76
tests/Paca.Tests/Core/WorkspaceEncodingSanitizerTests.cs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
using Paca.Core.Config;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Core;
|
||||
|
||||
public class WorkspaceEncodingSanitizerTests
|
||||
{
|
||||
[Fact]
|
||||
public void IsCorrupted_ReturnsTrue_ForUnicodeReplacementAndMojibake()
|
||||
{
|
||||
// Corrupted characters found in real user config.json
|
||||
string corrupted1 = "\uFFFD\u06FE\uFFFD";
|
||||
string corrupted2 = "\uFFFD\uFFFD\uFFFD\uFFFD";
|
||||
string corrupted3 = "\uFFFD\u043D\uFFFD";
|
||||
string corrupted4 = "??";
|
||||
string empty = " ";
|
||||
|
||||
Assert.True(WorkspaceConfigSanitizer.IsCorrupted(corrupted1));
|
||||
Assert.True(WorkspaceConfigSanitizer.IsCorrupted(corrupted2));
|
||||
Assert.True(WorkspaceConfigSanitizer.IsCorrupted(corrupted3));
|
||||
Assert.True(WorkspaceConfigSanitizer.IsCorrupted(corrupted4));
|
||||
Assert.True(WorkspaceConfigSanitizer.IsCorrupted(empty));
|
||||
Assert.True(WorkspaceConfigSanitizer.IsCorrupted(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCorrupted_ReturnsFalse_ForValidKoreanAndEnglishNames()
|
||||
{
|
||||
Assert.False(WorkspaceConfigSanitizer.IsCorrupted("작업"));
|
||||
Assert.False(WorkspaceConfigSanitizer.IsCorrupted("개인"));
|
||||
Assert.False(WorkspaceConfigSanitizer.IsCorrupted("학습"));
|
||||
Assert.False(WorkspaceConfigSanitizer.IsCorrupted("기본"));
|
||||
Assert.False(WorkspaceConfigSanitizer.IsCorrupted("Work"));
|
||||
Assert.False(WorkspaceConfigSanitizer.IsCorrupted("Research 2026"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_ReplacesAllCorruptedNamesWithCleanDefaults()
|
||||
{
|
||||
var corruptedList = new List<string> { "\uFFFD\u06FE\uFFFD", "\uFFFD\uFFFD\uFFFD\uFFFD", "\uFFFD\u043D\uFFFD" };
|
||||
var sanitized = WorkspaceConfigSanitizer.Sanitize(corruptedList);
|
||||
|
||||
Assert.NotNull(sanitized);
|
||||
Assert.NotEmpty(sanitized);
|
||||
Assert.All(sanitized, name => Assert.False(WorkspaceConfigSanitizer.IsCorrupted(name)));
|
||||
Assert.Contains("작업", sanitized);
|
||||
Assert.Contains("개인", sanitized);
|
||||
Assert.Contains("학습", sanitized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_PreservesValidNamesAndFiltersCorrupted()
|
||||
{
|
||||
var mixedList = new List<string> { "프로젝트A", "\uFFFD\u06FE\uFFFD", "디자인" };
|
||||
var sanitized = WorkspaceConfigSanitizer.Sanitize(mixedList);
|
||||
|
||||
Assert.Equal(2, sanitized.Count);
|
||||
Assert.Equal("프로젝트A", sanitized[0]);
|
||||
Assert.Equal("디자인", sanitized[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppConfig_Sanitize_CleansCorruptedWorkspaceNames()
|
||||
{
|
||||
var config = new AppConfig
|
||||
{
|
||||
WorkspaceNames = new List<string> { "\uFFFD\u06FE\uFFFD", "\uFFFD\uFFFD\uFFFD\uFFFD" }
|
||||
};
|
||||
|
||||
config.Sanitize();
|
||||
|
||||
Assert.NotNull(config.WorkspaceNames);
|
||||
Assert.All(config.WorkspaceNames, name => Assert.False(WorkspaceConfigSanitizer.IsCorrupted(name)));
|
||||
Assert.Equal("작업", config.WorkspaceNames[0]);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue