388 lines
17 KiB
C#
388 lines
17 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using Paca.Browser.Diagnostics;
|
|
using Xunit;
|
|
|
|
namespace Paca.Tests;
|
|
|
|
public sealed class ModernBrowsingMilestone14Phase114Tests : IDisposable
|
|
{
|
|
private readonly string _tempDir;
|
|
|
|
public ModernBrowsingMilestone14Phase114Tests()
|
|
{
|
|
_tempDir = Path.Combine(Path.GetTempPath(), "paca_m14_test_" + Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(_tempDir);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
try
|
|
{
|
|
if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, true);
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate195_BatteryEfficiencyModeGovernor_ThrottlesTimersAndLowersFrameRate()
|
|
{
|
|
// Arrange
|
|
var governor = new BatteryEfficiencyModeGovernor(
|
|
policy: EfficiencyModePolicy.LowBattery,
|
|
batteryThresholdPercent: 20,
|
|
normalFrameRate: 60,
|
|
efficiencyFrameRate: 30,
|
|
throttleBackgroundTimerInterval: TimeSpan.FromSeconds(10));
|
|
|
|
// Act & Assert 1: Plugged in and high battery -> Normal 60fps, no throttle
|
|
governor.UpdateBatteryStatus(batteryPercentage: 85, isPluggedIn: true);
|
|
Assert.False(governor.IsEfficiencyModeActive);
|
|
Assert.Equal(60, governor.GetEffectiveFrameRate());
|
|
var normalInterval = governor.GetEffectiveTimerInterval(TimeSpan.FromSeconds(1), isBackgroundTab: true);
|
|
Assert.Equal(TimeSpan.FromSeconds(1), normalInterval);
|
|
|
|
// Act & Assert 2: Unplugged with 15% battery (below 20% threshold) -> Efficiency mode active (30fps)
|
|
bool eventFired = false;
|
|
governor.EfficiencyModeChanged += (sender, active) => eventFired = active;
|
|
|
|
governor.UpdateBatteryStatus(batteryPercentage: 15, isPluggedIn: false);
|
|
Assert.True(governor.IsEfficiencyModeActive);
|
|
Assert.True(eventFired);
|
|
Assert.Equal(30, governor.GetEffectiveFrameRate());
|
|
|
|
// Background timer throttled to at least 10s floor
|
|
var throttledInterval = governor.GetEffectiveTimerInterval(TimeSpan.FromSeconds(1), isBackgroundTab: true);
|
|
Assert.Equal(TimeSpan.FromSeconds(10), throttledInterval);
|
|
|
|
// Active foreground tab timer is preserved
|
|
var foregroundInterval = governor.GetEffectiveTimerInterval(TimeSpan.FromMilliseconds(16), isBackgroundTab: false);
|
|
Assert.Equal(TimeSpan.FromMilliseconds(16), foregroundInterval);
|
|
|
|
// Act & Assert 3: Manual override always takes precedence
|
|
governor.SetManualOverride(false);
|
|
Assert.False(governor.IsEfficiencyModeActive);
|
|
Assert.Equal(60, governor.GetEffectiveFrameRate());
|
|
|
|
governor.SetManualOverride(true);
|
|
Assert.True(governor.IsEfficiencyModeActive);
|
|
Assert.Equal(30, governor.GetEffectiveFrameRate());
|
|
|
|
// Act & Assert 4: Clear override resumes policy-driven evaluation
|
|
governor.SetManualOverride(null);
|
|
Assert.True(governor.IsEfficiencyModeActive); // still 15% and unplugged
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate196_DiscardedTabVisualState_RendersOpacityAndSleepingBadge()
|
|
{
|
|
// Act 1: Active tab visual state
|
|
var activeState = DiscardedTabVisualState.Create(isDiscarded: false, reclaimedMemoryBytes: 0);
|
|
Assert.False(activeState.IsDiscarded);
|
|
Assert.Equal(1.0, activeState.Opacity);
|
|
Assert.False(activeState.ShowSleepingBadge);
|
|
Assert.Equal(string.Empty, activeState.SleepingBadgeText);
|
|
Assert.Equal(string.Empty, activeState.SleepingBadgeIcon);
|
|
Assert.False(activeState.HasReclaimedMemory);
|
|
|
|
// Act 2: Discarded/sleeping tab visual state (spec: opacity 0.6, sleeping badge text/icon)
|
|
long savedBytes = 145 * 1024 * 1024; // 145 MB
|
|
var discardedState = DiscardedTabVisualState.Create(
|
|
isDiscarded: true,
|
|
reclaimedMemoryBytes: savedBytes,
|
|
sleepingBadgeText: "절전 중",
|
|
sleepingBadgeIcon: "💤");
|
|
|
|
Assert.True(discardedState.IsDiscarded);
|
|
Assert.Equal(0.6, discardedState.Opacity);
|
|
Assert.True(discardedState.ShowSleepingBadge);
|
|
Assert.Equal("절전 중", discardedState.SleepingBadgeText);
|
|
Assert.Equal("💤", discardedState.SleepingBadgeIcon);
|
|
Assert.True(discardedState.HasReclaimedMemory);
|
|
Assert.Contains("145.0 MB", discardedState.FormattedReclaimedMemory);
|
|
Assert.Contains("절전", discardedState.TooltipText);
|
|
|
|
// Act 3: CSS styling helper verification
|
|
string css = discardedState.ToCssStyle();
|
|
Assert.Contains("opacity: 0.6", css);
|
|
|
|
string activeCss = activeState.ToCssStyle();
|
|
Assert.Contains("opacity: 1", activeCss);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate197_SiteStorageQuotaInspector_CalculatesUsageAndGeneratesCleanupOrders()
|
|
{
|
|
// Arrange
|
|
var inspector = new SiteStorageQuotaInspector(perDomainQuotaBytes: 200 * 1024 * 1024); // 200MB quota
|
|
|
|
// Domain 1: 350MB total (Over Quota)
|
|
inspector.RegisterUsage(new SiteStorageItem(
|
|
Domain: "video.streaming.com",
|
|
IndexedDbBytes: 150 * 1024 * 1024,
|
|
CacheStorageBytes: 180 * 1024 * 1024,
|
|
CookieBytes: 20 * 1024 * 1024,
|
|
LastAccessedUtc: DateTime.UtcNow.AddDays(-10)));
|
|
|
|
// Domain 2: 80MB total (Under Quota)
|
|
inspector.RegisterUsage(new SiteStorageItem(
|
|
Domain: "news.portal.com",
|
|
IndexedDbBytes: 10 * 1024 * 1024,
|
|
CacheStorageBytes: 68 * 1024 * 1024,
|
|
CookieBytes: 2 * 1024 * 1024,
|
|
LastAccessedUtc: DateTime.UtcNow.AddDays(-2)));
|
|
|
|
// Domain 3: 250MB total (Over Quota, older)
|
|
inspector.RegisterUsage(new SiteStorageItem(
|
|
Domain: "heavy.webapp.org",
|
|
IndexedDbBytes: 200 * 1024 * 1024,
|
|
CacheStorageBytes: 49 * 1024 * 1024,
|
|
CookieBytes: 1 * 1024 * 1024,
|
|
LastAccessedUtc: DateTime.UtcNow.AddDays(-30)));
|
|
|
|
// Act 1: Verify total storage computation
|
|
long totalBytes = inspector.GetTotalStorageBytes();
|
|
Assert.Equal(680 * 1024 * 1024, totalBytes);
|
|
|
|
// Act 2: Detect over-quota domains
|
|
var overQuota = inspector.GetOverQuotaDomains();
|
|
Assert.Equal(2, overQuota.Count);
|
|
Assert.Contains(overQuota, d => d.Domain == "video.streaming.com");
|
|
Assert.Contains(overQuota, d => d.Domain == "heavy.webapp.org");
|
|
Assert.DoesNotContain(overQuota, d => d.Domain == "news.portal.com");
|
|
|
|
// Act 3: Generate over-quota cleanup orders
|
|
var overQuotaOrders = inspector.GenerateOverQuotaCleanupOrders();
|
|
Assert.Equal(2, overQuotaOrders.Count);
|
|
var videoOrder = overQuotaOrders.First(o => o.Domain == "video.streaming.com");
|
|
Assert.True(videoOrder.ClearCacheStorage);
|
|
Assert.True(videoOrder.TargetBytesToReclaim > 0);
|
|
|
|
// Act 4: Generate target free cleanup plan (Free 300MB from LRU domains)
|
|
var cleanupPlan = inspector.GenerateCleanupPlan(targetBytesToFree: 300 * 1024 * 1024);
|
|
Assert.NotEmpty(cleanupPlan);
|
|
long plannedReclamation = cleanupPlan.Sum(p => p.TargetBytesToReclaim);
|
|
Assert.True(plannedReclamation >= 300 * 1024 * 1024);
|
|
// heavy.webapp.org is oldest (-30 days), should be cleaned first
|
|
Assert.Equal("heavy.webapp.org", cleanupPlan[0].Domain);
|
|
|
|
// Act 5: Execute cleanup order and verify state mutation
|
|
long freed = inspector.ExecuteCleanupOrder(videoOrder);
|
|
Assert.True(freed > 0);
|
|
var updatedVideo = inspector.GetUsage("video.streaming.com");
|
|
Assert.NotNull(updatedVideo);
|
|
Assert.True(updatedVideo.TotalBytes < 350 * 1024 * 1024);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate198_DoNotDisturbNotificationPolicy_SuppressesPushDuringQuietHoursAndFullscreen()
|
|
{
|
|
// Arrange
|
|
var policy = new DoNotDisturbNotificationPolicy(
|
|
quietHoursStart: new TimeOnly(22, 0), // 10:00 PM
|
|
quietHoursEnd: new TimeOnly(7, 0), // 07:00 AM
|
|
quietHoursEnabled: true,
|
|
suppressDuringFullscreen: true);
|
|
|
|
policy.AddWhitelistedOrigin("https://critical-alerts.corp.internal");
|
|
|
|
// Act & Assert 1: Day time (14:30) outside quiet hours, windowed mode -> Allow
|
|
var resDay = policy.EvaluateNotification(
|
|
origin: "https://social.example.com",
|
|
isFullscreen: false,
|
|
currentTime: new TimeOnly(14, 30),
|
|
priority: NotificationPriority.Normal);
|
|
Assert.False(resDay.IsSuppressed);
|
|
Assert.Equal(DndSuppressionReason.None, resDay.Reason);
|
|
Assert.Equal(NotificationDeliveryAction.DeliverImmediately, resDay.Action);
|
|
|
|
// Act & Assert 2: Quiet hours (23:15) -> Suppress & Queue
|
|
var resNight = policy.EvaluateNotification(
|
|
origin: "https://social.example.com",
|
|
isFullscreen: false,
|
|
currentTime: new TimeOnly(23, 15),
|
|
priority: NotificationPriority.Normal);
|
|
Assert.True(resNight.IsSuppressed);
|
|
Assert.Equal(DndSuppressionReason.QuietHours, resNight.Reason);
|
|
Assert.Equal(NotificationDeliveryAction.QueueForLater, resNight.Action);
|
|
|
|
// Act & Assert 3: Fullscreen active during daytime (15:00) -> Suppress
|
|
var resFullscreen = policy.EvaluateNotification(
|
|
origin: "https://social.example.com",
|
|
isFullscreen: true,
|
|
currentTime: new TimeOnly(15, 0),
|
|
priority: NotificationPriority.Normal);
|
|
Assert.True(resFullscreen.IsSuppressed);
|
|
Assert.Equal(DndSuppressionReason.Fullscreen, resFullscreen.Reason);
|
|
|
|
// Act & Assert 4: Whitelisted origin bypasses quiet hours and fullscreen
|
|
var resWhitelisted = policy.EvaluateNotification(
|
|
origin: "https://critical-alerts.corp.internal",
|
|
isFullscreen: true,
|
|
currentTime: new TimeOnly(3, 30),
|
|
priority: NotificationPriority.Normal);
|
|
Assert.False(resWhitelisted.IsSuppressed);
|
|
Assert.Equal(NotificationDeliveryAction.DeliverImmediately, resWhitelisted.Action);
|
|
|
|
// Act & Assert 5: Critical priority bypasses DND
|
|
var resCritical = policy.EvaluateNotification(
|
|
origin: "https://random.news.com",
|
|
isFullscreen: true,
|
|
currentTime: new TimeOnly(2, 0),
|
|
priority: NotificationPriority.Critical);
|
|
Assert.False(resCritical.IsSuppressed);
|
|
|
|
// Act & Assert 6: Queuing and flushing suppressed notifications
|
|
var item1 = new WebPushNotification("notif-1", "https://social.example.com", "New Message", "Hello", NotificationPriority.Normal, DateTime.UtcNow);
|
|
policy.EnqueueSuppressedNotification(item1);
|
|
Assert.Single(policy.GetSuppressedQueue());
|
|
|
|
var flushed = policy.FlushSuppressedQueue();
|
|
Assert.Single(flushed);
|
|
Assert.Equal("notif-1", flushed[0].Id);
|
|
Assert.Empty(policy.GetSuppressedQueue());
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate199_PrintBackgroundGraphicsPolicy_PreservesBackgroundColorsImagesAndBorders()
|
|
{
|
|
// Arrange
|
|
var policy = new PrintBackgroundGraphicsPolicy
|
|
{
|
|
PreserveBackgroundGraphics = true,
|
|
PreserveBackgroundColors = true,
|
|
PreserveBackgroundImages = true,
|
|
PreserveBorders = true,
|
|
ColorMode = PrintColorMode.Color,
|
|
Orientation = PrintOrientation.Portrait,
|
|
DisplayHeaderFooter = false
|
|
};
|
|
|
|
// Act 1: Verify CSS generator outputs exact color adjust rules
|
|
string css = policy.GeneratePrintCss();
|
|
Assert.Contains("-webkit-print-color-adjust: exact", css);
|
|
Assert.Contains("print-color-adjust: exact", css);
|
|
Assert.Contains("@media print", css);
|
|
Assert.Contains("background-color", css);
|
|
Assert.Contains("background-image", css);
|
|
|
|
// Act 2: Export CDP / WebView2 print parameters
|
|
var cdpParams = policy.ToChromiumPrintParameters();
|
|
Assert.True(cdpParams.ContainsKey("printBackground"));
|
|
Assert.True((bool)cdpParams["printBackground"]);
|
|
Assert.False((bool)cdpParams["displayHeaderFooter"]);
|
|
Assert.False((bool)cdpParams["landscape"]); // Portrait
|
|
|
|
// Act 3: JSON persistence / serialization
|
|
string jsonPath = Path.Combine(_tempDir, "print_policy.json");
|
|
policy.SaveToFile(jsonPath);
|
|
Assert.True(File.Exists(jsonPath));
|
|
|
|
var loaded = PrintBackgroundGraphicsPolicy.LoadFromFile(jsonPath);
|
|
Assert.True(loaded.PreserveBackgroundGraphics);
|
|
Assert.True(loaded.PreserveBackgroundColors);
|
|
Assert.True(loaded.PreserveBackgroundImages);
|
|
Assert.True(loaded.PreserveBorders);
|
|
Assert.Equal(PrintColorMode.Color, loaded.ColorMode);
|
|
|
|
// Act 4: Turning off graphics preservation
|
|
policy.PreserveBackgroundGraphics = false;
|
|
var offParams = policy.ToChromiumPrintParameters();
|
|
Assert.False((bool)offParams["printBackground"]);
|
|
string offCss = policy.GeneratePrintCss();
|
|
Assert.DoesNotContain("-webkit-print-color-adjust: exact", offCss);
|
|
}
|
|
|
|
[Fact]
|
|
public void Gate200_AdvancedProcessTaskInspector_CategorizesProcessesAndComputesResourceUsage()
|
|
{
|
|
// Arrange
|
|
var inspector = new AdvancedProcessTaskInspector();
|
|
|
|
// Act 1: Categorization logic for command line switches
|
|
Assert.Equal(BrowserProcessCategory.Browser, inspector.CategorizeProcess("PacaBrowser.exe", ""));
|
|
Assert.Equal(BrowserProcessCategory.Gpu, inspector.CategorizeProcess("PacaBrowser.exe", "--type=gpu-process --enable-vulkan"));
|
|
Assert.Equal(BrowserProcessCategory.Renderer, inspector.CategorizeProcess("PacaBrowser.exe", "--type=renderer --site-per-process"));
|
|
Assert.Equal(BrowserProcessCategory.Audio, inspector.CategorizeProcess("PacaBrowser.exe", "--type=utility --utility-sub-type=audio.mojom.AudioService"));
|
|
Assert.Equal(BrowserProcessCategory.Utility, inspector.CategorizeProcess("PacaBrowser.exe", "--type=utility --utility-sub-type=network.mojom.NetworkService"));
|
|
|
|
// Act 2: Register task descriptors
|
|
inspector.RegisterProcess(new ProcessTaskSnapshot(
|
|
ProcessId: 1001,
|
|
ProcessName: "PacaBrowser.exe",
|
|
Category: BrowserProcessCategory.Browser,
|
|
Title: "Browser Main (Paca)",
|
|
CommandLine: "",
|
|
WorkingSetBytes: 150 * 1024 * 1024,
|
|
CpuPercent: 1.5));
|
|
|
|
inspector.RegisterProcess(new ProcessTaskSnapshot(
|
|
ProcessId: 1002,
|
|
ProcessName: "PacaBrowser.exe",
|
|
Category: BrowserProcessCategory.Gpu,
|
|
Title: "GPU Process",
|
|
CommandLine: "--type=gpu-process",
|
|
WorkingSetBytes: 100 * 1024 * 1024,
|
|
CpuPercent: 2.0));
|
|
|
|
inspector.RegisterProcess(new ProcessTaskSnapshot(
|
|
ProcessId: 1003,
|
|
ProcessName: "PacaBrowser.exe",
|
|
Category: BrowserProcessCategory.Renderer,
|
|
Title: "Tab: Dashboard",
|
|
CommandLine: "--type=renderer",
|
|
WorkingSetBytes: 250 * 1024 * 1024,
|
|
CpuPercent: 8.5));
|
|
|
|
inspector.RegisterProcess(new ProcessTaskSnapshot(
|
|
ProcessId: 1004,
|
|
ProcessName: "PacaBrowser.exe",
|
|
Category: BrowserProcessCategory.Audio,
|
|
Title: "Audio Service",
|
|
CommandLine: "--type=utility --utility-sub-type=audio.mojom.AudioService",
|
|
WorkingSetBytes: 30 * 1024 * 1024,
|
|
CpuPercent: 0.5));
|
|
|
|
inspector.RegisterProcess(new ProcessTaskSnapshot(
|
|
ProcessId: 1005,
|
|
ProcessName: "PacaBrowser.exe",
|
|
Category: BrowserProcessCategory.Utility,
|
|
Title: "Network Service",
|
|
CommandLine: "--type=utility --utility-sub-type=network.mojom.NetworkService",
|
|
WorkingSetBytes: 45 * 1024 * 1024,
|
|
CpuPercent: 0.8));
|
|
|
|
// Act 3: Aggregation and metrics
|
|
Assert.Equal(575 * 1024 * 1024, inspector.GetTotalWorkingSetBytes());
|
|
Assert.Equal(13.3, Math.Round(inspector.GetTotalCpuPercent(), 1));
|
|
|
|
// Category filtering
|
|
var renderers = inspector.GetProcessesByCategory(BrowserProcessCategory.Renderer);
|
|
Assert.Single(renderers);
|
|
Assert.Equal("Tab: Dashboard", renderers[0].Title);
|
|
|
|
var audioList = inspector.GetProcessesByCategory(BrowserProcessCategory.Audio);
|
|
Assert.Single(audioList);
|
|
Assert.Equal(BrowserProcessCategory.Audio, audioList[0].Category);
|
|
|
|
// Act 4: Group summary by category
|
|
var summary = inspector.GetCategorySummaries();
|
|
Assert.Equal(5, summary.Count);
|
|
var rendererSummary = summary.First(s => s.Category == BrowserProcessCategory.Renderer);
|
|
Assert.Equal(1, rendererSummary.ProcessCount);
|
|
Assert.Equal(250 * 1024 * 1024, rendererSummary.TotalWorkingSetBytes);
|
|
Assert.Equal(8.5, rendererSummary.TotalCpuPercent);
|
|
|
|
// Act 5: Heavy process detection (thresholds: 200MB or 5% CPU)
|
|
var heavy = inspector.FindHeavyProcesses(memoryThresholdBytes: 200 * 1024 * 1024, cpuThresholdPercent: 5.0);
|
|
Assert.Single(heavy);
|
|
Assert.Equal(1003, heavy[0].ProcessId);
|
|
|
|
// Act 6: Sorting
|
|
var sortedByMem = inspector.Sort(ProcessSortColumn.WorkingSet, descending: true);
|
|
Assert.Equal(1003, sortedByMem[0].ProcessId); // 250MB
|
|
Assert.Equal(1004, sortedByMem.Last().ProcessId); // 30MB
|
|
}
|
|
}
|