diff --git a/src/SheetMe.Designer/App.xaml.cs b/src/SheetMe.Designer/App.xaml.cs index 9f4d246..1bbb001 100644 --- a/src/SheetMe.Designer/App.xaml.cs +++ b/src/SheetMe.Designer/App.xaml.cs @@ -288,6 +288,12 @@ public partial class App : Application return Diagnostics.MaximizeCheck.Run(args.Length >= 2 ? args[1] : null); } + if (args.Length >= 1 && args[0] == "--scale-budget") + { + var count = args.Length >= 3 && int.TryParse(args[2], out var n) ? n : 1067; + return Diagnostics.ScaleBudget.Run(args.Length >= 2 ? args[1] : null, count); + } + if (args.Length >= 1 && args[0] == "--cleartype") { return Diagnostics.ClearTypeCheck.Run(); diff --git a/src/SheetMe.Designer/Diagnostics/ScaleBudget.cs b/src/SheetMe.Designer/Diagnostics/ScaleBudget.cs new file mode 100644 index 0000000..21b6bc5 --- /dev/null +++ b/src/SheetMe.Designer/Diagnostics/ScaleBudget.cs @@ -0,0 +1,199 @@ +using System.Diagnostics; +using System.Text; +using System.Windows; +using SheetMe.Core.Models; +using SheetMe.Designer.Services; +using SheetMe.Designer.ViewModels; + +namespace SheetMe.Designer.Diagnostics; + +/// +/// 운영 규모에서 상호작용이 얼마나 걸리는지 잰다 — --scale-budget [출력파일] [컨트롤수]. +/// +/// 왜 필요한가. --edit-smoke 는 컨트롤 3개 합성 문서에서 배선만 확인한다. +/// 운영 최대 서식은 1,067개다. 그 규모에서 무엇이 느린지 지금 CI 에 보이는 숫자가 하나도 없어서, +/// 재설계가 새 상호작용을 얹으면 재설계가 드러낸 느림을 재설계 탓으로 뒤집어쓴다. +/// +/// 무엇을 겨누는가. 월드 좌표 조회가 O(N) 이고 그것이 O(N) 루프 안에 있다: +/// PageOfPages.FirstOrDefault(p => p.Controls.Contains(root)) 이고 +/// ParentWorldOffsetWorldBoundsOf 가 전부 이걸 탄다. +/// 그래서 호버 한 번, 드래그 한 프레임, 마퀴 전체 선택이 각각 몇 밀리초인지가 이 진단의 답이다. +/// +/// 시간 예산은 넉넉하게 잡는다. 이 숫자는 기계와 부하에 따라 흔들리므로, +/// 좁게 잡으면 멀쩡한 커밋에서 거짓 실패가 난다. 목적은 "빠른지"가 아니라 +/// "사람이 못 쓸 정도로 느려지는 회귀를 잡는 것"이다. +/// 그래서 예산은 체감 한계(호버·드래그는 프레임당 16ms, 한 번 하는 조작은 1초) 기준으로 둔다. +/// +/// 재지 못하는 것. 여기서 도는 것은 뷰모델 계층뿐이다 — 시각 트리가 없으므로 +/// WPF 의 Measure/Arrange, 바인딩 재평가, 렌더가 빠져 있다. +/// 줌이 LayoutTransform 이라 눈금마다 서브트리 전체를 다시 재는 비용, +/// Undo 뒤 시각 트리를 통째로 다시 세우는 비용은 이 숫자에 들어 있지 않다. +/// 이 진단이 답하는 질문은 "상호작용 산술이 규모에서 터지는가"이고, 답은 아니다 — +/// 실측으로 O(N)-in-O(N) 경고가 과장이었음이 드러났다(§커밋 메시지 참조). +/// +internal static class ScaleBudget +{ + #region Member Fields + /// 프레임당 이 이상이면 60fps 를 못 지킨다 — 드래그가 끊겨 보인다 + private const double FrameBudgetMs = 16; + + /// 한 번 하는 조작(마퀴 전체 선택, 되돌리기)이 이 이상이면 멈춘 것으로 보인다 + private const double GestureBudgetMs = 1000; + + private const int HoverFrames = 100; + private const int DragFrames = 60; + #endregion + + #region Methods + public static int Run(string? outputPath, int controlCount) + { + var report = new StringBuilder(); + var failures = 0; + + report.AppendLine($"운영 규모 예산 — 컨트롤 {controlCount}개"); + report.AppendLine(); + + var designer = BuildPage(controlCount); + var controls = designer.Pages[0].Controls; + report.AppendLine($"최상위 {controls.Count}개 + 중첩 자식 {controls.Sum(c => c.Model.Children.Count)}개," + + $" 페이지 {designer.Pages.Count}장"); + if (controls.Count != controlCount) + { + report.AppendLine($"★ 만들려던 수와 다르다 — 이 상태의 숫자는 비교 대상이 못 된다"); + failures++; + } + report.AppendLine(); + + var controller = new InteractionController(designer); + var none = new PointerContext(false, false, false, 1); + var ctrl = new PointerContext(true, false, false, 1); + + // ① 호버 — 드래그가 아니어도 마우스만 움직이면 매 프레임 히트테스트가 돈다 + failures += Budget(report, "호버(선택 0)", HoverFrames, FrameBudgetMs, frame => + designer.Interaction.CursorAt(new Point(60 + (frame % 40), 60 + (frame % 40)))); + + // ② 드래그 1개 — 가장 흔한 조작 + designer.Selection.SetSingle(controls[0]); + failures += Budget(report, "드래그(선택 1)", DragFrames, FrameBudgetMs, frame => + { + if (frame == 0) + { + controller.PointerDown(new Point(controls[0].X + 2, controls[0].Y + 2), none); + } + controller.PointerMove(new Point(controls[0].X + 6 + frame, controls[0].Y + 6 + frame), none); + }); + controller.CancelDrag(); + + // ③ 드래그 다수 — 정렬·같은 크기·간격 균등이 겨냥하는 바로 그 상황이다 + var many = Math.Min(500, controls.Count); + designer.Selection.Set(controls.Take(many).ToList()); + var anchor = controls[0]; + failures += Budget(report, $"드래그(선택 {many})", DragFrames, FrameBudgetMs, frame => + { + if (frame == 0) + { + controller.PointerDown(new Point(anchor.X + 2, anchor.Y + 2), none); + } + controller.PointerMove(new Point(anchor.X + 6 + frame, anchor.Y + 6 + frame), none); + }); + controller.CancelDrag(); + designer.Selection.Clear(); + + // ④ 마퀴로 전부 선택 — 한 제스처 안에서 O(N²) 가 터지는 자리 + failures += Budget(report, "마퀴 전체 선택", 1, GestureBudgetMs, _ => + { + controller.PointerDown(new Point(-40, -40), ctrl); + controller.PointerMove(new Point(4000, 40000), ctrl); + controller.PointerUp(new Point(4000, 40000), ctrl); + }); + report.AppendLine($" (마퀴로 잡힌 것 {designer.Selection.Items.Count}개)"); + + // ⑤ 되돌리기 — 문서 전체 딥클론 + VM 전체 재생성이라 앱에서 가장 비싼 키 한 방이다 + designer.Selection.SetSingle(controls[0]); + controller.PointerDown(new Point(controls[0].X + 2, controls[0].Y + 2), none); + controller.PointerMove(new Point(controls[0].X + 40, controls[0].Y + 40), none); + controller.PointerUp(new Point(controls[0].X + 40, controls[0].Y + 40), none); + failures += Budget(report, "되돌리기 1회", 1, GestureBudgetMs, _ => designer.Undo.Undo()); + + report.AppendLine(); + report.AppendLine($"결과: 실패 {failures}건"); + var text = report.ToString(); + Console.Error.Write(text); + if (outputPath is { Length: > 0 }) + { + System.IO.File.WriteAllText(outputPath, text); + } + return failures == 0 ? 0 : 1; + } + + /// + /// 프레임당 평균으로 판정한다 — 최악값은 GC 한 번에 튀어서 거짓 실패를 낸다. + /// 최악값은 함께 적어 사람이 볼 수 있게만 남긴다. + /// + private static int Budget(StringBuilder report, string name, int frames, double budgetMs, Action work) + { + // 첫 회는 JIT·지연 초기화가 섞여 본 측정과 성격이 다르다 — 버리는 대신 따로 적는다 + var warmUp = Stopwatch.StartNew(); + work(0); + warmUp.Stop(); + + var worst = 0.0; + var watch = Stopwatch.StartNew(); + for (var frame = 0; frame < frames; frame++) + { + var one = Stopwatch.StartNew(); + work(frame); + one.Stop(); + worst = Math.Max(worst, one.Elapsed.TotalMilliseconds); + } + watch.Stop(); + + var average = watch.Elapsed.TotalMilliseconds / Math.Max(1, frames); + var ok = average <= budgetMs; + report.AppendLine($"{(ok ? "PASS" : "FAIL")} {name,-18} 평균 {average,8:0.00}ms" + + $" (예산 {budgetMs}ms, 최악 {worst:0.00}ms, 첫회 {warmUp.Elapsed.TotalMilliseconds:0.00}ms, {frames}회)"); + return ok ? 0 : 1; + } + + /// + /// 운영 최대 규모의 페이지를 만든다. 컨테이너에 담긴 자식을 섞는다 — + /// 중첩 자식이 28,009개(그 중 RadioButton 15,026개)라 그것이 실제 모양이고, + /// 월드 좌표 조회가 부모를 타고 올라가는 비용도 거기서만 나온다. + /// + private static DesignerViewModel BuildPage(int count) + { + var document = new FormDocument(); + var page = new FormPage { Width = 720, Height = 856 }; + document.Pages.Add(page); + + // 최상위를 정확히 count 개로 만든다. O(N) 원시연산(PageOf 의 Controls.Contains, + // ControlsIntersecting)이 보는 N 은 최상위 개수이므로, 자식을 최상위에서 빼면 N 이 절반이 되어 + // 최악을 재지 못한다(처음에 그렇게 만들어 534 를 재고 있었다). + // 컨테이너 자식은 그 위에 더 얹는다 — 중첩 자식 28,009개가 실제 모양이고, + // 월드 좌표가 부모를 타고 올라가는 비용은 거기서만 난다. + var panel = new ControlElement { Type = "Panel", Id = "Panel1" }; + panel.Bounds = new LayoutRect { X = 20, Y = 20, W = 660, H = 400 }; + page.Controls.Add(panel); + for (var i = 0; i < count / 2; i++) + { + panel.Children.Add(new ControlElement + { + Type = "RadioButton", + Id = $"N{i:0000}", + Bounds = new LayoutRect { X = 8 + (i % 40) * 16, Y = 8 + (i / 40 % 24) * 16, W = 60, H = 14 }, + }); + } + + for (var i = 0; i < count - 1; i++) + { + page.Controls.Add(new ControlElement + { + Type = i % 3 == 0 ? "RadioButton" : i % 3 == 1 ? "Label" : "TextBox", + Id = $"C{i:0000}", + Bounds = new LayoutRect { X = 20 + (i % 40) * 17, Y = 440 + (i / 40 % 24) * 17, W = 60, H = 14 }, + }); + } + return new DesignerViewModel(document); + } + #endregion +}