From a749e18e7b5a83809249aa2eed9734bdc77da247 Mon Sep 17 00:00:00 2001 From: Msystech Date: Wed, 12 Aug 2026 09:20:30 +0900 Subject: [PATCH] =?UTF-8?q?MaskedTextBox=20=EB=A5=BC=20=EB=A0=88=EA=B1=B0?= =?UTF-8?q?=EC=8B=9C=EC=99=80=20=EB=8F=99=EC=9D=BC=ED=95=98=EA=B2=8C=20?= =?UTF-8?q?=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8=20=EB=AC=B8=EC=9E=90?= =?UTF-8?q?=EB=A1=9C=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4단계 육안 대조에서 발견한 차이. 마스크 패턴을 그대로 그려서 값이 이미 입력된 것처럼 보였다. 레거시 ___년 __월 __일 (프롬프트 문자) SheetMe 0000년 90월 90일 (마스크 패턴 원문) 저장 데이터와는 무관한 표시 문제지만, 운영 실사용이 MaskedTextBox 475개 / Mask 보유 460개라 자주 눈에 띈다. LegacyMask.ToPromptDisplay 신설(Core, 순수 함수) — 입력 자리 지시자는 프롬프트 문자로, 리터럴은 그대로, 이스케이프(\)는 다음 문자를 리터럴로, 대소문자 변환자(< > |)는 표시하지 않는다. 활성 디자인 전수 census 로 지원 범위를 정했다: 등장 지시자는 0(2,675) 9(687) #(12) 와 이스케이프 \(2) 뿐이고 문자류(L ? & C A a)와 변환자는 쓰이지 않는다. PromptChar 속성을 가진 컨트롤은 0개라 전부 기본값 '_' 이지만, 속성이 있으면 반영하도록 구현했다. 구분자 . , : / $ 는 WinForms 가 문화권 구분자로 치환하지만 원문 그대로 둔다 — 운영 마스크는 보이는 대로 쓰이고 레거시 화면도 그렇게 나온다. MaskDisplay 는 표시 전용 파생 속성이라 PropBag 에 아무것도 쓰지 않는다(왕복 무손실 유지). 인스펙터 편집용 Mask 원문 속성은 그대로 둔다. 검증: 테스트 98/98(마스크 28건 추가 — 운영 실사용 마스크 10종, 이스케이프, 문자류 지시자, 변환자, PromptChar 지정, 끝 백슬래시, 표시 길이 보존). S481 재렌더로 레거시와 동일 표시 확인. edit-smoke 실패 0, 왕복 1,271건 diff 0/예외 0. Co-Authored-By: Claude Fable 5 --- src/SheetMe.Core/Serialization/LegacyMask.cs | 78 +++++++++++++++ .../Controls/ControlTemplates.xaml | 2 +- .../Controls/TypedControlViewModels.cs | 10 +- tests/SheetMe.Core.Tests/LegacyMaskTests.cs | 95 +++++++++++++++++++ 4 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 src/SheetMe.Core/Serialization/LegacyMask.cs create mode 100644 tests/SheetMe.Core.Tests/LegacyMaskTests.cs diff --git a/src/SheetMe.Core/Serialization/LegacyMask.cs b/src/SheetMe.Core/Serialization/LegacyMask.cs new file mode 100644 index 0000000..b0f7d7e --- /dev/null +++ b/src/SheetMe.Core/Serialization/LegacyMask.cs @@ -0,0 +1,78 @@ +using System.Text; + +namespace SheetMe.Core.Serialization; + +/// +/// WinForms MaskedTextBox 마스크를 화면 표시 문자열로 변환한다. +/// +/// 레거시는 마스크 패턴을 그대로 그리지 않고, 입력 자리마다 프롬프트 문자(기본 _)를 찍고 +/// 리터럴은 그대로 둔다. 예: 0000년 90월 90일____년 __월 __일. +/// 마스크 원문을 그대로 보여주면 디자이너가 값이 이미 입력된 것으로 오해한다. +/// +/// 운영 실측(활성 디자인 전수): MaskedTextBox 475개 / Mask 보유 460개 / 고유 44종. +/// 등장 지시자는 0(2,675) 9(687) #(12) 와 이스케이프 \(2) 뿐이고 +/// 문자류 지시자(L/?/&/C/A/a)와 대소문자 변환자(< > |)는 쓰이지 않는다. +/// PromptChar 속성을 가진 컨트롤은 0개라 전부 기본값이다. 그래도 전 지시자를 구현해 둔다. +/// +public static class LegacyMask +{ + #region Member Fields + /// WinForms 기본 프롬프트 문자 + public const char DefaultPromptChar = '_'; + #endregion + + #region Methods + /// + /// 마스크 → 표시 문자열. 입력 자리는 , 그 외는 리터럴로 남긴다. + /// + /// 레거시 Mask 속성 값 + /// 프롬프트 문자 — 레거시 PromptChar 속성(기본 _) + public static string ToPromptDisplay(string? mask, char promptChar = DefaultPromptChar) + { + if (string.IsNullOrEmpty(mask)) + { + return string.Empty; + } + + var result = new StringBuilder(mask.Length); + for (var i = 0; i < mask.Length; i++) + { + var c = mask[i]; + switch (c) + { + // 이스케이프 — 다음 문자를 리터럴로. 마스크 끝에 홀로 오면 리터럴 백슬래시로 둔다. + case '\\': + if (i + 1 < mask.Length) + { + result.Append(mask[++i]); + } + else + { + result.Append(c); + } + break; + + // 입력 자리 지시자 — 숫자/문자/영숫자 계열 모두 한 자리를 소비한다 + case '0' or '9' or '#' // 숫자 계열 + or 'L' or '?' // 문자 계열 + or '&' or 'C' // 임의 문자 계열 + or 'A' or 'a': // 영숫자 계열 + result.Append(promptChar); + break; + + // 대소문자 변환자 — 자리를 차지하지 않고 표시에도 나타나지 않는다 + case '<' or '>' or '|': + break; + + // 그 외(구분자 . , : / $ 와 한글·공백 등)는 리터럴. + // WinForms 는 . , : / $ 를 문화권 구분자로 치환하지만, 운영 마스크는 보이는 그대로 + // 쓰이고 레거시 화면도 그렇게 나오므로 원문을 유지한다. + default: + result.Append(c); + break; + } + } + return result.ToString(); + } + #endregion +} diff --git a/src/SheetMe.Designer/Controls/ControlTemplates.xaml b/src/SheetMe.Designer/Controls/ControlTemplates.xaml index d47c3da..6852c97 100644 --- a/src/SheetMe.Designer/Controls/ControlTemplates.xaml +++ b/src/SheetMe.Designer/Controls/ControlTemplates.xaml @@ -66,7 +66,7 @@ - + diff --git a/src/SheetMe.Designer/ViewModels/Controls/TypedControlViewModels.cs b/src/SheetMe.Designer/ViewModels/Controls/TypedControlViewModels.cs index ac49621..eba135a 100644 --- a/src/SheetMe.Designer/ViewModels/Controls/TypedControlViewModels.cs +++ b/src/SheetMe.Designer/ViewModels/Controls/TypedControlViewModels.cs @@ -28,9 +28,17 @@ public sealed class TextBoxViewModel : ControlViewModel /// 마스크 입력 (레거시 MaskedTextBox) public sealed class MaskedTextBoxViewModel : ControlViewModel { - /// 입력 마스크 + /// 입력 마스크 원문 — 인스펙터 편집용 public string Mask => Prop("Mask") ?? string.Empty; + /// + /// 빈 값일 때 캔버스에 그릴 문자열 — 레거시와 동일하게 입력 자리를 프롬프트 문자로 표시한다 + /// (예: 0000년 90월 90일____년 __월 __일). + /// 마스크 원문을 그대로 그리면 값이 입력된 것으로 오해하게 된다. + /// + public string MaskDisplay => LegacyMask.ToPromptDisplay( + Mask, (Prop("PromptChar") is { Length: > 0 } p) ? p[0] : LegacyMask.DefaultPromptChar); + public MaskedTextBoxViewModel(ControlElement model) : base(model) { } } diff --git a/tests/SheetMe.Core.Tests/LegacyMaskTests.cs b/tests/SheetMe.Core.Tests/LegacyMaskTests.cs new file mode 100644 index 0000000..8def566 --- /dev/null +++ b/tests/SheetMe.Core.Tests/LegacyMaskTests.cs @@ -0,0 +1,95 @@ +using SheetMe.Core.Serialization; + +namespace SheetMe.Core.Tests; + +/// WinForms MaskedTextBox 마스크 → 표시 문자열 변환 테스트. +[TestClass] +public sealed class LegacyMaskTests +{ + /// 운영 실사용 마스크 상위 항목 — 2026-08-11 활성 디자인 전수 census 근거 + [TestMethod] + [DataRow("0000년 90월 90일", "____년 __월 __일")] // 198건 — 최다 + [DataRow("0000-00-00", "____-__-__")] // 51건 + [DataRow("90:90", "__:__")] // 36건 + [DataRow("90시90분", "__시__분")] // 22건 + [DataRow("0000.00.00.", "____.__.__.")] // 21건 + [DataRow("90월 90일", "__월 __일")] // 15건 — 연속 공백 보존 + [DataRow("000-9000-0000", "___-____-____")] // 6건 + [DataRow("( 90 : 00 )", "( __ : __ )")] + [DataRow("####/##/## ##:##", "____/__/__ __:__")] // # 지시자 + [DataRow("0000년 90월 90일 90시 90분", "____년 __월 __일 __시 __분")] + public void ToPromptDisplay_ProductionMasks(string mask, string expected) + { + Assert.AreEqual(expected, LegacyMask.ToPromptDisplay(mask)); + } + + /// 이스케이프 — 백슬래시 다음 문자는 리터럴(운영에 '2\000년' 형태가 존재) + [TestMethod] + [DataRow(@"2\000년", "20__년")] + [DataRow(@"2\000년 90월 90일", "20__년 __월 __일")] + [DataRow(@"\9\0", "90")] + public void ToPromptDisplay_EscapedLiterals(string mask, string expected) + { + Assert.AreEqual(expected, LegacyMask.ToPromptDisplay(mask)); + } + + /// 문자·영숫자 지시자도 한 자리를 소비한다(운영 미사용이나 규약상 지원) + [TestMethod] + [DataRow("LLL", "___")] + [DataRow("???", "___")] + [DataRow("AAA", "___")] + [DataRow("aaa", "___")] + [DataRow("&&&", "___")] + [DataRow("CCC", "___")] + public void ToPromptDisplay_CharacterDirectives(string mask, string expected) + { + Assert.AreEqual(expected, LegacyMask.ToPromptDisplay(mask)); + } + + /// 대소문자 변환자는 자리를 차지하지 않고 표시에도 나오지 않는다 + [TestMethod] + [DataRow(">LLL", "___")] + [DataRow("LL|LL", "____")] + public void ToPromptDisplay_CaseModifiersAreInvisible(string mask, string expected) + { + Assert.AreEqual(expected, LegacyMask.ToPromptDisplay(mask)); + } + + /// PromptChar 지정 — 운영엔 0건이지만 속성이 있으면 반영해야 한다 + [TestMethod] + public void ToPromptDisplay_HonorsPromptChar() + { + Assert.AreEqual("0000년 00월 00일", LegacyMask.ToPromptDisplay("0000년 90월 90일", '0')); + Assert.AreEqual("####-##-##", LegacyMask.ToPromptDisplay("0000-00-00", '#')); + Assert.AreEqual(" - - ", LegacyMask.ToPromptDisplay("0000-00-00", ' ')); + } + + /// 빈 마스크·null 안전 + [TestMethod] + public void ToPromptDisplay_EmptyInputs() + { + Assert.AreEqual(string.Empty, LegacyMask.ToPromptDisplay(null)); + Assert.AreEqual(string.Empty, LegacyMask.ToPromptDisplay(string.Empty)); + } + + /// 끝에 홀로 남은 백슬래시는 리터럴로 — 인덱스 초과로 죽지 않아야 한다 + [TestMethod] + public void ToPromptDisplay_TrailingBackslashIsLiteral() + { + Assert.AreEqual(@"__\", LegacyMask.ToPromptDisplay(@"00\")); + } + + /// + /// 표시 길이는 마스크의 시각 길이와 같아야 한다(변환자·이스케이프 제외). + /// 길이가 달라지면 캔버스에서 필드 폭이 어긋나 보인다. + /// + [TestMethod] + [DataRow("0000년 90월 90일")] + [DataRow("0000.00.00.")] + [DataRow("( 90 : 00 )")] + public void ToPromptDisplay_PreservesVisibleLength(string mask) + { + Assert.AreEqual(mask.Length, LegacyMask.ToPromptDisplay(mask).Length); + } +}