MEDIABROWSER 개명·업데이트 채널 전환·아이콘 tofu 근본 수정 (BACKLOG 13차) — ①개명: installer.iss AppName/출력 MediaBrowser-Setup-*(AppId·exe·데이터 경로 유지로 업그레이드 호환), 워크플로 산출물명(mediabrowser-*.tar.gz, MediaBrowser-<ver>-arm64.apk), 창 타이틀·모바일 표시명·obtainium.json·README. ②UpdateChecker: apiBase 주입(기본=자체 Forgejo /api/v1, GitHub 는 apiBase 로 호환), osx 자산 tar.gz 허용(기존 dmg 만 → mac 영원히 자산 없음 결함 수정), AppConfig 기본 UpdateRepo=Video-Downloader/releases+UpdateApiBase — 테스트 4건. ③아이콘 tofu(사용자 보고 "절대 안고쳐지네") 근본 원인: Shared.xaml 암시적 TextBlock 스타일의 FontFamily=UiFont 강제가 버튼 ContentPresenter 자동 TextBlock 의 IconFont 상속을 덮어 모든 PUA 글리프가 notdef — 폰트 설치/글리프 존재/HW·SW 렌더/RTB/DPI/런타임 8·10/자체포함·공유 전부 배제 후 실창 픽셀 비교로 격리, 콘텐츠 TextBlock 의 실폰트가 Pretendard 체인임을 테스트로 실증. 수정: 암시 스타일 FontFamily 제거(창 루트 상속으로 통일, PairingWindow/PlayerWindow 는 SetResourceReference), PUA+텍스트 혼합 콘텐츠 7곳+코드 1곳 정리, 순수 글리프 2곳 IconFont 명시. 회귀 방어: IconGlyphXamlTests(정적 스캔)·IconContentFontTests(실창 폰트 단정)·IconGlyphRenderTests(픽셀 비교) — 462/462 GREEN, 수정 전후 실창 캡처로 글리프 렌더 확인
This commit is contained in:
parent
c8b061959b
commit
02995f4153
24 changed files with 451 additions and 64 deletions
71
VideoDownloader.Tests/E2E/IconContentFontTests.cs
Normal file
71
VideoDownloader.Tests/E2E/IconContentFontTests.cs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using VideoDownloader.App;
|
||||
using VideoDownloader.Tests.Infrastructure;
|
||||
|
||||
namespace VideoDownloader.Tests.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// 아이콘 tofu 실결함(사용자 보고 "아이콘은 절대 안고쳐지네") 회귀 테스트 —
|
||||
/// 근본 원인: Shared.xaml 의 암시적 TextBlock 스타일이 FontFamily 를 UiFont 로 강제해,
|
||||
/// 버튼 ContentPresenter 가 만드는 자동 TextBlock 이 IconFont 상속을 잃고 PUA 글리프가 notdef 가 됐다.
|
||||
/// 아이콘 버튼의 실제 콘텐츠 TextBlock 이 아이콘 폰트로 해석되는지 실창에서 검증한다.
|
||||
/// </summary>
|
||||
[Collection("E2E")]
|
||||
public class IconContentFontTests
|
||||
{
|
||||
private static TextBlock? FindTextBlock(DependencyObject root)
|
||||
{
|
||||
if (root is TextBlock tb) return tb;
|
||||
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++)
|
||||
{
|
||||
if (FindTextBlock(VisualTreeHelper.GetChild(root, i)) is { } found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task 아이콘_버튼의_콘텐츠_TextBlock_은_아이콘폰트로_렌더된다()
|
||||
{
|
||||
var dataDir = Path.Combine(Path.GetTempPath(), $"vd_iconfont_{Guid.NewGuid():N}");
|
||||
Environment.SetEnvironmentVariable("VD_DATA_DIR", dataDir);
|
||||
try
|
||||
{
|
||||
using var sta = StaDispatcher.Start();
|
||||
WpfTestApp.Ensure(sta.Dispatcher);
|
||||
var (navFamily, captionFamily, bodyFamily) = await sta.Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
// 앱과 동일 조건: 절대 pack URI 테마 재로드 후 실창
|
||||
VideoDownloader.App.App.ApplyTheme("dark");
|
||||
var win = new MainWindow { Left = 20, Top = 700, WindowStartupLocation = WindowStartupLocation.Manual };
|
||||
win.Show();
|
||||
win.UpdateLayout();
|
||||
|
||||
var nav = (Button)win.FindName("NavBack")!;
|
||||
var navText = FindTextBlock(nav)!;
|
||||
|
||||
// 캡션(최대화/복원) 버튼 — CaptionButton 스타일 경유
|
||||
var caption = win.FindName("MaxRestoreBtn") as Button;
|
||||
var captionText = caption != null ? FindTextBlock(caption) : null;
|
||||
|
||||
// 일반 본문 TextBlock 은 여전히 UI 폰트를 따라야 한다(암시 스타일의 원래 목적)
|
||||
var status = (TextBlock)win.FindName("StatusText")!;
|
||||
|
||||
var result = (navText.FontFamily.Source, captionText?.FontFamily.Source ?? "(캡션 버튼 없음)", status.FontFamily.Source);
|
||||
win.Close();
|
||||
return result;
|
||||
});
|
||||
|
||||
Assert.Equal("Segoe Fluent Icons", navFamily);
|
||||
if (captionFamily != "(캡션 버튼 없음)")
|
||||
Assert.Equal("Segoe Fluent Icons", captionFamily);
|
||||
Assert.Contains("Pretendard", bodyFamily); // 본문은 UI 폰트 유지
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("VD_DATA_DIR", null);
|
||||
try { Directory.Delete(dataDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
116
VideoDownloader.Tests/E2E/IconGlyphRenderTests.cs
Normal file
116
VideoDownloader.Tests/E2E/IconGlyphRenderTests.cs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using VideoDownloader.Tests.Infrastructure;
|
||||
|
||||
namespace VideoDownloader.Tests.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// 아이콘 tofu 실결함(사용자 보고: "아이콘은 절대 안고쳐지네") 재현 시도 —
|
||||
/// 테마 사전을 앱과 같은 방식으로 로드했을 때 FluentIcon/IconButton 경유 글리프가
|
||||
/// 명시적 Segoe Fluent Icons 렌더와 픽셀 단위로 같은지 검증한다.
|
||||
/// 같지 않고 본문 폰트 렌더(tofu)와 같다면 리소스 해석이 깨진 것이다.
|
||||
/// </summary>
|
||||
[Collection("E2E")]
|
||||
public class IconGlyphRenderTests
|
||||
{
|
||||
private static byte[] Render(FrameworkElement el)
|
||||
{
|
||||
el.Measure(new Size(48, 48));
|
||||
el.Arrange(new Rect(0, 0, 48, 48));
|
||||
el.UpdateLayout();
|
||||
var rtb = new RenderTargetBitmap(48, 48, 96, 96, PixelFormats.Pbgra32);
|
||||
rtb.Render(el);
|
||||
var px = new byte[48 * 48 * 4];
|
||||
rtb.CopyPixels(px, 48 * 4, 0);
|
||||
return px;
|
||||
}
|
||||
|
||||
private static int Lit(byte[] px)
|
||||
{
|
||||
var lit = 0;
|
||||
for (var i = 3; i < px.Length; i += 4) if (px[i] > 60) lit++;
|
||||
return lit;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FluentIcon_스타일_글리프는_명시적_아이콘폰트_렌더와_동일하다()
|
||||
{
|
||||
using var sta = StaDispatcher.Start();
|
||||
WpfTestApp.Ensure(sta.Dispatcher);
|
||||
var (family, litStyled, litExplicit, litBody, sameAsExplicit, sameAsBody) = await sta.Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
TextBlock Make() => new()
|
||||
{
|
||||
Text = "", FontSize = 14, Foreground = Brushes.White,
|
||||
Background = Brushes.Black, Width = 48, Height = 48,
|
||||
};
|
||||
|
||||
var styled = Make();
|
||||
styled.Style = (Style)Application.Current.FindResource("FluentIcon");
|
||||
|
||||
var explicitIcon = Make();
|
||||
explicitIcon.FontFamily = new FontFamily("Segoe Fluent Icons");
|
||||
|
||||
var body = Make();
|
||||
body.FontFamily = (FontFamily)Application.Current.FindResource("UiFont");
|
||||
|
||||
var pStyled = Render(styled);
|
||||
var pExplicit = Render(explicitIcon);
|
||||
var pBody = Render(body);
|
||||
|
||||
return (styled.FontFamily.Source, Lit(pStyled), Lit(pExplicit), Lit(pBody),
|
||||
pStyled.SequenceEqual(pExplicit), pStyled.SequenceEqual(pBody));
|
||||
});
|
||||
|
||||
Assert.True(litExplicit > 0, "기준(명시적 Segoe Fluent Icons) 렌더 자체가 비어 있음 — 폰트 미설치?");
|
||||
Assert.True(sameAsExplicit && !sameAsBody,
|
||||
$"FluentIcon 경유 글리프가 아이콘 폰트로 렌더되지 않음 — 해석된 폰트: '{family}', " +
|
||||
$"픽셀(스타일/명시/본문): {litStyled}/{litExplicit}/{litBody}, 본문과 동일: {sameAsBody}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task 앱_시작_시퀀스를_그대로_태워도_FluentIcon_글리프가_렌더된다()
|
||||
{
|
||||
// 실제 App.OnStartup 과 동일한 순서: ApplyTheme(절대 pack URI 재로드) → 강조색 → 폰트 → 모션
|
||||
var dataDir = Path.Combine(Path.GetTempPath(), $"vd_icon_{Guid.NewGuid():N}");
|
||||
Environment.SetEnvironmentVariable("VD_DATA_DIR", dataDir);
|
||||
try
|
||||
{
|
||||
using var sta = StaDispatcher.Start();
|
||||
WpfTestApp.Ensure(sta.Dispatcher);
|
||||
var (family, same, litStyled, litExplicit) = await sta.Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
VideoDownloader.App.App.ApplyTheme("dark");
|
||||
VideoDownloader.App.App.ApplyAccent(null);
|
||||
VideoDownloader.App.App.ApplyFontSettings();
|
||||
VideoDownloader.App.App.ApplyReduceMotion(false);
|
||||
|
||||
var styled = new TextBlock
|
||||
{
|
||||
Text = "\uE76B", FontSize = 14, Foreground = Brushes.White,
|
||||
Background = Brushes.Black, Width = 48, Height = 48,
|
||||
Style = (Style)Application.Current.FindResource("FluentIcon"),
|
||||
};
|
||||
var explicitIcon = new TextBlock
|
||||
{
|
||||
Text = "\uE76B", FontSize = 14, Foreground = Brushes.White,
|
||||
Background = Brushes.Black, Width = 48, Height = 48,
|
||||
FontFamily = new FontFamily("Segoe Fluent Icons"),
|
||||
};
|
||||
var pStyled = Render(styled);
|
||||
var pExplicit = Render(explicitIcon);
|
||||
return (styled.FontFamily.Source, pStyled.SequenceEqual(pExplicit), Lit(pStyled), Lit(pExplicit));
|
||||
});
|
||||
|
||||
Assert.True(same,
|
||||
$"앱 시작 시퀀스 후 FluentIcon 글리프 깨짐 — 해석된 폰트: '{family}', 픽셀(스타일/명시): {litStyled}/{litExplicit}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("VD_DATA_DIR", null);
|
||||
try { Directory.Delete(dataDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
89
VideoDownloader.Tests/Infrastructure/IconGlyphXamlTests.cs
Normal file
89
VideoDownloader.Tests/Infrastructure/IconGlyphXamlTests.cs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace VideoDownloader.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// 아이콘 글리프(PUA, Segoe Fluent Icons)를 본문 폰트 요소에 넣으면 tofu(□)로 렌더된다
|
||||
/// (2026-08-18 실행 캡처로 확인 — "□ 모두 일시정지"). XAML/코드 정적 검사로 재발을 막는다.
|
||||
/// 규칙:
|
||||
/// ① PUA 를 쓰는 Content/Text 는 IconFont 보장 스타일(IconFont 를 설정하는 스타일 또는 그 BasedOn 파생)에
|
||||
/// 있거나 요소에 FontFamily=IconFont 를 명시한다.
|
||||
/// ② 한 문자열에 PUA 와 일반 텍스트를 섞지 않는다 — 폰트가 하나뿐이라 어느 한쪽은 반드시 깨진다.
|
||||
/// </summary>
|
||||
public class IconGlyphXamlTests
|
||||
{
|
||||
private static readonly Regex PuaEntity = new(@"&#x(E[0-9A-Fa-f]{3});", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>Shared.xaml 에서 IconFont 를 보장하는 스타일 키 집합(BasedOn 파생 포함).</summary>
|
||||
private static HashSet<string> IconFontStyles()
|
||||
{
|
||||
var shared = SourceTree.SharedXaml;
|
||||
var direct = new HashSet<string>();
|
||||
var basedOn = new Dictionary<string, string>();
|
||||
foreach (Match style in Regex.Matches(shared, "<Style x:Key=\"(\\w+)\"([^>]*)>(.*?)</Style>", RegexOptions.Singleline))
|
||||
{
|
||||
var key = style.Groups[1].Value;
|
||||
if (style.Groups[3].Value.Contains("IconFont")) direct.Add(key);
|
||||
var b = Regex.Match(style.Groups[2].Value, "BasedOn=\"\\{StaticResource (\\w+)\\}\"");
|
||||
if (b.Success) basedOn[key] = b.Groups[1].Value;
|
||||
}
|
||||
// BasedOn 체인 전개 (파생이 부모의 FontFamily 세터를 상속)
|
||||
bool grew;
|
||||
do
|
||||
{
|
||||
grew = false;
|
||||
foreach (var (child, parent) in basedOn)
|
||||
if (!direct.Contains(child) && direct.Contains(parent)) { direct.Add(child); grew = true; }
|
||||
} while (grew);
|
||||
return direct;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> Violations(string xaml, string fileName)
|
||||
{
|
||||
var iconStyles = IconFontStyles();
|
||||
// 여는 태그 단위로 검사 — Button/TextBlock/Run 등 텍스트 표시 요소
|
||||
foreach (Match tag in Regex.Matches(xaml, @"<(Button|TextBlock|Run|Label)\b[^>]*?>", RegexOptions.Singleline))
|
||||
{
|
||||
var open = tag.Value;
|
||||
foreach (Match attr in Regex.Matches(open, "(?:Content|Text)=\"([^\"]*)\""))
|
||||
{
|
||||
var value = attr.Groups[1].Value;
|
||||
if (!PuaEntity.IsMatch(value)) continue;
|
||||
var line = xaml[..tag.Index].Count(c => c == '\n') + 1;
|
||||
|
||||
var stripped = PuaEntity.Replace(value, "").Trim();
|
||||
if (stripped.Length > 0)
|
||||
{
|
||||
yield return $"{fileName} L{line}: PUA+일반 텍스트 혼합 \"{value}\" — 한 폰트로는 둘 다 못 그린다";
|
||||
continue;
|
||||
}
|
||||
|
||||
var styleMatch = Regex.Match(open, "Style=\"\\{(?:StaticResource|DynamicResource) (\\w+)\\}\"");
|
||||
var styled = styleMatch.Success && iconStyles.Contains(styleMatch.Groups[1].Value);
|
||||
var explicitFont = open.Contains("FontFamily=\"{DynamicResource IconFont}\"")
|
||||
|| open.Contains("FontFamily=\"{StaticResource IconFont}\"");
|
||||
if (!styled && !explicitFont)
|
||||
yield return $"{fileName} L{line}: PUA 글리프 \"{value}\" 가 IconFont 보장 없이 사용됨(본문 폰트 → tofu)";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void XAML_의_PUA_글리프는_IconFont_보장_하에서만_쓴다()
|
||||
{
|
||||
var violations = Violations(SourceTree.MainWindowXaml, "MainWindow.xaml")
|
||||
.Concat(Violations(SourceTree.SettingsWindowXaml, "SettingsWindow.xaml"))
|
||||
.ToList();
|
||||
Assert.True(violations.Count == 0, "tofu 유발 지점:\n" + string.Join("\n", violations));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 코드비하인드도_PUA_와_일반_텍스트를_섞지_않는다()
|
||||
{
|
||||
// 예: LibraryViewToggle.Content = "; 그리드" — 본문 폰트 버튼이라 tofu + 잉여 세미콜론 노출
|
||||
var offenders = Regex.Matches(SourceTree.MainWindowCs + File.ReadAllText(SourceTree.AppPath("MainWindow.Usecases.cs")),
|
||||
@"""\\uE[0-9A-Fa-f]{3}[^""]*[가-힣A-Za-z][^""]*""")
|
||||
.Select(m => m.Value).ToList();
|
||||
Assert.True(offenders.Count == 0, "코드 내 혼합 문자열:\n" + string.Join("\n", offenders));
|
||||
}
|
||||
}
|
||||
|
|
@ -80,8 +80,8 @@ public class UpdateCheckerTests
|
|||
Assert.Equal("https://github.com/user/videodownloader/releases/tag/v1.2.0", result.ReleaseUrl);
|
||||
Assert.Equal("VideoDownloader-Setup-x64.exe", result.Asset!.Name);
|
||||
|
||||
// GitHub API 규약: User-Agent 필수 + 올바른 엔드포인트
|
||||
Assert.Equal("https://api.github.com/repos/user/videodownloader/releases/latest", handler.LastRequest!.RequestUri!.ToString());
|
||||
// 기본 채널(자체 Forgejo) 엔드포인트 + User-Agent(GitHub 채널 호환 규약 유지)
|
||||
Assert.Equal("https://git.chanpaca.net/api/v1/repos/user/videodownloader/releases/latest", handler.LastRequest!.RequestUri!.ToString());
|
||||
Assert.NotEmpty(handler.LastRequest.Headers.UserAgent);
|
||||
}
|
||||
|
||||
|
|
@ -99,4 +99,56 @@ public class UpdateCheckerTests
|
|||
var checker = new UpdateChecker(new FakeHandler(HttpStatusCode.NotFound, """{"message":"Not Found"}"""));
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => checker.CheckAsync("user/none", "1.0.0", "win-x64"));
|
||||
}
|
||||
|
||||
// ── 배포 채널 전환 (DEPLOYMENT_PLAN §5-bis/§5-ter): 기본 = 자체 Forgejo 공개 릴리스 저장소 ──
|
||||
|
||||
[Fact]
|
||||
public async Task 최신확인_기본은_Forgejo_공개저장소_API()
|
||||
{
|
||||
var handler = new FakeHandler(HttpStatusCode.OK, ReleaseJson);
|
||||
var checker = new UpdateChecker(handler);
|
||||
|
||||
await checker.CheckAsync("Video-Downloader/releases", "1.0.0", "win-x64");
|
||||
|
||||
// Forgejo 는 /api/v1 접두가 필요 — GitHub 형태(api.github.com/repos/...)와 경로 규칙만 다르다
|
||||
Assert.Equal(
|
||||
"https://git.chanpaca.net/api/v1/repos/Video-Downloader/releases/releases/latest",
|
||||
handler.LastRequest!.RequestUri!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task 최신확인_apiBase_지정으로_GitHub_채널도_호환()
|
||||
{
|
||||
var handler = new FakeHandler(HttpStatusCode.OK, ReleaseJson);
|
||||
var checker = new UpdateChecker(handler);
|
||||
|
||||
var result = await checker.CheckAsync("user/videodownloader", "1.0.0", "win-x64",
|
||||
apiBase: "https://api.github.com");
|
||||
|
||||
Assert.True(result!.IsNewer);
|
||||
Assert.Equal("https://api.github.com/repos/user/videodownloader/releases/latest",
|
||||
handler.LastRequest!.RequestUri!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 자산선택_osx는_tar_gz_도_허용()
|
||||
{
|
||||
// macOS 프리뷰는 dmg 가 아니라 tar.gz 로 배포 중 — dmg 만 허용하면 mac 은 영원히 자산 없음
|
||||
var assets = new[]
|
||||
{
|
||||
new UpdateAsset("mediabrowser-osx-arm64.tar.gz", "https://dl/osx-arm64"),
|
||||
new UpdateAsset("mediabrowser-osx-x64.tar.gz", "https://dl/osx-x64"),
|
||||
};
|
||||
|
||||
Assert.Equal("https://dl/osx-arm64", UpdateChecker.SelectAsset(assets, "osx-arm64")!.DownloadUrl);
|
||||
Assert.Equal("https://dl/osx-x64", UpdateChecker.SelectAsset(assets, "osx-x64")!.DownloadUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 업데이트_기본채널은_공개릴리스저장소()
|
||||
{
|
||||
var config = new VideoDownloader.Core.Config.AppConfig();
|
||||
Assert.Equal("Video-Downloader/releases", config.UpdateRepo);
|
||||
Assert.Equal("https://git.chanpaca.net/api/v1", config.UpdateApiBase);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue