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); + } +}