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,129 @@
using System.Reflection;
using System.Text.RegularExpressions;
using Paca.App;
using Paca.Browser;
using Paca.Core.Config;
namespace Paca.Tests.Infrastructure;
/// <summary>
/// UI 유즈케이스 RED 스펙 테스트(docs/UI_USECASES.md)용 소스 접근 헬퍼.
/// 컴파일된 리소스가 아니라 저장소 원본 XAML·코드비하인드를 직접 읽어
/// "UI 복잡 버전" 계약(리소스 토큰·요소·설정 속성 존재)을 검증한다.
/// GREEN 전 단계이므로 대부분의 계약은 의도적으로 실패(RED)한다.
/// </summary>
public static class SourceTree
{
/// <summary>저장소 루트(Paca.slnx 위치). 테스트 bin 에서 위로 올라가 탐색.</summary>
public static string Root { get; } = FindRoot();
private static string FindRoot()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
{
if (File.Exists(Path.Combine(dir.FullName, "Paca.slnx")) || File.Exists(Path.Combine(dir.FullName, "VideoDownloader.slnx")))
return dir.FullName;
}
throw new InvalidOperationException(
$"솔루션 루트를 찾을 수 없습니다 (시작: {AppContext.BaseDirectory}). 빌드 산출물에서 실행 시 저장소 내 bin 디렉터리여야 합니다.");
}
public static string AppPath(string relative)
{
var srcPath = Path.Combine(Root, "src", "Paca.App", relative);
if (File.Exists(srcPath) || Directory.Exists(srcPath)) return srcPath;
return Path.Combine(Root, "Paca.App", relative);
}
public static bool AppFileExists(string relative) => File.Exists(AppPath(relative));
public static bool DocFileExists(string fileName) => File.Exists(Path.Combine(Root, "docs", fileName));
private static readonly Lazy<string> _mainWindowXaml = new(() => File.ReadAllText(AppPath("MainWindow.xaml")));
private static readonly Lazy<string> _settingsXaml = new(() => File.ReadAllText(AppPath("SettingsWindow.xaml")));
private static readonly Lazy<string> _sharedXaml = new(() => File.ReadAllText(AppPath("Themes/Shared.xaml")));
private static readonly Lazy<string> _themeDark = new(() => File.ReadAllText(AppPath("Themes/Dark.xaml")));
private static readonly Lazy<string> _themeLight = new(() => File.ReadAllText(AppPath("Themes/Light.xaml")));
private static readonly Lazy<string> _mainWindowCs = new(() => File.ReadAllText(AppPath("MainWindow.xaml.cs")));
private static readonly Lazy<string> _settingsCs = new(() => File.ReadAllText(AppPath("SettingsWindow.xaml.cs")));
private static readonly Lazy<string> _readerModeCs = new(() => File.ReadAllText(AppPath("Browser/Features/ReaderMode.cs")));
public static string MainWindowXaml => _mainWindowXaml.Value;
public static string SettingsWindowXaml => _settingsXaml.Value;
public static string SharedXaml => _sharedXaml.Value;
public static string ThemeDarkXaml => _themeDark.Value;
public static string ThemeLightXaml => _themeLight.Value;
public static string MainWindowCs => _mainWindowCs.Value;
public static string SettingsWindowCs => _settingsCs.Value;
public static string ReaderModeCs => _readerModeCs.Value;
/// <summary>XAML 리소스 사전에 키(x:Key)가 정의되어 있는지.</summary>
public static bool HasKey(string xaml, string key) => xaml.Contains($"x:Key=\"{key}\"");
/// <summary>SolidColorBrush 키의 색상(#RRGGBB) 조회. 없으면 null.</summary>
public static string? BrushHex(string xaml, string key)
{
var m = Regex.Match(xaml, $"<SolidColorBrush\\s+x:Key=\"{Regex.Escape(key)}\"[^>]*Color=\"#([0-9A-Fa-f]{{6}})\"");
return m.Success ? $"#{m.Groups[1].Value}" : null;
}
/// <summary>sys:Double 키의 숫자 값 조회. 없으면 null.</summary>
public static double? GetDoubleResource(string xaml, string key)
{
var m = Regex.Match(xaml, $"<sys:Double\\s+x:Key=\"{Regex.Escape(key)}\">([0-9.]+)</sys:Double>");
return m.Success && double.TryParse(m.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture, out var val) ? val : null;
}
/// <summary>WCAG 2.x 상대 휘도 기반 명도 대비율(1:1 ~ 21:1).</summary>
public static double ContrastRatio(string hexA, string hexB)
{
var l1 = RelativeLuminance(hexA);
var l2 = RelativeLuminance(hexB);
return (Math.Max(l1, l2) + 0.05) / (Math.Min(l1, l2) + 0.05);
}
private static double RelativeLuminance(string hex)
{
double Channel(int offset) => Convert.ToInt32(hex.Substring(offset, 2), 16) / 255.0;
static double Linearize(double c) => c <= 0.03928 ? c / 12.92 : Math.Pow((c + 0.055) / 1.055, 2.4);
var r = Linearize(Channel(1));
var g = Linearize(Channel(3));
var b = Linearize(Channel(5));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
/// <summary>Core·App·Browser 어셈블리에서 단순 이름으로 형식 탐색(아직 없는 신규 컴포넌트 계약용).</summary>
public static Type? FindType(string simpleName)
{
foreach (var asm in TargetAssemblies())
{
Type[] types;
try
{
types = asm.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
// 일부 형식 로드 실패는 건너뛰고 로드된 형식만 대상으로 한다.
types = ex.Types.Where(t => t is not null).Select(t => t!).ToArray();
}
var found = types.FirstOrDefault(t => t.Name == simpleName);
if (found is not null)
return found;
}
return null;
}
private static IEnumerable<Assembly> TargetAssemblies()
{
yield return typeof(AppConfig).Assembly;
yield return typeof(MainWindow).Assembly;
yield return typeof(TabArchive).Assembly;
}
public static bool PropertyExists(Type type, string name) => UiContractPipeline.HasProperty(type, name);
public static bool MethodExists(Type type, string name) => UiContractPipeline.HasMethod(type, name);
/// <summary>유즈케이스 어설션 — 실패 메시지에 UC ID 와 사용자 스토리를 남긴다.</summary>
public static void Usecase(bool ok, string id, string story) =>
UiContractPipeline.AssertContract(ok, id, story);
}