마스크 편집기 — 프리셋 목록 + 실시간 미리보기

레거시 MaskedTextBox 는 System.Windows.Forms.MaskedTextBox 를 상속하고 Mask 를 재정의하지 않아
WinForms 기본 마스크 디자이너(프리셋 목록 + 시험 입력)를 그대로 물려받는다. SheetMe 인스펙터는
평범한 텍스트 상자뿐이라 마스크 문법을 외워야 했다.

[프리셋] LegacyMaskCatalog — .NET 문화권 기본값(짧은 날짜/전화번호 등) 대신 운영 활성 디자인
전수 census 에서 실제로 쓰이는 마스크를 사용 건수와 함께 제공한다. 기존 서식과 같은 표기를
그대로 고를 수 있어 표기 흔들림이 줄어든다. 날짜/시간/날짜+시간/번호 그룹 + 표준 3종.

[미리보기] 두 줄로 보여준다.
  빈 값 표시 — 마스크만으로 화면에 어떻게 보이는지(ToPromptDisplay)
  시험 입력 — 값을 쳐 보면 어떻게 채워지는지(Apply, 신규)
인스펙터 행에도 '빈 값 표시'를 상시 노출해 편집기를 열지 않아도 확인된다.

[Apply 규칙] 구현 중 모호한 경우가 드러나 규칙을 단순하게 확정했다.
  1) 리터럴은 항상 그대로 출력되고 입력을 소비하지 않는다.
  2) 지시자는 입력에서 자기에게 맞는 첫 문자를 가져오며, 맞지 않는 문자와 공백은 건너뛴다.
값만 치든(20260812) 구분자를 같이 치든(2026-08-12, 2026년 08월 12일) 결과가 같아진다.
공백을 건너뛰는 이유는 '9' 가 공백도 받아들이기 때문이다 — 그대로 두면 구분자 공백이 데이터
자리를 먹어 한 칸씩 밀린다. '9' 의 공백 허용은 빈 자리를 위한 것이고 그건 프롬프트 문자가 이미
표현한다.
한계를 코드에 명시했다: 리터럴이 숫자인 마스크(2\000년 — 운영 460건 중 1건)는 사용자가
리터럴까지 치면 어디까지가 데이터인지 원리적으로 가릴 수 없다. 편집 가능한 자리만 치면 된다.
미리보기용이므로 휴리스틱을 쌓기보다 규칙을 단순하게 두는 편이 낫다고 판단했다.

안내문에서 백슬래시를 글리프로 보여주지 않는다 — 한글 글꼴에서 원화 기호로 렌더되어 오해를 부른다.

검증: 테스트 124/124(마스크 26건 추가 — Apply 자리 채움/구분자 무시/유효성 거부/대소문자 변환자/
이스케이프/공백 처리, 프리셋 형식·중복·운영 다빈도 포함 검사). edit-smoke 실패 0,
왕복 1,271건 diff 0/예외 0. 실제 UI 로 대화상자 열어 프리셋 그룹·건수·두 줄 미리보기 확인.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-12 09:38:05 +09:00
co-authored by Claude Fable 5
parent a749e18e7b
commit 68f0fbd5a3
9 changed files with 529 additions and 1 deletions
+4 -1
View File
@@ -48,7 +48,7 @@ public static class ControlRegistry
Type = "MaskedTextBox", DisplayName = "마스크입력", DefaultW = 160, DefaultH = 22, CreatesField = true,
Properties = new PropertyDef[]
{
new() { Key = "Mask", Label = "마스크", Editor = PropEditorKind.Text },
new() { Key = "Mask", Label = "마스크", Editor = PropEditorKind.Mask },
new() { Key = "IsRequiredValue", Label = "필수 입력", Editor = PropEditorKind.Choice, Choices = new[] { "No", "Yes" } },
new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag },
},
@@ -244,6 +244,9 @@ public enum PropEditorKind
DataActionTag,
/// <summary>SQL 쿼리 — 전용 편집기 창(치환 변수 삽입 지원)</summary>
SqlQuery,
/// <summary>입력 마스크 — 프리셋 목록 + 미리보기 대화상자</summary>
Mask,
}
/// <summary>인스펙터 속성 정의 1건 — PropBag 의 레거시 Property 를 편집</summary>
@@ -0,0 +1,63 @@
namespace SheetMe.Core.Catalog;
/// <summary>마스크 프리셋 1건</summary>
/// <param name="Group">분류 — 편집기 목록의 그룹 헤더</param>
/// <param name="Mask">Mask 속성에 들어갈 값</param>
/// <param name="Description">사람이 읽는 설명</param>
/// <param name="UsageCount">운영 활성 디자인에서의 사용 건수(0 = 운영 미사용, 표준 제공분)</param>
public sealed record MaskPreset(string Group, string Mask, string Description, int UsageCount);
/// <summary>
/// MaskedTextBox 마스크 프리셋 — 레거시 .NET 마스크 디자이너의 대응물.
///
/// .NET 기본 프리셋(짧은 날짜/전화번호/우편번호…)은 문화권 일반 서식이라 이 시스템과 안 맞는다.
/// 대신 <b>운영 활성 디자인 전수 census(2026-08-12)</b>에서 실제로 쓰이는 마스크를 사용 빈도와 함께
/// 제공한다 — MaskedTextBox 475개 / Mask 보유 460개 / 고유 44종.
/// 이렇게 하면 디자이너가 기존 서식과 같은 표기를 그대로 고를 수 있어 표기 흔들림이 줄어든다.
/// </summary>
public static class LegacyMaskCatalog
{
#region Member Fields
/// <summary>프리셋 — 그룹별, 그룹 안에서는 사용 빈도 내림차순</summary>
public static readonly IReadOnlyList<MaskPreset> All = new MaskPreset[]
{
// ── 날짜 ──
new("날짜", "0000년 90월 90일", "연월일 (한글)", 198),
new("날짜", "0000-00-00", "연-월-일", 51),
new("날짜", "0000.00.00.", "연.월.일.", 21),
new("날짜", "0000년 90월 90일", "연월일 (한글, 넓은 간격)", 12),
new("날짜", "0000년 00월 00일", "연월일 (한글, 전체 필수)", 6),
new("날짜", "0000 년 00월 00일", "연월일 (한글, 연도 뒤 공백)", 4),
new("날짜", "0000.90.90.", "연.월.일. (월일 선택)", 4),
new("날짜", "90월 90일", "월일", 15),
new("날짜", "0000-00", "연-월", 1),
new("날짜", @"2\000년 90월 90일", "연월일 (2000년대 고정)", 1),
// ── 시간 ──
new("시간", "90:90", "시:분", 36),
new("시간", "90시90분", "시분 (한글)", 22),
new("시간", "90:00", "시:분 (분 필수)", 11),
new("시간", "00:00", "시:분 (전체 필수)", 8),
new("시간", "00시 00분", "시분 (한글, 넓은 간격)", 5),
new("시간", "00분 00초", "분초", 1),
new("시간", "90시간90분", "소요 시간", 1),
// ── 날짜+시간 ──
new("날짜+시간", "0000-00-00 90:00", "연-월-일 시:분", 5),
new("날짜+시간", "0000년 90월 90일 90시 90분", "연월일 시분 (한글)", 4),
new("날짜+시간", "####/##/## ##:##", "연/월/일 시:분 (선택 입력)", 1),
// ── 번호 ──
new("번호", "000-9000-0000", "휴대전화", 6),
new("번호", "000-00-00000-0", "사업자·기관 번호", 1),
// ── 표준(운영 미사용, 필요 시) ──
new("표준", "000000-0000000", "주민등록번호", 0),
new("표준", "00000", "우편번호(5자리)", 0),
new("표준", "LLL", "영문 3자", 0),
};
/// <summary>운영에서 실제 쓰이는 프리셋만(사용 건수 &gt; 0)</summary>
public static IEnumerable<MaskPreset> InUse => All.Where(p => p.UsageCount > 0);
#endregion
}
@@ -74,5 +74,108 @@ public static class LegacyMask
}
return result.ToString();
}
/// <summary>
/// 마스크에 입력을 채운 결과 — 마스크 편집기의 <b>시험 입력 미리보기</b>용.
///
/// <para>규칙은 둘뿐이라 결과를 예측할 수 있다.</para>
/// <list type="number">
/// <item>리터럴은 항상 그대로 출력되고 입력을 소비하지 않는다.</item>
/// <item>지시자는 입력에서 <b>자기에게 맞는 첫 문자</b>를 가져온다. 맞지 않는 문자와
/// 공백은 구분자로 보고 건너뛴다. 채워지지 않은 자리는 프롬프트 문자로 남는다.</item>
/// </list>
///
/// <para>
/// 덕분에 값만 치든(<c>20260812</c>) 구분자를 같이 치든(<c>2026-08-12</c>, <c>2026년 08월 12일</c>)
/// 같은 결과가 나온다. 공백을 건너뛰는 이유는 <c>9</c> 가 공백도 받아들이기 때문이다 —
/// 그대로 두면 <c>0000년 90월 90일</c> 에 <c>2026년 08월 12일</c> 을 넣을 때 구분자 공백이
/// 데이터 자리를 먹어 한 칸씩 밀린다. <c>9</c> 의 공백 허용은 '빈 자리'를 위한 것이고
/// 그건 프롬프트 문자가 이미 표현한다.
/// </para>
///
/// <para>
/// 한계: 리터럴이 숫자인 마스크(<c>2\000년</c> — 운영 460건 중 1건)는 사용자가 리터럴까지 치면
/// 어디까지가 데이터인지 원리적으로 가릴 수 없다. 이때는 편집 가능한 자리만 치면 된다(<c>26</c>).
/// 미리보기용이므로 규칙을 단순하게 두는 편이 낫다고 판단했다.
/// </para>
/// </summary>
public static string Apply(string? mask, string? input, char promptChar = DefaultPromptChar)
{
if (string.IsNullOrEmpty(mask))
{
return string.Empty;
}
var source = input ?? string.Empty;
var next = 0;
var result = new StringBuilder(mask.Length);
for (var i = 0; i < mask.Length; i++)
{
var c = mask[i];
switch (c)
{
case '\\':
result.Append(i + 1 < mask.Length ? mask[++i] : c);
break;
case '<' or '>' or '|':
break;
case '0' or '9' or '#' or 'L' or '?' or '&' or 'C' or 'A' or 'a':
while (next < source.Length
&& (char.IsWhiteSpace(source[next]) || !Accepts(c, source[next])))
{
next++;
}
if (next < source.Length)
{
result.Append(Transform(mask, i, source[next]));
next++;
}
else
{
result.Append(promptChar);
}
break;
default:
result.Append(c);
break;
}
}
return result.ToString();
}
/// <summary>지시자가 받아들이는 문자인지 — WinForms MaskedTextBox 규약</summary>
private static bool Accepts(char directive, char value) => directive switch
{
'0' => char.IsDigit(value),
'9' => char.IsDigit(value) || value == ' ',
'#' => char.IsDigit(value) || value is ' ' or '+' or '-',
'L' => char.IsLetter(value),
'?' => char.IsLetter(value) || value == ' ',
'A' => char.IsLetterOrDigit(value),
'a' => char.IsLetterOrDigit(value) || value == ' ',
'&' => !char.IsControl(value),
'C' => !char.IsControl(value) || value == ' ',
_ => false,
};
/// <summary>직전에 나온 대소문자 변환자(&lt; &gt; |)를 적용한다</summary>
private static char Transform(string mask, int directiveIndex, char value)
{
for (var i = directiveIndex - 1; i >= 0; i--)
{
switch (mask[i])
{
case '>': return char.ToUpperInvariant(value);
case '<': return char.ToLowerInvariant(value);
case '|': return value;
default: continue; // 변환자를 만날 때까지 거슬러 올라간다
}
}
return value;
}
#endregion
}
@@ -257,6 +257,7 @@ public sealed class InspectorViewModel : ViewModelBase
PropEditorKind.DataActionTag => new TagPickerRowViewModel(def.Label,
"액션 태그 선택 — 더블클릭/버튼 액션(EN_DataActionTyp)", LegacyTagCatalog.DataActionTags),
PropEditorKind.SqlQuery => new QueryRowViewModel(def.Label),
PropEditorKind.Mask => new MaskRowViewModel(def.Label),
_ => new TextRowViewModel(def.Label),
};
AddRow(row, binding);
@@ -167,6 +167,40 @@ public sealed class TagPickerRowViewModel : PropertyRowViewModel
}
}
/// <summary>
/// 마스크 행 — 값과 표시 미리보기를 함께 보여주고, 찾아보기로 프리셋 편집기를 연다.
/// 레거시는 WinForms 기본 마스크 디자이너(프리셋 + 시험 입력)를 제공했다.
/// </summary>
public sealed class MaskRowViewModel : PropertyRowViewModel
{
/// <summary>이 마스크가 화면에 어떻게 보이는지 — 인스펙터에서 바로 확인</summary>
public string Preview => ValueText.Length == 0
? "(마스크 없음)"
: SheetMe.Core.Serialization.LegacyMask.ToPromptDisplay(ValueText);
/// <summary>마스크 편집기 열기</summary>
public M.Framework.WPF.ICustomCommand? BrowseCommand { get; set; }
public MaskRowViewModel(string label) : base(label)
{
BrowseCommand = new M.Framework.WPF.Command((sender, e) => OnBrowse());
}
protected override void OnValueApplied() => OnPropertyChanged(nameof(Preview));
private void OnBrowse()
{
var dialog = new Views.MaskPickerDialogView(ValueText)
{
Owner = System.Windows.Application.Current.MainWindow,
};
if (dialog.ShowDialog() == true)
{
ValueText = dialog.Mask;
}
}
}
/// <summary>SQL 쿼리 행 — 요약 표시 + 전용 편집기 창(치환 변수 삽입)</summary>
public sealed class QueryRowViewModel : PropertyRowViewModel
{
@@ -106,6 +106,24 @@
</DockPanel>
</DataTemplate>
<DataTemplate DataType="{x:Type ins:MaskRowViewModel}">
<StackPanel Margin="0,2">
<DockPanel>
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
<Button DockPanel.Dock="Right" Content="…" Padding="8,1" Margin="4,0,0,0"
Command="{Binding BrowseCommand}"
ToolTip="마스크 편집기 — 자주 쓰는 마스크 목록과 미리보기"/>
<TextBox Text="{Binding ValueText, UpdateSourceTrigger=LostFocus}" FontSize="12"
FontFamily="Consolas, D2Coding, 굴림체"
ToolTip="0=필수 숫자, 9=선택 숫자, #=숫자·부호 — 오른쪽 … 버튼에서 목록·미리보기"/>
</DockPanel>
<!-- 화면에 실제로 어떻게 보이는지 — 마스크 문법을 몰라도 결과로 확인 -->
<TextBlock Text="{Binding Preview}" FontSize="11" Margin="0,2,0,0"
FontFamily="Consolas, D2Coding, 굴림체"
Foreground="{DynamicResource B.Muted}" TextTrimming="CharacterEllipsis"/>
</StackPanel>
</DataTemplate>
<DataTemplate DataType="{x:Type ins:ColorRowViewModel}">
<DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
@@ -0,0 +1,92 @@
<Window x:Class="SheetMe.Designer.Views.MaskPickerDialogView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="마스크 편집" Width="620" Height="620"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False">
<DockPanel Margin="12">
<!--
백슬래시를 글리프로 보여주지 않는다 — 한글 글꼴에서 원화 기호(₩)로 렌더되어 오해를 부른다.
아래 '마스크' 입력란은 Consolas 우선이라 거기서는 백슬래시로 제대로 보인다.
-->
<TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,10"
Text="0=필수 숫자, 9=선택 숫자(공백 허용), #=숫자·부호, L=필수 영문, ?=선택 영문, A=영숫자.&#x0a;그 외 문자는 입력할 수 없는 구분자로 그대로 표시됩니다. 지시자 문자를 구분자로 쓰려면 앞에 역슬래시를 붙이세요."/>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Content="마스크 없음" Padding="12,4" Click="OnClear"/>
<Button Content="확인" Padding="20,4" Margin="8,0,0,0" Click="OnConfirm" IsDefault="True"/>
<Button Content="취소" Padding="20,4" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel>
<!-- 마스크 입력 + 미리보기 -->
<Border DockPanel.Dock="Bottom" Margin="0,10,0,0" Padding="10"
Background="{DynamicResource B.Panel}" BorderBrush="{DynamicResource B.Line}" BorderThickness="1">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="86"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="마스크" VerticalAlignment="Center" Margin="0,0,8,6"/>
<TextBox x:Name="MaskBox" Grid.Row="0" Grid.Column="1" Margin="0,0,0,6" Padding="4,3"
FontFamily="Consolas, D2Coding, 굴림체" TextChanged="OnMaskChanged"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="빈 값 표시" VerticalAlignment="Center" Margin="0,0,8,6"
Foreground="{DynamicResource B.Muted}"/>
<TextBlock x:Name="PromptPreview" Grid.Row="1" Grid.Column="1" VerticalAlignment="Center" Margin="0,0,0,6"
FontFamily="Consolas, D2Coding, 굴림체" Foreground="{DynamicResource B.Accent}"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="시험 입력" VerticalAlignment="Center" Margin="0,0,8,0"
Foreground="{DynamicResource B.Muted}"
ToolTip="여기에 값을 쳐 보면 마스크가 어떻게 채워지는지 확인할 수 있습니다"/>
<Grid Grid.Row="2" Grid.Column="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="TestInputBox" Grid.Column="0" Margin="0,0,6,0" Padding="4,3"
TextChanged="OnMaskChanged"/>
<TextBlock x:Name="TestPreview" Grid.Column="1" VerticalAlignment="Center"
FontFamily="Consolas, D2Coding, 굴림체"/>
</Grid>
</Grid>
</Border>
<!-- 프리셋 목록 -->
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="자주 쓰는 마스크 (더블클릭으로 선택)" FontWeight="Bold"
Foreground="{DynamicResource B.Muted}" Margin="2,0,0,6"/>
<ListBox x:Name="PresetList" FontSize="12" SelectionChanged="OnPresetSelected" MouseDoubleClick="OnConfirm">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid Margin="0,2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="230"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="64"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="{Binding Mask}" FontFamily="Consolas, D2Coding, 굴림체"/>
<TextBlock Grid.Column="1" Text="{Binding Description}" Foreground="{DynamicResource B.Muted}"/>
<TextBlock Grid.Column="2" Text="{Binding UsageLabel}" Foreground="{DynamicResource B.Muted}"
FontSize="11" HorizontalAlignment="Right"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
<ListBox.GroupStyle>
<GroupStyle>
<GroupStyle.HeaderTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}" FontWeight="Bold" Margin="2,8,0,3"
Foreground="{DynamicResource B.Accent}"/>
</DataTemplate>
</GroupStyle.HeaderTemplate>
</GroupStyle>
</ListBox.GroupStyle>
</ListBox>
</DockPanel>
</DockPanel>
</Window>
@@ -0,0 +1,88 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using SheetMe.Core.Catalog;
using SheetMe.Core.Serialization;
namespace SheetMe.Designer.Views;
/// <summary>
/// 마스크 편집기 — 레거시 .NET 마스크 디자이너의 대응물.
/// 레거시는 MaskedTextBox 가 WinForms 기본 편집기를 물려받아 프리셋 목록과 시험 입력을 제공했는데,
/// SheetMe 인스펙터는 평범한 텍스트 상자뿐이라 마스크 문법을 외워야 했다.
///
/// 프리셋은 .NET 문화권 기본값이 아니라 <see cref="LegacyMaskCatalog"/>(운영 실사용 census)를 쓴다.
/// </summary>
public partial class MaskPickerDialogView : Window
{
#region Types
/// <summary>목록 표시용 투영 — 사용 건수를 사람이 읽는 라벨로</summary>
private sealed record PresetRow(string Group, string Mask, string Description, string UsageLabel);
#endregion
#region Properties
/// <summary>확정된 마스크 — 취소 시 의미 없음</summary>
public string Mask { get; private set; } = string.Empty;
#endregion
#region Constructors
public MaskPickerDialogView(string currentMask)
{
InitializeComponent();
var rows = LegacyMaskCatalog.All.Select(p => new PresetRow(
p.Group, p.Mask, p.Description,
p.UsageCount > 0 ? $"{p.UsageCount:N0}건" : "표준"));
var view = new CollectionViewSource { Source = rows.ToList() };
view.GroupDescriptions.Add(new PropertyGroupDescription(nameof(PresetRow.Group)));
PresetList.ItemsSource = view.View;
MaskBox.Text = currentMask;
UpdatePreview();
Loaded += (_, _) =>
{
MaskBox.Focus();
MaskBox.CaretIndex = MaskBox.Text.Length;
};
}
#endregion
#region Methods
private void OnMaskChanged(object sender, TextChangedEventArgs e) => UpdatePreview();
private void OnPresetSelected(object sender, SelectionChangedEventArgs e)
{
if (PresetList.SelectedItem is PresetRow row && row.Mask != MaskBox.Text)
{
MaskBox.Text = row.Mask;
}
}
/// <summary>빈 값 표시와 시험 입력 결과를 즉시 갱신 — 마스크 문법을 몰라도 결과로 확인할 수 있게</summary>
private void UpdatePreview()
{
// PromptPreview 는 InitializeComponent 이전에는 없다(TextChanged 가 먼저 뛸 수 있음)
if (PromptPreview is null || TestPreview is null)
{
return;
}
var mask = MaskBox.Text;
PromptPreview.Text = mask.Length == 0 ? "(마스크 없음 — 자유 입력)" : LegacyMask.ToPromptDisplay(mask);
TestPreview.Text = mask.Length == 0 ? string.Empty : LegacyMask.Apply(mask, TestInputBox.Text);
}
private void OnClear(object sender, RoutedEventArgs e)
{
Mask = string.Empty;
DialogResult = true;
}
private void OnConfirm(object sender, RoutedEventArgs e)
{
Mask = MaskBox.Text;
DialogResult = true;
}
#endregion
}
+126
View File
@@ -1,3 +1,4 @@
using SheetMe.Core.Catalog;
using SheetMe.Core.Serialization;
namespace SheetMe.Core.Tests;
@@ -92,4 +93,129 @@ public sealed class LegacyMaskTests
{
Assert.AreEqual(mask.Length, LegacyMask.ToPromptDisplay(mask).Length);
}
// ── 시험 입력 미리보기(Apply) ──
/// <summary>입력이 마스크 자리를 순서대로 채운다</summary>
[TestMethod]
[DataRow("0000년 90월 90일", "20260812", "2026년 08월 12일")]
[DataRow("0000년 90월 90일", "2026", "2026년 __월 __일")] // 부분 입력 — 나머지는 프롬프트
[DataRow("0000-00-00", "20260812", "2026-08-12")]
[DataRow("90:90", "0930", "09:30")]
[DataRow("000-9000-0000", "01012345678", "010-1234-5678")]
public void Apply_FillsPositionsInOrder(string mask, string input, string expected)
{
Assert.AreEqual(expected, LegacyMask.Apply(mask, input));
}
/// <summary>값만 치든 구분자를 같이 치든 결과가 같아야 한다</summary>
[TestMethod]
[DataRow("0000-00-00", "2026-08-12", "2026-08-12")]
[DataRow("0000-00-00", "20260812", "2026-08-12")]
[DataRow("0000년 90월 90일", "2026년 08월 12일", "2026년 08월 12일")]
[DataRow("0000년 90월 90일", "20260812", "2026년 08월 12일")]
[DataRow("000-9000-0000", "010-1234-5678", "010-1234-5678")]
[DataRow("000-9000-0000", "01012345678", "010-1234-5678")]
[DataRow("90시90분", "0930", "09시30분")]
public void Apply_SeparatorsInInputAreIgnored(string mask, string input, string expected)
{
Assert.AreEqual(expected, LegacyMask.Apply(mask, input));
}
/// <summary>지시자에 맞지 않는 문자는 건너뛴다(WinForms 가 거부하는 것과 같은 결과)</summary>
[TestMethod]
public void Apply_RejectsInvalidCharacters()
{
Assert.AreEqual("12__", LegacyMask.Apply("0000", "ab12")); // 숫자 자리에 영문
Assert.AreEqual("AB__", LegacyMask.Apply("LLLL", "12AB")); // 영문 자리에 숫자
}
/// <summary>대소문자 변환자는 입력에 적용된다</summary>
[TestMethod]
public void Apply_AppliesCaseModifiers()
{
Assert.AreEqual("ABC", LegacyMask.Apply(">LLL", "abc"));
Assert.AreEqual("abc", LegacyMask.Apply("<LLL", "ABC"));
}
/// <summary>
/// 이스케이프 리터럴은 입력을 소비하지 않는다.
/// 마스크 <c>2\000년</c> 은 리터럴 '2','0' 뒤에 입력 자리 2칸이라 표시가 <c>20__년</c> 이고,
/// 편집 가능한 자리(2칸)만 입력하면 된다.
/// </summary>
[TestMethod]
public void Apply_EscapedLiteralConsumesNoInput()
{
Assert.AreEqual("20__년", LegacyMask.ToPromptDisplay(@"2\000년"));
Assert.AreEqual("2026년", LegacyMask.Apply(@"2\000년", "26"));
Assert.AreEqual("202_년", LegacyMask.Apply(@"2\000년", "2"));
}
/// <summary>
/// 입력의 공백은 구분자로 보고 건너뛴다 — '9' 가 공백을 허용해서 생기는 밀림 방지.
/// 빈 자리는 프롬프트 문자가 표현하므로 공백을 데이터로 받을 이유가 없다.
/// </summary>
[TestMethod]
public void Apply_TreatsInputWhitespaceAsSeparator()
{
Assert.AreEqual("2026년 08월 12일", LegacyMask.Apply("0000년 90월 90일", "2026 08 12"));
Assert.AreEqual("09:30", LegacyMask.Apply("90:90", "09 30"));
}
/// <summary>입력이 없으면 빈 값 표시와 같아야 한다</summary>
[TestMethod]
[DataRow("0000년 90월 90일")]
[DataRow("000-9000-0000")]
public void Apply_WithoutInputEqualsPromptDisplay(string mask)
{
Assert.AreEqual(LegacyMask.ToPromptDisplay(mask), LegacyMask.Apply(mask, null));
Assert.AreEqual(LegacyMask.ToPromptDisplay(mask), LegacyMask.Apply(mask, string.Empty));
}
/// <summary>빈 마스크는 빈 결과 — 자유 입력 필드</summary>
[TestMethod]
public void Apply_EmptyMask()
{
Assert.AreEqual(string.Empty, LegacyMask.Apply(null, "abc"));
Assert.AreEqual(string.Empty, LegacyMask.Apply(string.Empty, "abc"));
}
// ── 프리셋 카탈로그 ──
/// <summary>프리셋 마스크는 전부 유효해야 한다 — 표시 길이가 보존되고 지시자가 하나 이상 있어야 한다</summary>
[TestMethod]
public void Presets_AreWellFormed()
{
foreach (var preset in LegacyMaskCatalog.All)
{
var display = LegacyMask.ToPromptDisplay(preset.Mask);
Assert.AreNotEqual(preset.Mask, display,
$"[{preset.Mask}] 에 입력 지시자가 없습니다 — 마스크로 쓸 수 없습니다.");
Assert.IsFalse(string.IsNullOrWhiteSpace(preset.Description), $"[{preset.Mask}] 설명 누락");
Assert.IsFalse(string.IsNullOrWhiteSpace(preset.Group), $"[{preset.Mask}] 그룹 누락");
}
}
/// <summary>프리셋 마스크에 중복이 없어야 한다</summary>
[TestMethod]
public void Presets_HaveNoDuplicates()
{
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var preset in LegacyMaskCatalog.All)
{
Assert.IsTrue(seen.Add(preset.Mask), $"중복 프리셋: {preset.Mask}");
}
}
/// <summary>운영 최다 사용 마스크가 프리셋에 있어야 한다(census 근거)</summary>
[TestMethod]
[DataRow("0000년 90월 90일")] // 198건
[DataRow("0000-00-00")] // 51건
[DataRow("90:90")] // 36건
[DataRow("90시90분")] // 22건
[DataRow("0000.00.00.")] // 21건
public void Presets_CoverMostUsedProductionMasks(string mask)
{
Assert.IsTrue(LegacyMaskCatalog.All.Any(p => p.Mask == mask), $"운영 다빈도 마스크 누락: {mask}");
}
}