가림막이 실제로는 안 깔리고 있었다 — 판정 시점을 고친다

직전 커밋(d9c1eaa)은 코드만 멀쩡했고 화면에는 아무 일도 일어나지 않았다.

ShowDialog() 는 창을 먼저 보이고(그때 Loaded 가 온다) <b>그다음에</b> 모달 루프에 들어간다.
그래서 Loaded 에서 읽는 ComponentDispatcher.IsThreadModal 은 아직 false 다 —
가드에 걸려 매번 그냥 반환했다. 모달 프레임이 돌기 시작한 뒤로 판정을 미룬다.
한 프레임 늦게 깔리는 것은 눈에 띄지 않는다.

이런 종류는 코드를 다시 읽어서는 안 잡힌다. 열어 봐야 안다.
그래서 --modal-check 를 만들었다: 대화상자를 실제로 ShowDialog 로 띄우고
그 순간 가림막 창이 화면에 존재하는지 센 뒤 닫는다.

이 진단을 만들면서 두 번 헛다리를 짚었고, 둘 다 검사가 스스로를 속인 경우다.
· BeginInvoke 로 세면 그 호출이 Loaded 보다 먼저 큐에 들어가 가림막을 거는 콜백보다
  앞서 실행된다 — 항상 0 이 나온다. 타이머로 바꿨다.
· 진단 분기는 OnStartup 의 전역 배선보다 먼저 반환하므로 진단 모드에는 그 훅이 없다.
  검사가 같은 배선을 직접 걸어야 프로덕션과 같은 것을 잰다.

실측: Loaded 시점 IsThreadModal=False / 모달 루프 안 True / 떠 있는 동안 가림막 1개 / 닫으면 0개.

게이트: 테스트 267/267, --edit-smoke 0실패, --maxrect 10/10, --dialog-shots 57파일,
--db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일, --modal-check 2/2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-14 14:20:02 +09:00
co-authored by Claude Opus 5
parent d9c1eaa293
commit 6ae05b73cd
3 changed files with 146 additions and 1 deletions
+5
View File
@@ -277,6 +277,11 @@ public partial class App : Application
return Diagnostics.SnapShots.Run(args[1]);
}
if (args.Length >= 1 && args[0] == "--modal-check")
{
return Diagnostics.ModalCheck.Run();
}
if (args.Length >= 1 && args[0] == "--maxrect")
{
return Diagnostics.MaximizeCheck.Run(args.Length >= 2 ? args[1] : null);
@@ -0,0 +1,130 @@
using System.Windows;
using System.Windows.Threading;
namespace SheetMe.Designer.Diagnostics;
/// <summary>
/// 모달 가림막 자동 검증 — <c>--modal-check</c>.
///
/// <b>무엇을 재는가.</b> 대화상자를 실제로 <c>ShowDialog()</c> 로 띄우고,
/// 그 순간 화면에 가림막 창이 실제로 존재하는지 센다.
///
/// <b>왜 필요했나.</b> 처음 만든 판정이 틀렸다. <c>ShowDialog()</c> 는 창을 먼저 보이고
/// (그때 Loaded 가 뜬다) <b>그다음에</b> 모달 루프에 들어가므로,
/// Loaded 시점의 <c>ComponentDispatcher.IsThreadModal</c> 은 아직 false 다.
/// 그래서 가림막이 한 번도 안 깔렸는데 코드는 멀쩡해 보였다 — 눈으로 열어 보기 전엔 모른다.
/// </summary>
public static class ModalCheck
{
#region Methods
public static int Run()
{
var lines = new List<string>();
var failed = 0;
void Check(string label, bool ok, string detail = "")
{
lines.Add($"{(ok ? "PASS" : "FAIL")} {label}{(detail.Length > 0 ? " " + detail : string.Empty)}");
if (!ok)
{
failed++;
}
}
try
{
Application.Current.ShutdownMode = ShutdownMode.OnExplicitShutdown;
Services.ThemeManager.Apply(light: true);
// 실제 앱은 OnStartup 에서 이 배선을 건다. 진단 분기는 그보다 먼저 반환하므로
// 검사에서 같은 배선을 직접 걸어야 프로덕션과 같은 것을 재게 된다.
EventManager.RegisterClassHandler(typeof(Window), FrameworkElement.LoadedEvent,
new RoutedEventHandler((sender, _) =>
{
if (sender is Window loaded)
{
Services.ModalScrim.Attach(loaded);
}
}));
// 뒤에 깔릴 창 — 실제 앱에서는 메인 셸이 이 자리다
var owner = new Window
{
Title = "가림막 검사 — 뒤 창",
Width = 600,
Height = 400,
Left = 80,
Top = 80,
ShowInTaskbar = false,
};
owner.Show();
Application.Current.MainWindow = owner;
var dialog = new Window
{
Title = "가림막 검사 — 대화상자",
Width = 300,
Height = 200,
Owner = owner,
ShowInTaskbar = false,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
};
var scrimsWhileOpen = -1;
var modalAtLoaded = false;
dialog.Loaded += (_, _) =>
modalAtLoaded = System.Windows.Interop.ComponentDispatcher.IsThreadModal;
// 대화상자가 떠 있는 동안 창 목록을 세고 닫는다.
// BeginInvoke 로는 안 된다 — 이 호출이 Loaded 보다 먼저 큐에 들어가므로
// 가림막을 거는 콜백보다 앞서 실행돼 항상 0 을 센다(실제로 그렇게 헛다리를 짚었다).
var modalAtProbe = false;
var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(400) };
timer.Tick += (_, _) =>
{
timer.Stop();
modalAtProbe = System.Windows.Interop.ComponentDispatcher.IsThreadModal;
scrimsWhileOpen = CountScrims();
dialog.Close();
};
timer.Start();
dialog.ShowDialog();
lines.Add($"Loaded 시점 IsThreadModal = {modalAtLoaded}");
lines.Add($"모달 루프 안 IsThreadModal = {modalAtProbe}");
lines.Add($"대화상자가 떠 있는 동안의 가림막 수 = {scrimsWhileOpen}");
Check("모달일 때 가림막이 깔린다", scrimsWhileOpen == 1,
$"실제 {scrimsWhileOpen}개");
Check("닫으면 가림막이 걷힌다", CountScrims() == 0, $"실제 {CountScrims()}개");
owner.Close();
}
catch (Exception ex)
{
lines.Add($"EXCEPTION {ex.GetType().Name}: {ex.Message}");
failed++;
}
lines.Add($"결과: 실패 {failed}건");
Console.Error.WriteLine(string.Join(Environment.NewLine, lines));
return failed == 0 ? 0 : 1;
}
/// <summary>가림막은 투명 + 작업표시줄 미표시 + 소유자 있는 창이다</summary>
private static int CountScrims()
{
var count = 0;
foreach (Window window in Application.Current.Windows)
{
if (window.AllowsTransparency && !window.ShowInTaskbar && window.Owner is not null
&& window.WindowStyle == WindowStyle.None)
{
count++;
}
}
return count;
}
#endregion
}
+11 -1
View File
@@ -31,9 +31,19 @@ public static class ModalScrim
/// 이 창이 모달이면 뒤에 가림막을 깐다 — 창이 <b>보이기 시작할 때</b> 불러야 한다.
/// </summary>
public static void Attach(Window window)
{
// 모달 판정을 여기서 바로 하면 안 된다. ShowDialog() 는 창을 먼저 보이고(그때 Loaded 가 온다)
// <b>그다음에</b> 모달 루프에 들어가므로, 이 시점의 IsThreadModal 은 아직 false 다.
// 실제로 그렇게 만들어서 가림막이 한 번도 안 깔렸다(--modal-check 가 그 상태를 잡았다).
// 모달 프레임이 돌기 시작한 뒤에 다시 본다 — 한 프레임 늦게 깔리는 것은 눈에 띄지 않는다.
window.Dispatcher.BeginInvoke(new Action(() => AttachIfModal(window)),
System.Windows.Threading.DispatcherPriority.Background);
}
private static void AttachIfModal(Window window)
{
// 모달이 아닌 창(메인 셸, 진단 렌더용 오프스크린 창)은 대상이 아니다
if (!System.Windows.Interop.ComponentDispatcher.IsThreadModal)
if (!System.Windows.Interop.ComponentDispatcher.IsThreadModal || !window.IsVisible)
{
return;
}