T1-4/T1-7: search engine resolver + site zoom store (TDD RED->GREEN, 32 tests)

This commit is contained in:
Yun Chan 2026-08-15 12:04:23 +09:00
parent 21b7c4d8bd
commit 470c9267f2
9 changed files with 522 additions and 0 deletions

View file

@ -0,0 +1,106 @@
using System.IO;
using System.Text.Json;
namespace VideoDownloader.App.Browser;
/// <summary>
/// 사이트별 줌 배율 영속 저장소(T1-7). WebView2 는 사이트별 줌 영속을 보장하지 않아
/// 앱이 도메인→배율 사전을 SSOT 로 관리한다. LRU 300개, 저장 즉시 원자적 파일 기록.
/// </summary>
public sealed class SiteZoomStore
{
private sealed record Entry(double Zoom, long LastUsedTicks);
private const int MaxEntries = 300;
private const double Min = 0.1, Max = 5.0;
private readonly string _path;
private readonly object _lock = new();
private readonly Dictionary<string, Entry> _map = new(StringComparer.OrdinalIgnoreCase);
public SiteZoomStore(string path)
{
_path = path;
try
{
if (File.Exists(path))
{
var loaded = JsonSerializer.Deserialize<Dictionary<string, Entry>>(File.ReadAllText(path));
if (loaded != null)
foreach (var (k, v) in loaded) _map[k] = v;
}
}
catch
{
// 손상된 JSON → 빈 상태로 시작(기존 BrowserStore 정책과 동일)
_map.Clear();
}
}
public int Count { get { lock (_lock) return _map.Count; } }
/// <summary>URL 의 호스트에 저장된 배율. 없으면 1.0. 읽기는 LRU 를 갱신한다.</summary>
public double Get(string? url)
{
var host = NormalizeHost(url);
if (host == null) return 1.0;
lock (_lock)
{
if (_map.TryGetValue(host, out var e))
{
_map[host] = e with { LastUsedTicks = DateTime.UtcNow.Ticks };
return e.Zoom;
}
}
return 1.0;
}
/// <summary>배율 저장(0.1~5.0 클램프). 초과분은 가장 오래된 사용 순으로 제거 후 즉시 영속.</summary>
public void Set(string? url, double zoom)
{
var host = NormalizeHost(url);
if (host == null) return;
zoom = Math.Clamp(zoom, Min, Max);
lock (_lock)
{
_map[host] = new Entry(zoom, DateTime.UtcNow.Ticks);
if (_map.Count > MaxEntries)
foreach (var k in _map.OrderBy(kv => kv.Value.LastUsedTicks).Take(_map.Count - MaxEntries).Select(kv => kv.Key).ToList())
_map.Remove(k);
Save();
}
}
public void Clear()
{
lock (_lock)
{
_map.Clear();
try { if (File.Exists(_path)) File.Delete(_path); } catch { }
}
}
private void Save()
{
try
{
var dir = Path.GetDirectoryName(_path);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
var tmp = _path + ".tmp";
File.WriteAllText(tmp, JsonSerializer.Serialize(_map));
File.Move(tmp, _path, overwrite: true);
}
catch
{
// 저장 실패는 메모리 상태는 유지(조용한 내구성 저하)
}
}
private static string? NormalizeHost(string? url)
{
if (string.IsNullOrWhiteSpace(url)) return null;
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || string.IsNullOrEmpty(uri.Host)) return null;
var host = uri.Host;
return host.StartsWith("www.", StringComparison.OrdinalIgnoreCase) ? host[4..] : host;
}
}