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

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

View file

@ -0,0 +1,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);
}
}

View file

@ -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]);
}
}

View file

@ -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);
}
}

View 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);
}
}

View 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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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));
}
}

View 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);
}
}

View 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);
}
}

View 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;
}
}

View file

@ -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);
}
}

View 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
}
}

View 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);
}
}
}

View file

@ -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";
}
}

View 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);
}
}

View file

@ -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);
}
}

View 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);
}
}

View 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}");
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

File diff suppressed because it is too large Load diff

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View file

@ -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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View file

@ -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);
}
}

View 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);
}
}

View 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 _));
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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"));
}
}

View 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(""));
}
}

View 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
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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());
}
}

View file

@ -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);
}
}
}

View 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
}
}

View 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));
}
}
}

View 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);
}
}

View file

@ -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);
}
}
}

View file

@ -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);
}
}

View 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);
}
}

View 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);
}
}

View 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));
}
}

View 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);
}
}

View 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);
}
}

View 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 _));
}
}

View 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));
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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]);
}
}