feat(ads/seo/release): v1.2.0 with live Google Ads, isolated ad profile, ads.txt, and SEO/AEO/GEO optimization
This commit is contained in:
parent
a88aafa1e5
commit
0e568f1f0a
975 changed files with 130593 additions and 16783 deletions
453
tests/Paca.Tests/ModernBrowsingMilestone10Phase110Tests.cs
Normal file
453
tests/Paca.Tests/ModernBrowsingMilestone10Phase110Tests.cs
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Paca.Browser.Networking;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests;
|
||||
|
||||
public sealed class ModernBrowsingMilestone10Phase110Tests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public ModernBrowsingMilestone10Phase110Tests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), "paca_m10_test_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignored in cleanup
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate167_DohProviderSelector_SelectsProvidersAndBuildsDnsQueryUrls()
|
||||
{
|
||||
// Arrange
|
||||
var configPath = Path.Combine(_tempDir, "doh_config.json");
|
||||
var selector = new DohProviderSelector(configPath);
|
||||
|
||||
// Act & Assert 1: Default state is SystemDefault / Off
|
||||
Assert.Equal(DohProvider.SystemDefault, selector.CurrentProvider);
|
||||
Assert.False(selector.IsEnabled);
|
||||
Assert.Empty(selector.EffectiveTemplateUrl);
|
||||
|
||||
// Act & Assert 2: Select Cloudflare (1.1.1.1)
|
||||
selector.SelectProvider(DohProvider.Cloudflare);
|
||||
Assert.True(selector.IsEnabled);
|
||||
Assert.Equal("https://cloudflare-dns.com/dns-query", selector.EffectiveTemplateUrl);
|
||||
|
||||
var queryCloudflareA = selector.BuildQueryUrl("paca-browser.org", DohRecordType.A);
|
||||
Assert.Equal("https://cloudflare-dns.com/dns-query?name=paca-browser.org&type=A", queryCloudflareA);
|
||||
|
||||
// Act & Assert 3: Select Google (8.8.8.8)
|
||||
selector.SelectProvider(DohProvider.Google);
|
||||
Assert.Equal("https://dns.google/dns-query", selector.EffectiveTemplateUrl);
|
||||
|
||||
var queryGoogleAaaa = selector.BuildQueryUrl("github.com", DohRecordType.AAAA);
|
||||
Assert.Equal("https://dns.google/dns-query?name=github.com&type=AAAA", queryGoogleAaaa);
|
||||
|
||||
// Act & Assert 4: Select Quad9 (9.9.9.9)
|
||||
selector.SelectProvider(DohProvider.Quad9);
|
||||
Assert.Equal("https://dns.quad9.net/dns-query", selector.EffectiveTemplateUrl);
|
||||
|
||||
// Act & Assert 5: Select Custom
|
||||
Assert.Throws<ArgumentException>(() => selector.SelectProvider(DohProvider.Custom, "http://insecure-dns.local/query"));
|
||||
Assert.Throws<ArgumentException>(() => selector.SelectProvider(DohProvider.Custom, ""));
|
||||
|
||||
selector.SelectProvider(DohProvider.Custom, "https://secure-resolver.internal.net/dns-query");
|
||||
Assert.Equal("https://secure-resolver.internal.net/dns-query", selector.EffectiveTemplateUrl);
|
||||
Assert.Equal("https://secure-resolver.internal.net/dns-query?name=my-server.local&type=TXT",
|
||||
selector.BuildQueryUrl("my-server.local", DohRecordType.TXT));
|
||||
|
||||
// Act & Assert 6: Persistence Save & Reload
|
||||
selector.Save();
|
||||
var reloaded = new DohProviderSelector(configPath);
|
||||
Assert.Equal(DohProvider.Custom, reloaded.CurrentProvider);
|
||||
Assert.Equal("https://secure-resolver.internal.net/dns-query", reloaded.EffectiveTemplateUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate168_AntiFingerprintingEngine_InjectsPerDomainNoiseAndFarblingScript()
|
||||
{
|
||||
// Arrange
|
||||
var engine = new AntiFingerprintingEngine(FarblingMode.Balanced, sessionSalt: "test-salt-2026");
|
||||
|
||||
// Act 1: Domain Seed Determinism & Diversity
|
||||
var seed1 = engine.GetDomainSeed("example.com");
|
||||
var seed2 = engine.GetDomainSeed("example.com");
|
||||
var seedTracker = engine.GetDomainSeed("malicious-tracker.org");
|
||||
|
||||
Assert.Equal(seed1, seed2);
|
||||
Assert.NotEqual(seed1, seedTracker);
|
||||
|
||||
// Act 2: Canvas Farbling Noise
|
||||
var originalPixels = new byte[32]; // 8 pixels RGBA
|
||||
for (int i = 0; i < originalPixels.Length; i++)
|
||||
{
|
||||
originalPixels[i] = (byte)(100 + (i * 3) % 100);
|
||||
}
|
||||
|
||||
var farbledSiteA1 = engine.ApplyCanvasNoise("example.com", originalPixels, 4, 2);
|
||||
var farbledSiteA2 = engine.ApplyCanvasNoise("example.com", originalPixels, 4, 2);
|
||||
var farbledSiteB = engine.ApplyCanvasNoise("another-site.org", originalPixels, 4, 2);
|
||||
|
||||
// Verify deterministic for same domain
|
||||
Assert.Equal(farbledSiteA1, farbledSiteA2);
|
||||
// Verify different across domains
|
||||
Assert.False(farbledSiteA1.SequenceEqual(farbledSiteB));
|
||||
// Verify noise magnitude is subtly bounded (+-2 in Balanced mode)
|
||||
for (int i = 0; i < originalPixels.Length; i++)
|
||||
{
|
||||
var diff = Math.Abs(farbledSiteA1[i] - originalPixels[i]);
|
||||
Assert.True(diff <= 2, $"Pixel noise diff {diff} exceeded balanced threshold 2 at index {i}");
|
||||
}
|
||||
|
||||
// Act 3: Audio Buffer Farbling
|
||||
var originalAudio = new float[] { 0.125f, -0.25f, 0.5f, 0.75f, -0.1f };
|
||||
var farbledAudioA = engine.ApplyAudioNoise("example.com", originalAudio);
|
||||
var farbledAudioB = engine.ApplyAudioNoise("another-site.org", originalAudio);
|
||||
|
||||
Assert.Equal(originalAudio.Length, farbledAudioA.Length);
|
||||
Assert.False(farbledAudioA.SequenceEqual(farbledAudioB));
|
||||
for (int i = 0; i < originalAudio.Length; i++)
|
||||
{
|
||||
var delta = Math.Abs(farbledAudioA[i] - originalAudio[i]);
|
||||
Assert.True(delta <= 0.001f, $"Audio noise delta {delta} exceeded threshold at index {i}");
|
||||
}
|
||||
|
||||
// Act 4: Farbling Injection Script
|
||||
var script = engine.GenerateFarblingScript("example.com");
|
||||
Assert.False(string.IsNullOrWhiteSpace(script));
|
||||
Assert.Contains("HTMLCanvasElement", script);
|
||||
Assert.Contains("getImageData", script);
|
||||
Assert.Contains("AudioBuffer", script);
|
||||
Assert.Contains(seed1.ToString(), script);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate169_IsolatedIncognitoTabPolicy_IsolatesInWindowPartitionsAndPurgesOnClose()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new IsolatedIncognitoTabPolicy();
|
||||
|
||||
// Act 1: Initial tab state
|
||||
Assert.False(policy.IsIncognitoTab("tab-normal-1"));
|
||||
Assert.Equal(0, policy.ActivePartitionsCount);
|
||||
Assert.Equal(0, policy.ActiveIncognitoTabsCount);
|
||||
|
||||
// Act 2: Create isolated incognito tabs in the same window
|
||||
var part1 = policy.CreatePartition("Private Session Alpha");
|
||||
policy.AssignTabToPartition("tab-incognito-1", part1.PartitionId);
|
||||
policy.AssignTabToPartition("tab-incognito-2", part1.PartitionId);
|
||||
|
||||
var part2 = policy.CreatePartition("Private Session Beta");
|
||||
policy.AssignTabToPartition("tab-incognito-3", part2.PartitionId);
|
||||
|
||||
Assert.True(policy.IsIncognitoTab("tab-incognito-1"));
|
||||
Assert.True(policy.IsIncognitoTab("tab-incognito-2"));
|
||||
Assert.True(policy.IsIncognitoTab("tab-incognito-3"));
|
||||
Assert.Equal(2, policy.ActivePartitionsCount);
|
||||
Assert.Equal(3, policy.ActiveIncognitoTabsCount);
|
||||
|
||||
// Act 3: Partition Storage Isolation
|
||||
policy.SetPartitionStorage(part1.PartitionId, "session_user", "alice@paca.internal");
|
||||
policy.SetPartitionStorage(part2.PartitionId, "session_user", "bob@paca.internal");
|
||||
|
||||
Assert.Equal("alice@paca.internal", policy.GetPartitionStorage(part1.PartitionId, "session_user"));
|
||||
Assert.Equal("bob@paca.internal", policy.GetPartitionStorage(part2.PartitionId, "session_user"));
|
||||
Assert.Null(policy.GetPartitionStorage("unknown-partition", "session_user"));
|
||||
|
||||
// Act 4: Close tab-incognito-1 (part1 still has tab-incognito-2, should NOT purge)
|
||||
policy.CloseTab("tab-incognito-1");
|
||||
Assert.False(policy.IsIncognitoTab("tab-incognito-1"));
|
||||
Assert.Equal(2, policy.ActivePartitionsCount);
|
||||
Assert.Equal(2, policy.ActiveIncognitoTabsCount);
|
||||
Assert.Equal("alice@paca.internal", policy.GetPartitionStorage(part1.PartitionId, "session_user"));
|
||||
|
||||
// Act 5: Close tab-incognito-2 (last tab in part1, should auto-purge ephemeral partition)
|
||||
policy.CloseTab("tab-incognito-2");
|
||||
Assert.Equal(1, policy.ActivePartitionsCount);
|
||||
Assert.Equal(1, policy.ActiveIncognitoTabsCount);
|
||||
Assert.Null(policy.GetPartitionStorage(part1.PartitionId, "session_user"));
|
||||
|
||||
// Act 6: Explicit Purge of part2
|
||||
policy.PurgePartition(part2.PartitionId);
|
||||
Assert.Equal(0, policy.ActivePartitionsCount);
|
||||
Assert.Equal(0, policy.ActiveIncognitoTabsCount);
|
||||
Assert.Null(policy.GetPartitionStorage(part2.PartitionId, "session_user"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate170_JavaScriptKillSwitchPolicy_EnforcesPerDomainRulesAndInteractionRestore()
|
||||
{
|
||||
// Arrange
|
||||
var configPath = Path.Combine(_tempDir, "js_policy.json");
|
||||
var policy = new JavaScriptKillSwitchPolicy(configPath);
|
||||
|
||||
// Act 1: Default JS rule is Allow
|
||||
Assert.Equal(JsExecutionRule.Allow, policy.DefaultRule);
|
||||
Assert.True(policy.IsJsAllowed("https://good-news.org/page"));
|
||||
|
||||
// Act 2: Disable JS on spam site
|
||||
policy.SetRule("spam-site.com", JsExecutionRule.Block);
|
||||
Assert.False(policy.IsJsAllowed("https://spam-site.com/article/123"));
|
||||
Assert.False(policy.IsJsAllowed("https://sub.spam-site.com/login"));
|
||||
Assert.True(policy.IsJsAllowed("https://good-news.org/page"));
|
||||
|
||||
// Act 3: Interaction and selection restore rule
|
||||
Assert.False(policy.IsInteractionRestoreEnabled("paywall-archive.com"));
|
||||
policy.SetInteractionRestore("paywall-archive.com", true);
|
||||
Assert.True(policy.IsInteractionRestoreEnabled("paywall-archive.com"));
|
||||
|
||||
var restoreScript = policy.GetRestoreInteractionScript();
|
||||
Assert.Contains("contextmenu", restoreScript);
|
||||
Assert.Contains("user-select", restoreScript);
|
||||
Assert.Contains("selectstart", restoreScript);
|
||||
Assert.Contains("!important", restoreScript);
|
||||
|
||||
// Act 4: Persistence Save & Reload
|
||||
policy.Save();
|
||||
var reloaded = new JavaScriptKillSwitchPolicy(configPath);
|
||||
Assert.False(reloaded.IsJsAllowed("https://spam-site.com"));
|
||||
Assert.True(reloaded.IsInteractionRestoreEnabled("paywall-archive.com"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate171_MockLocationSimulator_SpoofsPresetsAndCoordinatesWithJsEmulation()
|
||||
{
|
||||
// Arrange
|
||||
var configPath = Path.Combine(_tempDir, "mock_location.json");
|
||||
var simulator = new MockLocationSimulator(configPath);
|
||||
|
||||
// Act 1: Preset Seoul
|
||||
simulator.SetPreset("tab-101", LocationPreset.Seoul);
|
||||
Assert.True(simulator.IsSimulationActive("tab-101"));
|
||||
var seoulCoords = simulator.GetCoordinates("tab-101");
|
||||
Assert.NotNull(seoulCoords);
|
||||
Assert.Equal(37.5665, seoulCoords.Latitude, precision: 4);
|
||||
Assert.Equal(126.9780, seoulCoords.Longitude, precision: 4);
|
||||
Assert.Contains("Seoul", seoulCoords.LocationName);
|
||||
|
||||
// Act 2: Preset San Francisco
|
||||
simulator.SetPreset("tab-102", LocationPreset.SanFrancisco);
|
||||
var sfCoords = simulator.GetCoordinates("tab-102");
|
||||
Assert.NotNull(sfCoords);
|
||||
Assert.Equal(37.7749, sfCoords.Latitude, precision: 4);
|
||||
Assert.Equal(-122.4194, sfCoords.Longitude, precision: 4);
|
||||
|
||||
// Act 3: Custom Coordinates
|
||||
var custom = new GeoCoordinates(35.6762, 139.6503, 5.0, LocationName: "Tokyo Tower");
|
||||
simulator.SetCustomCoordinates("tab-103", custom);
|
||||
var tokyoCoords = simulator.GetCoordinates("tab-103");
|
||||
Assert.NotNull(tokyoCoords);
|
||||
Assert.Equal(35.6762, tokyoCoords.Latitude, precision: 4);
|
||||
Assert.Equal(139.6503, tokyoCoords.Longitude, precision: 4);
|
||||
|
||||
// Act 4: Geolocation Emulation JavaScript Generator
|
||||
var script = simulator.GenerateGeolocationOverrideScript("tab-103");
|
||||
Assert.Contains("navigator.geolocation.getCurrentPosition", script);
|
||||
Assert.Contains("navigator.geolocation.watchPosition", script);
|
||||
Assert.Contains("35.6762", script);
|
||||
Assert.Contains("139.6503", script);
|
||||
|
||||
// Act 5: Clear simulation
|
||||
simulator.ClearMockLocation("tab-101");
|
||||
Assert.False(simulator.IsSimulationActive("tab-101"));
|
||||
Assert.Null(simulator.GetCoordinates("tab-101"));
|
||||
|
||||
// Act 6: Persistence Save & Reload
|
||||
simulator.Save();
|
||||
var reloaded = new MockLocationSimulator(configPath);
|
||||
Assert.True(reloaded.IsSimulationActive("tab-103"));
|
||||
Assert.Equal("Tokyo Tower", reloaded.GetCoordinates("tab-103")?.LocationName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate172_AmsiScannerBridge_ScansBuffersAndFilesWithMalwareDetection()
|
||||
{
|
||||
// Arrange
|
||||
var bridge = new AmsiScannerBridge();
|
||||
|
||||
// Act 1: Clean buffer scan
|
||||
var cleanCode = Encoding.UTF8.GetBytes("function sayHello() { console.log('Welcome to PACA Browser'); }");
|
||||
var cleanReport = bridge.ScanBuffer(cleanCode, "app.js");
|
||||
Assert.Equal(AmsiScanVerdict.Clean, cleanReport.Verdict);
|
||||
Assert.False(cleanReport.IsBlocked);
|
||||
Assert.True(bridge.IsSafeToExecute(cleanReport));
|
||||
|
||||
// Act 2: EICAR Standard Anti-Virus Test File Detection
|
||||
var eicarString = "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*";
|
||||
var eicarBytes = Encoding.UTF8.GetBytes(eicarString);
|
||||
var malwareReport = bridge.ScanBuffer(eicarBytes, "eicar.com");
|
||||
Assert.Equal(AmsiScanVerdict.MalwareDetected, malwareReport.Verdict);
|
||||
Assert.True(malwareReport.IsBlocked);
|
||||
Assert.False(bridge.IsSafeToExecute(malwareReport));
|
||||
Assert.Contains("EICAR", malwareReport.Details);
|
||||
|
||||
// Act 3: Suspicious Dropper Script Pattern
|
||||
var suspiciousScript = "cmd.exe /c powershell -ExecutionPolicy Bypass -NoProfile -EncodedCommand SQBFAFgA...";
|
||||
var susReport = bridge.ScanString(suspiciousScript, "invoice_attachment.bat");
|
||||
Assert.Equal(AmsiScanVerdict.Suspicious, susReport.Verdict);
|
||||
Assert.True(susReport.IsBlocked);
|
||||
Assert.False(bridge.IsSafeToExecute(susReport));
|
||||
|
||||
// Act 4: File Scanning on Disk
|
||||
var cleanFilePath = Path.Combine(_tempDir, "safe_readme.txt");
|
||||
File.WriteAllText(cleanFilePath, "PACA Browser Secure Download Completed.");
|
||||
var fileReportClean = bridge.ScanFile(cleanFilePath);
|
||||
Assert.Equal(AmsiScanVerdict.Clean, fileReportClean.Verdict);
|
||||
Assert.True(bridge.IsSafeToExecute(fileReportClean));
|
||||
|
||||
var infectedFilePath = Path.Combine(_tempDir, "trojan_test.bin");
|
||||
File.WriteAllText(infectedFilePath, eicarString);
|
||||
var fileReportInfected = bridge.ScanFile(infectedFilePath);
|
||||
Assert.Equal(AmsiScanVerdict.MalwareDetected, fileReportInfected.Verdict);
|
||||
Assert.True(fileReportInfected.IsBlocked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate173_RealtimeStreamInspector_TracksWebSocketSseWebRtcAndBandwidth()
|
||||
{
|
||||
// Arrange
|
||||
var inspector = new RealtimeStreamInspector();
|
||||
|
||||
// Act 1: Register active streams
|
||||
inspector.RegisterConnection("ws-1", StreamProtocol.WebSocket, "wss://stream.crypto.com/ticker", "tab-1");
|
||||
inspector.RegisterConnection("sse-1", StreamProtocol.ServerSentEvents, "https://news.paca.org/live-feed", "tab-1");
|
||||
inspector.RegisterConnection("rtc-1", StreamProtocol.WebRtc, "webrtc://meet.corp.net/room-alpha", "tab-2");
|
||||
|
||||
// Act 2: Record traffic & messages
|
||||
inspector.RecordTraffic("ws-1", bytesReceivedDelta: 1024, bytesSentDelta: 256, messagesReceivedDelta: 10, messagesSentDelta: 2);
|
||||
inspector.RecordTraffic("sse-1", bytesReceivedDelta: 2048, bytesSentDelta: 0, messagesReceivedDelta: 20, messagesSentDelta: 0);
|
||||
inspector.RecordTraffic("rtc-1", bytesReceivedDelta: 50000, bytesSentDelta: 40000, messagesReceivedDelta: 50, messagesSentDelta: 45);
|
||||
|
||||
// Act 3: Global Aggregate Metrics
|
||||
var globalMetrics = inspector.GetAggregateMetrics();
|
||||
Assert.Equal(3, globalMetrics.TotalActiveConnections);
|
||||
Assert.Equal(1, globalMetrics.WebSocketCount);
|
||||
Assert.Equal(1, globalMetrics.SseCount);
|
||||
Assert.Equal(1, globalMetrics.WebRtcCount);
|
||||
Assert.Equal(53072, globalMetrics.TotalBytesReceived);
|
||||
Assert.Equal(40256, globalMetrics.TotalBytesSent);
|
||||
Assert.Equal(127, globalMetrics.TotalMessages);
|
||||
|
||||
// Act 4: Tab-scoped Metrics
|
||||
var tab1Metrics = inspector.GetAggregateMetrics("tab-1");
|
||||
Assert.Equal(2, tab1Metrics.TotalActiveConnections);
|
||||
Assert.Equal(3072, tab1Metrics.TotalBytesReceived);
|
||||
Assert.Equal(256, tab1Metrics.TotalBytesSent);
|
||||
|
||||
var tab2Metrics = inspector.GetAggregateMetrics("tab-2");
|
||||
Assert.Equal(1, tab2Metrics.TotalActiveConnections);
|
||||
Assert.Equal(50000, tab2Metrics.TotalBytesReceived);
|
||||
|
||||
// Act 5: Connection State & Closure
|
||||
inspector.CloseConnection("ws-1", StreamState.Closed);
|
||||
var updatedWs1 = inspector.GetConnection("ws-1");
|
||||
Assert.NotNull(updatedWs1);
|
||||
Assert.Equal(StreamState.Closed, updatedWs1.State);
|
||||
Assert.NotNull(updatedWs1.ClosedAtUtc);
|
||||
|
||||
var activeAfterClose = inspector.GetActiveConnections();
|
||||
Assert.Equal(2, activeAfterClose.Count);
|
||||
Assert.DoesNotContain(activeAfterClose, c => c.Id == "ws-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate174_DiskCacheCapManager_EnforcesSizeCapAndPlansLruEviction()
|
||||
{
|
||||
// Arrange: Start with small 1000 byte cap
|
||||
var manager = new DiskCacheCapManager(maxSizeBytes: 1000);
|
||||
|
||||
// Act & Assert 1: Presets
|
||||
manager.SetPreset(DiskCacheCapPreset.Cap512MB);
|
||||
Assert.Equal(512L * 1024 * 1024, manager.MaxSizeBytes);
|
||||
Assert.Equal(DiskCacheCapPreset.Cap512MB, manager.Preset);
|
||||
|
||||
manager.SetPreset(DiskCacheCapPreset.Cap1GB);
|
||||
Assert.Equal(1024L * 1024 * 1024, manager.MaxSizeBytes);
|
||||
Assert.Equal(DiskCacheCapPreset.Cap1GB, manager.Preset);
|
||||
|
||||
manager.SetPreset(DiskCacheCapPreset.Cap2GB);
|
||||
Assert.Equal(2048L * 1024 * 1024, manager.MaxSizeBytes);
|
||||
Assert.Equal(DiskCacheCapPreset.Cap2GB, manager.Preset);
|
||||
|
||||
// Reset to Custom 1000 bytes for deterministic LRU eviction testing
|
||||
manager.SetPreset(DiskCacheCapPreset.Custom, customSizeBytes: 1000);
|
||||
Assert.Equal(1000, manager.MaxSizeBytes);
|
||||
|
||||
// Act 2: Register cache entries with different last-accessed times
|
||||
var now = DateTime.UtcNow;
|
||||
var file1 = Path.Combine(_tempDir, "cache_001.bin");
|
||||
var file2 = Path.Combine(_tempDir, "cache_002.bin");
|
||||
var file3 = Path.Combine(_tempDir, "cache_003.bin");
|
||||
|
||||
File.WriteAllBytes(file1, new byte[400]);
|
||||
File.WriteAllBytes(file2, new byte[400]);
|
||||
File.WriteAllBytes(file3, new byte[300]);
|
||||
|
||||
// file1 accessed 15m ago (oldest), file2 accessed 10m ago, file3 accessed 1m ago (newest)
|
||||
manager.RegisterEntry("key1", file1, 400, lastAccessedUtc: now.AddMinutes(-15));
|
||||
manager.RegisterEntry("key2", file2, 400, lastAccessedUtc: now.AddMinutes(-10));
|
||||
manager.RegisterEntry("key3", file3, 300, lastAccessedUtc: now.AddMinutes(-1));
|
||||
|
||||
// Act & Assert 3: Total usage exceeds cap
|
||||
Assert.Equal(1100, manager.GetTotalUsageBytes());
|
||||
Assert.True(manager.IsCapExceeded());
|
||||
|
||||
// Act 4: Plan eviction targeting 20% headroom (target usage <= 800 bytes)
|
||||
// Excess to free = 1100 - 800 = 300 bytes.
|
||||
// In LRU order, key1 (400 bytes) should be evicted first. Evicting key1 drops usage to 700 bytes <= 800.
|
||||
var plan = manager.PlanEviction(targetHeadroomRatio: 0.20);
|
||||
Assert.True(plan.IsEvictionNeeded);
|
||||
Assert.Single(plan.EntriesToEvict);
|
||||
Assert.Equal("key1", plan.EntriesToEvict[0].Key);
|
||||
Assert.Equal(400, plan.TotalBytesToEvict);
|
||||
Assert.Equal(700, plan.ProjectedUsageBytes);
|
||||
|
||||
// Act 5: Execute eviction
|
||||
var deletedFilePaths = new List<string>();
|
||||
var freedBytes = manager.ExecuteEviction(plan, deleteAction: path =>
|
||||
{
|
||||
deletedFilePaths.Add(path);
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
});
|
||||
|
||||
Assert.Equal(400, freedBytes);
|
||||
Assert.Contains(file1, deletedFilePaths);
|
||||
Assert.False(File.Exists(file1));
|
||||
Assert.True(File.Exists(file2));
|
||||
Assert.True(File.Exists(file3));
|
||||
|
||||
// Usage is now within cap
|
||||
Assert.Equal(700, manager.GetTotalUsageBytes());
|
||||
Assert.False(manager.IsCapExceeded());
|
||||
|
||||
// Act 6: Touch entry updates LRU order
|
||||
// Touch key2 to make it newer than key3
|
||||
manager.TouchEntry("key2");
|
||||
// Add another entry of 400 bytes -> total = 700 + 400 = 1100 bytes
|
||||
var file4 = Path.Combine(_tempDir, "cache_004.bin");
|
||||
File.WriteAllBytes(file4, new byte[400]);
|
||||
manager.RegisterEntry("key4", file4, 400, lastAccessedUtc: DateTime.UtcNow);
|
||||
|
||||
// Since key2 was touched, key3 is now the oldest (accessed ~1m ago vs key2 touched now)
|
||||
var plan2 = manager.PlanEviction(targetHeadroomRatio: 0.20);
|
||||
Assert.True(plan2.IsEvictionNeeded);
|
||||
Assert.Equal("key3", plan2.EntriesToEvict[0].Key);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue