From c445b851a17da410a429e835fd93f0b81bc6e6a9 Mon Sep 17 00:00:00 2001 From: Msystech Date: Tue, 11 Aug 2026 17:30:28 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9D=B8=EC=8A=A4=ED=8E=99=ED=84=B0=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=EA=B0=92=20=EB=8F=84=EC=9E=85=20+=20Choice?= =?UTF-8?q?=20=EC=BD=A4=EB=B3=B4=EC=9D=98=20=EC=86=8D=EC=84=B1=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [값 삭제 버그] InspectorView 의 ComboBox SelectedItem 은 TwoWay 기본이라, 저장된 값이 Choices 에 없으면 null 로 코어스한 뒤 그 null 을 소스에 되써서 속성을 삭제했다. Label TextAlign Choices 에 Bottom* 3값이 빠져 있어, 해당 라벨을 선택하는 것만으로 발동했다(운영 활성 디자인 1,305건 census: BottomCenter 126 / BottomLeft 107 / BottomRight 16). - ContentAlignment 9값 전체로 보정 - ChoiceRowViewModel.EnsureChoice — 목록 밖 값을 표시 전에 편입(사이트 커스텀 값 방어) [기본값] PropertyDef.Default 추가. RowBinding.Get 한 곳에서만 주입하므로 Initialize(building=true) 경로를 타 Commit 이 발생하지 않는다 — 사용자가 건드리기 전까지 PropBag 에 아무것도 쓰이지 않아 왕복 무손실이 유지된다. 다중 선택 병합도 자동으로 옳아진다. 지정 대상(레거시 필드 초기값/WinForms DefaultValueAttribute 근거): TextBox.RwdRsvWrdYon=True (TextBox.vb:63, 2022-09-28 추가라 이전 서식엔 키 없음) DataTable.ExcuteQuery=True (MDataTable.vb:15) Label.TextAlign=TopLeft / TextBox.TextAlign=Left / TextBox.BorderStyle=Fixed3D Panel.BorderStyle=None / PictureBox.SizeMode=Normal / DateTimePicker.Format=Long 이게 없으면 기본 True 속성이 항상 '꺼짐'으로 보이고, 사용자가 껐다 켜는 순간 명시 False 가 기록돼 상용구 입력·데이터소스 자동 실행이 실제로 죽는다. ControlRegistryTests 신설 — Default⊆Choices, Toggle 기본값 표기, 그리고 운영 실사용 열거값이 Choices 에 전부 포함되는지(census 근거) 검사. 검증: 테스트 59/59, edit-smoke 실패 0, 전수 왕복 1,271건 diff 0/예외 0. Co-Authored-By: Claude Fable 5 --- src/SheetMe.Core/Catalog/ControlRegistry.cs | 29 +++++-- .../Inspector/InspectorViewModel.cs | 14 +++- .../ViewModels/Inspector/PropertyRows.cs | 19 ++++- .../ControlRegistryTests.cs | 76 +++++++++++++++++++ 4 files changed, 126 insertions(+), 12 deletions(-) create mode 100644 tests/SheetMe.Core.Tests/ControlRegistryTests.cs diff --git a/src/SheetMe.Core/Catalog/ControlRegistry.cs b/src/SheetMe.Core/Catalog/ControlRegistry.cs index bafde78..385f064 100644 --- a/src/SheetMe.Core/Catalog/ControlRegistry.cs +++ b/src/SheetMe.Core/Catalog/ControlRegistry.cs @@ -15,7 +15,9 @@ public static class ControlRegistry Properties = new PropertyDef[] { new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.MultilineText }, - new() { Key = "TextAlign", Label = "정렬", Editor = PropEditorKind.Choice, Choices = new[] { "TopLeft", "TopCenter", "TopRight", "MiddleLeft", "MiddleCenter", "MiddleRight" } }, + // ContentAlignment 9값 전체 — Bottom* 3값이 빠져 있으면 그 라벨을 선택하는 것만으로 속성이 삭제된다(운영 249건) + new() { Key = "TextAlign", Label = "정렬", Editor = PropEditorKind.Choice, Default = "TopLeft", + Choices = new[] { "TopLeft", "TopCenter", "TopRight", "MiddleLeft", "MiddleCenter", "MiddleRight", "BottomLeft", "BottomCenter", "BottomRight" } }, new() { Key = "ForeColor", Label = "글자색", Editor = PropEditorKind.Color }, new() { Key = "BackColor", Label = "배경색", Editor = PropEditorKind.Color }, }, @@ -27,16 +29,18 @@ public static class ControlRegistry { new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.Text }, new() { Key = "Multiline", Label = "여러 줄", Editor = PropEditorKind.Toggle }, - new() { Key = "TextAlign", Label = "정렬", Editor = PropEditorKind.Choice, Choices = new[] { "Left", "Center", "Right" } }, + new() { Key = "TextAlign", Label = "정렬", Editor = PropEditorKind.Choice, Default = "Left", Choices = new[] { "Left", "Center", "Right" } }, new() { Key = "IsRequiredValue", Label = "필수 입력", Editor = PropEditorKind.Choice, Choices = new[] { "No", "Yes" } }, new() { Key = "InitialValue", Label = "초기값", Editor = PropEditorKind.Text }, new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag }, new() { Key = "DataActionTag", Label = "액션 태그", Editor = PropEditorKind.DataActionTag }, new() { Key = "DataActionTagControl", Label = "액션 대상 컨트롤", Editor = PropEditorKind.Text }, - new() { Key = "RwdRsvWrdYon", Label = "상용구 사용", Editor = PropEditorKind.Toggle }, + // 레거시 TextBox.vb:63 mbRwdRsvWrdYon = True — 2022-09-28 추가된 속성이라 그 이전 서식엔 키가 없다. + // 기본값을 알려주지 않으면 항상 '꺼짐'으로 보이고, 사용자가 껐다 켜면 명시 False 가 기록돼 기능이 죽는다. + new() { Key = "RwdRsvWrdYon", Label = "상용구 사용", Editor = PropEditorKind.Toggle, Default = "True" }, new() { Key = "RwdOrderAutYon", Label = "처방연동 상용구", Editor = PropEditorKind.Toggle }, new() { Key = "BackColor", Label = "배경색", Editor = PropEditorKind.Color }, - new() { Key = "BorderStyle", Label = "테두리", Editor = PropEditorKind.Choice, Choices = new[] { "None", "FixedSingle", "Fixed3D" } }, + new() { Key = "BorderStyle", Label = "테두리", Editor = PropEditorKind.Choice, Default = "Fixed3D", Choices = new[] { "None", "FixedSingle", "Fixed3D" } }, }, }, new() @@ -100,7 +104,7 @@ public static class ControlRegistry Type = "DateTimePicker", DisplayName = "날짜선택", DefaultW = 140, DefaultH = 22, CreatesField = true, DefaultDataType = "datetime", Properties = new PropertyDef[] { - new() { Key = "Format", Label = "형식", Editor = PropEditorKind.Choice, Choices = new[] { "Long", "Short", "Time", "Custom" } }, + new() { Key = "Format", Label = "형식", Editor = PropEditorKind.Choice, Default = "Long", Choices = new[] { "Long", "Short", "Time", "Custom" } }, new() { Key = "CustomFormat", Label = "사용자 형식", Editor = PropEditorKind.Text }, }, }, @@ -110,7 +114,7 @@ public static class ControlRegistry Properties = new PropertyDef[] { new() { Key = "BackColor", Label = "배경색", Editor = PropEditorKind.Color }, - new() { Key = "BorderStyle", Label = "테두리", Editor = PropEditorKind.Choice, Choices = new[] { "None", "FixedSingle", "Fixed3D" } }, + new() { Key = "BorderStyle", Label = "테두리", Editor = PropEditorKind.Choice, Default = "None", Choices = new[] { "None", "FixedSingle", "Fixed3D" } }, }, }, new() @@ -135,7 +139,7 @@ public static class ControlRegistry Properties = new PropertyDef[] { new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag }, - new() { Key = "SizeMode", Label = "크기 모드", Editor = PropEditorKind.Choice, Choices = new[] { "Normal", "StretchImage", "AutoSize", "CenterImage", "Zoom" } }, + new() { Key = "SizeMode", Label = "크기 모드", Editor = PropEditorKind.Choice, Default = "Normal", Choices = new[] { "Normal", "StretchImage", "AutoSize", "CenterImage", "Zoom" } }, }, }, new() @@ -167,7 +171,8 @@ public static class ControlRegistry { new() { Key = "Query", Label = "쿼리(SQL)", Editor = PropEditorKind.SqlQuery }, new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag }, - new() { Key = "ExcuteQuery", Label = "쿼리 실행", Editor = PropEditorKind.Toggle }, + // 레거시 MDataTable.vb:15 mbExcuteQuery = True — 미기록 서식에서 '꺼짐'으로 보이면 안 된다 + new() { Key = "ExcuteQuery", Label = "쿼리 실행", Editor = PropEditorKind.Toggle, Default = "True" }, }, }, }; @@ -255,4 +260,12 @@ public sealed class PropertyDef /// Choice 편집기의 선택지 public string[]? Choices { get; init; } + + /// + /// PropBag 에 키가 없을 때(미설정) 인스펙터에 표시할 값 — 표시 전용이다. + /// 사용자가 건드리지 않으면 XML 에 기록되지 않는다(왕복 무손실 불변식 유지). + /// 레거시 필드 초기값이 True 인 속성이나 WinForms DefaultValueAttribute 로 미기록되는 속성처럼, + /// "키 부재"와 "명시적 False/빈값"이 UI 상 구분되지 않아 오조작을 부르는 경우에만 지정한다. + /// + public string? Default { get; init; } } diff --git a/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs index cc87c7d..34183a2 100644 --- a/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs +++ b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs @@ -229,7 +229,9 @@ public sealed class InspectorViewModel : ViewModelBase } : new RowBinding { - Get = vm => vm.Model.Props.GetText(def.Key), + // 키가 없으면 def.Default 로 표시한다. Initialize 는 building 플래그로 감싸 Commit 을 내지 않으므로 + // 사용자가 건드리기 전까지 PropBag 에는 아무것도 쓰이지 않는다(왕복 무손실 유지). + Get = vm => vm.Model.Props.GetText(def.Key) ?? def.Default, Set = (vm, value) => { if (value.Length == 0) @@ -331,7 +333,15 @@ public sealed class InspectorViewModel : ViewModelBase { var items = designer.Selection.Items; var values = items.Select(binding.Get).Distinct(StringComparer.Ordinal).ToList(); - row.Initialize(values.Count == 1 ? values[0] : null, isMixed: values.Count > 1); + var initial = values.Count == 1 ? values[0] : null; + + // 목록 밖 값이 들어오면 ComboBox 가 null 로 코어스해 되쓰면서 속성을 지운다 — 표시 전에 편입시킨다. + if (row is ChoiceRowViewModel choice) + { + choice.EnsureChoice(initial); + } + + row.Initialize(initial, isMixed: values.Count > 1); row.Commit = value => { diff --git a/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs b/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs index 6e579c9..93691c6 100644 --- a/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs +++ b/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs @@ -108,12 +108,27 @@ public sealed class ToggleRowViewModel : PropertyRowViewModel /// 선택지 행 public sealed class ChoiceRowViewModel : PropertyRowViewModel { + private readonly List choices; + /// 선택지 목록 - public string[] Choices { get; } + public IReadOnlyList Choices => choices; public ChoiceRowViewModel(string label, string[] choices) : base(label) { - Choices = choices; + this.choices = choices.ToList(); + } + + /// + /// 현재 값이 선택지에 없으면 목록에 편입한다. + /// ComboBox.SelectedItem 은 TwoWay 기본이라, 바인딩 값이 ItemsSource 에 없으면 null 로 코어스한 뒤 + /// 그 null 을 소스에 되써서 속성이 삭제된다. 선택만 해도 데이터가 손상되므로 방어가 필요하다. + /// + public void EnsureChoice(string? value) + { + if (!string.IsNullOrEmpty(value) && !choices.Contains(value, StringComparer.Ordinal)) + { + choices.Add(value); + } } } diff --git a/tests/SheetMe.Core.Tests/ControlRegistryTests.cs b/tests/SheetMe.Core.Tests/ControlRegistryTests.cs new file mode 100644 index 0000000..65ee570 --- /dev/null +++ b/tests/SheetMe.Core.Tests/ControlRegistryTests.cs @@ -0,0 +1,76 @@ +using SheetMe.Core.Catalog; + +namespace SheetMe.Core.Tests; + +/// 인스펙터 속성 스키마(ControlRegistry) 불변식 테스트. +[TestClass] +public sealed class ControlRegistryTests +{ + /// + /// Choice 편집기의 Default 는 반드시 자기 Choices 안에 있어야 한다. + /// 어긋나면 ComboBox 가 값을 null 로 코어스해 되쓰면서 속성을 삭제한다. + /// + [TestMethod] + public void ChoiceDefaults_AreWithinTheirOwnChoices() + { + foreach (var descriptor in ControlRegistry.All) + { + foreach (var def in descriptor.Properties.Where(p => p.Editor == PropEditorKind.Choice && p.Default is not null)) + { + Assert.IsNotNull(def.Choices, $"{descriptor.Type}.{def.Key}: Default 가 있는데 Choices 가 없습니다."); + CollectionAssert.Contains(def.Choices!, def.Default, + $"{descriptor.Type}.{def.Key}: Default('{def.Default}')가 Choices 에 없습니다."); + } + } + } + + /// Choice 가 아닌 편집기에는 선택지 개념이 없다 — Choices 를 달아두면 죽은 설정이 된다. + [TestMethod] + public void NonChoiceEditors_HaveNoChoices() + { + foreach (var descriptor in ControlRegistry.All) + { + foreach (var def in descriptor.Properties.Where(p => p.Editor != PropEditorKind.Choice)) + { + Assert.IsNull(def.Choices, $"{descriptor.Type}.{def.Key}: {def.Editor} 편집기에 Choices 가 지정돼 있습니다."); + } + } + } + + /// Toggle 기본값은 "True"/"False" 만 — IsOn 이 문자열 정확 비교라 다른 표기는 조용히 꺼짐이 된다. + [TestMethod] + public void ToggleDefaults_AreCanonicalBooleanText() + { + foreach (var descriptor in ControlRegistry.All) + { + foreach (var def in descriptor.Properties.Where(p => p.Editor == PropEditorKind.Toggle && p.Default is not null)) + { + Assert.IsTrue(def.Default is "True" or "False", + $"{descriptor.Type}.{def.Key}: Toggle Default 는 \"True\"/\"False\" 여야 합니다(현재 '{def.Default}')."); + } + } + } + + /// + /// 운영 실사용 값이 Choices 에 전부 들어 있어야 한다(2026-08-11 활성 디자인 1,305건 census 근거). + /// 목록에서 빠진 값을 가진 컨트롤은 인스펙터에서 선택하는 것만으로 속성이 삭제된다. + /// + [TestMethod] + [DataRow("Label", "TextAlign", "TopLeft,TopCenter,MiddleLeft,MiddleCenter,MiddleRight,BottomLeft,BottomCenter,BottomRight")] + [DataRow("TextBox", "TextAlign", "Center,Right")] + [DataRow("TextBox", "BorderStyle", "None,FixedSingle")] + [DataRow("Panel", "BorderStyle", "None,FixedSingle")] + [DataRow("PictureBox", "SizeMode", "StretchImage,Zoom,AutoSize")] + [DataRow("DateTimePicker", "Format", "Custom,Time,Short")] + [DataRow("TextBox", "IsRequiredValue", "No,Yes")] + public void Choices_CoverProductionValues(string type, string key, string csvValues) + { + var def = ControlRegistry.Find(type)?.Properties.FirstOrDefault(p => p.Key == key); + Assert.IsNotNull(def, $"{type}.{key} 정의를 찾을 수 없습니다."); + foreach (var value in csvValues.Split(',')) + { + CollectionAssert.Contains(def!.Choices!, value, + $"{type}.{key}: 운영에서 쓰이는 값 '{value}'가 Choices 에 없습니다."); + } + } +}