using System; using System.IO; using Paca.Mobile.Core; using Xunit; namespace Paca.Tests.Mobile; public class MobileOrganicSafeAreaAndScrollGateTests { private static readonly string RepoRoot = FindRepoRoot(); private static readonly string MobileCoreDir = Path.Combine(RepoRoot, "src", "Paca.Mobile.Core"); private static readonly string MobileDir = Path.Combine(RepoRoot, "src", "Paca.Mobile"); private static string FindRepoRoot() { var dir = AppContext.BaseDirectory; while (!string.IsNullOrEmpty(dir)) { if (File.Exists(Path.Combine(dir, "Paca.slnx")) || Directory.Exists(Path.Combine(dir, "src", "Paca.Mobile.Core"))) return dir; var parent = Directory.GetParent(dir); if (parent == null) break; dir = parent.FullName; } return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..")); } [Fact] public void Gate186_ThemeColorParser_HexRgbNamedAndLuminance_Audit() { // 1. Hex parsing Assert.True(MobileThemeColorParser.TryParseColor("#FF0000", out var red)); Assert.Equal(255, red.R); Assert.Equal(0, red.G); Assert.Equal(0, red.B); Assert.True(MobileThemeColorParser.TryParseColor("#03C75A", out var naverGreen)); Assert.Equal(3, naverGreen.R); Assert.Equal(199, naverGreen.G); Assert.Equal(90, naverGreen.B); // 2. Short hex #RGB Assert.True(MobileThemeColorParser.TryParseColor("#0F0", out var shortGreen)); Assert.Equal(0, shortGreen.R); Assert.Equal(255, shortGreen.G); Assert.Equal(0, shortGreen.B); // 3. RGB & RGBA formats Assert.True(MobileThemeColorParser.TryParseColor("rgb(36, 41, 47)", out var githubDark)); Assert.Equal(36, githubDark.R); Assert.Equal(41, githubDark.G); Assert.Equal(47, githubDark.B); Assert.True(MobileThemeColorParser.TryParseColor("rgba(255, 255, 255, 0.9)", out var whiteRgba)); Assert.Equal(255, whiteRgba.R); Assert.Equal(255, whiteRgba.G); Assert.Equal(255, whiteRgba.B); // 4. Named colors Assert.True(MobileThemeColorParser.TryParseColor("black", out var black)); Assert.Equal(0, black.R); Assert.Equal(0, black.G); Assert.Equal(0, black.B); // 5. Invalid string handling (fallback) Assert.False(MobileThemeColorParser.TryParseColor("invalid-not-a-color", out _)); Assert.False(MobileThemeColorParser.TryParseColor("", out _)); Assert.False(MobileThemeColorParser.TryParseColor(null, out _)); // 6. Luminance & isDark calculation Assert.True(MobileThemeColorParser.IsDarkColor(black)); Assert.False(MobileThemeColorParser.IsDarkColor(whiteRgba)); Assert.True(MobileThemeColorParser.IsDarkColor(githubDark)); // 7. Adaptive UI palette generation var palette = MobileThemeColorParser.CreateAdaptivePalette(naverGreen); Assert.NotNull(palette); Assert.False(palette.IsDarkBackground); Assert.False(string.IsNullOrWhiteSpace(palette.StatusBarHex)); Assert.False(string.IsNullOrWhiteSpace(palette.CapsuleBackgroundHex)); Assert.False(string.IsNullOrWhiteSpace(palette.TextPrimaryHex)); } [Fact] public void Gate187_MobileScrollCoordinator_AutoHideAndRestoreLogic_Audit() { var coordinator = new MobileScrollCoordinator(); // 초기 상태는 항상 표시(Visible) Assert.True(coordinator.IsTopBarVisible); // 새 탭(NTP) 화면에서는 스크롤해도 항상 표시 유지 var ntpAction = coordinator.ProcessScroll(scrollY: 300, deltaY: 50, isNewTabPage: true, isInputFocused: false); Assert.Equal(TopBarScrollAction.KeepVisible, ntpAction); Assert.True(coordinator.IsTopBarVisible); // URL 입력창에 포커스 중일 때도 항상 표시 유지 var focusAction = coordinator.ProcessScroll(scrollY: 300, deltaY: 50, isNewTabPage: false, isInputFocused: true); Assert.Equal(TopBarScrollAction.KeepVisible, focusAction); Assert.True(coordinator.IsTopBarVisible); // 미세 스크롤 노이즈(이동량 < 15px) 무시 var noiseAction = coordinator.ProcessScroll(scrollY: 100, deltaY: 8, isNewTabPage: false, isInputFocused: false); Assert.Equal(TopBarScrollAction.None, noiseAction); Assert.True(coordinator.IsTopBarVisible); // 아래로 유의미하게 스크롤 (deltaY > 20, scrollY > 80) -> 숨김(Hide) 트리거 var hideAction = coordinator.ProcessScroll(scrollY: 200, deltaY: 35, isNewTabPage: false, isInputFocused: false); Assert.Equal(TopBarScrollAction.Hide, hideAction); Assert.False(coordinator.IsTopBarVisible); // 숨겨진 상태에서 계속 아래로 스크롤하면 중복 Hide 방지 (None) var keepHideAction = coordinator.ProcessScroll(scrollY: 350, deltaY: 40, isNewTabPage: false, isInputFocused: false); Assert.Equal(TopBarScrollAction.None, keepHideAction); Assert.False(coordinator.IsTopBarVisible); // 위로 스크롤 (deltaY < -15) -> 즉각 표시(Show) 트리거 var showAction = coordinator.ProcessScroll(scrollY: 300, deltaY: -25, isNewTabPage: false, isInputFocused: false); Assert.Equal(TopBarScrollAction.Show, showAction); Assert.True(coordinator.IsTopBarVisible); // 숨겨진 상태에서 최상단 복귀 (scrollY <= 20) -> 강제 표시 coordinator.ProcessScroll(scrollY: 500, deltaY: 50, isNewTabPage: false, isInputFocused: false); Assert.False(coordinator.IsTopBarVisible); var topAction = coordinator.ProcessScroll(scrollY: 10, deltaY: -5, isNewTabPage: false, isInputFocused: false); Assert.Equal(TopBarScrollAction.Show, topAction); Assert.True(coordinator.IsTopBarVisible); } [Fact] public void Gate188_OrganicSafeArea_ThemeSync_And_PlatformContracts_Audit() { var browserCs = File.ReadAllText(Path.Combine(MobileDir, "BrowserPage.cs")); var customizerCs = File.ReadAllText(Path.Combine(MobileDir, "Platforms", "Android", "PacaAndroidWebViewCustomizer.cs")); var statusBarHelperExists = File.Exists(Path.Combine(MobileDir, "Platforms", "Android", "PacaAndroidStatusBarHelper.cs")); Assert.True(statusBarHelperExists, "PacaAndroidStatusBarHelper.cs 가 존재해야 합니다."); // 1. DOM theme-color 메타 태그 추출 스크립트 계약 감사 Assert.Contains("meta[name=\\\"theme-color\\\"]", browserCs); // 2. Android 상태바 색상 및 인셋 동기화 호출 감사 Assert.Contains("PacaAndroidStatusBarHelper.SetStatusBarTheme", browserCs); // 3. Webview 네이티브 스크롤 리스너 연동 감사 Assert.Contains("IOnScrollChangeListener", customizerCs); Assert.Contains("OnScrollChange", customizerCs); Assert.Contains("MobileScrollCoordinator", browserCs); } [Fact] public void Gate189_WindowInsets_Performance_And_NoLayoutLoop_Audit() { var statusBarHelperCs = File.ReadAllText(Path.Combine(MobileDir, "Platforms", "Android", "PacaAndroidStatusBarHelper.cs")); // ANR 방지 헌법: OnGlobalLayoutListener 사용 엄격 금지 (뷰 레이아웃 트래버설마다 호출되어 UI 루퍼 스타베이션 및 10001ms ANR 유발) Assert.DoesNotContain("IOnGlobalLayoutListener", statusBarHelperCs); Assert.DoesNotContain("AddOnGlobalLayoutListener", statusBarHelperCs); // 정답 아키텍처: AndroidX SetOnApplyWindowInsetsListener 사용 필수 (시스템 바/키보드 인셋 변동 시에만 배타적 트리거) Assert.Contains("SetOnApplyWindowInsetsListener", statusBarHelperCs); Assert.Contains("IOnApplyWindowInsetsListener", statusBarHelperCs); } [Fact] public void Gate190_FloatingOverlayLayout_And_NoWebViewVerticalJolt_Audit() { var browserCs = File.ReadAllText(Path.Combine(MobileDir, "BrowserPage.cs")); // 1. Full-Bleed 플로팅 오버레이 아키텍처: 상단 바 컨테이너(_topContainer)와 하단 툴바(_bottomContainer) 분리 Assert.Contains("_topContainer", browserCs); Assert.Contains("LayoutOptions.Start", browserCs); Assert.Contains("LayoutOptions.End", browserCs); // 2. 웹뷰는 컨테이너 이동(Jolt) 금지: webViewNative를 위로 밀어올려 하단에 검은 틈을 만드는 버그 원천 차단 Assert.DoesNotContain("webViewNative.Animate().TranslationY(-height)", browserCs); Assert.DoesNotContain("webViewNative?.Animate()?.TranslationY(-height)", browserCs); Assert.Contains("webViewNative.TranslationY = 0f", browserCs); // 3. 하단 GNB 툴바 동기화 애니메이션: 스크롤 다운 시 하단으로 슬라이드 아웃, 스크롤 업 시 0으로 복귀 Assert.Contains("bottomNative?.Animate()", browserCs); Assert.Contains("TranslationY(bottomHeight)", browserCs); } [Fact] public void Gate191_GnbFloatingPill_OverWebview_ZeroBlackGap_And_EncodingSanitization_Audit() { var browserCs = File.ReadAllText(Path.Combine(MobileDir, "BrowserPage.cs")); var parserCs = File.ReadAllText(Path.Combine(MobileCoreDir, "VideoSnifferParser.cs")); // 1. 하단 GNB는 웹페이지 위에 둥둥 떠 있는 투명 컨테이너 구조여야 함 (뒤의 검은 공간 완전 제거) Assert.Contains("_bottomContainer.Children.Add(_bottomBar)", browserCs); Assert.Contains("BackgroundColor = Colors.Transparent", browserCs); // 2. 문자열 인코딩 및 타이틀/URL 새니타이저 연동 감사 Assert.Contains("MobileTextEncodingSanitizer.CleanDecodedString", browserCs); Assert.Contains("MobileTextEncodingSanitizer.FormatUrlForDisplay", browserCs); Assert.Contains("MobileTextEncodingSanitizer.CleanDecodedString", parserCs); } }