데이터소스 배선 전용 편집기 — 조용히 실패하던 문법을 코드가 조립한다

이 시스템의 핵심 자동 채움 경로다. MDataTable 조회 결과의 한 칸을 입력 컨트롤에 꽂는 배선인데,
지금까지 SheetMe 는 ComboBox 에만 안내 없는 자유 텍스트 한 줄이 있었고 나머지 7종에는 항목조차 없었다.

문제는 형식을 어겼을 때의 증상이다. 런타임 파서(ucLoadSheetBase.DataTableBinding_)는 문자열을
쪼개서 읽고, 실패하면 **빈 catch 가 예외를 삼킨다**. 로그도 없다. 디자이너에서는 정상으로 보이고
임상 화면에서만 값이 안 채워지므로 원인 추적이 어렵다. 그래서 문법 골격은 코드가 조립하고
저장 전에 검사한다.

■ 실측이 통설을 뒤집었다

레거시 전용 편집기(fmMDataTableField)는 Select 형 한 가지만 만든다:
  {이름}.Select("{필터}")({행}).Item("{필드}")   ← 공백 0개
그래서 이것이 '정본'이라고 알려져 있었다.

--db-dtf 진단을 새로 만들어 운영 디자인 1,271건을 센 결과는 반대였다:
  Rows 형 1,624건(76%) · Select 형 268건(12%) · 미완성 "MDataTable1.Rows" 234건(11%)
Select 형만 읽었다면 기존 배선의 4분의 3을 편집기가 못 여는 상태로 출시할 뻔했다.
게다가 형태를 함부로 바꾸면 안 된다 — 보조 로더(bzDesignSheetLoader)는 .Select( 분기가 아예 없어
Rows 형만 이해한다. 그래서 **연 값의 형태를 그대로 유지**하고, 새로 만들 때만 Select 형으로 시작한다.

같은 집계에서 운영 결함도 드러났다: 같은 서식에 없는 데이터소스를 가리키는 값 20건
(S0003 은 MDataTable2 를 가리키는데 그 서식에는 MDataTable3 만 있다). 전부 조용히 실패한다.
런타임 조회가 대소문자를 구분하므로 검증에 이름 일치 검사를 넣었다.

■ 레거시보다 나은 점 3가지 (파리티 초과지만 위험 없음)

1. 기존 값을 되읽어 채운다. 레거시는 열 때마다 백지에서 시작해(InitializeSelectedData)
   필드 하나만 고치려 해도 전부 다시 골라야 했다.
2. 아무것도 안 고르고 확인하는 경로를 막는다. 레거시는 5조각을 이어붙이므로 리터럴 ")(" 가 저장된다.
3. 저장 전 검사 — 필터·필드의 큰따옴표(파서가 따옴표로 쪼갠다), 이름의 마침표(첫 조각만 떼어 간다),
   음수 행. 레거시는 검증이 0이다.

필드 후보를 목록으로 주지는 않았다. 레거시는 쿼리를 실제로 실행해 결과 컬럼을 쓰는데,
그 쿼리에는 테스트 환자 설정이 필요한 치환 토큰이 들어 있어 재현이 별개 작업이다.
대신 필드는 자유 입력으로 두고 그 사정을 화면에 적었다. 실제로 필드에는 SQL 별칭 표현식이
들어오기도 한다(운영 값 EMDOPRDTE||'-'||EMDOPNAME) — 식별자 화이트리스트로 막으면 안 된다.

배선을 붙인 타입은 레거시가 bzMDataTableField 편집기를 다는 8종 그대로다:
TextBox · ComboBox · CheckBox · RadioButton · DateTimePicker · MaskedTextBox · CalcBox · PictureBox.

해석 못 하는 값은 조용히 고쳐 쓰지 않는다 — 인스펙터에 "⚠ 형식을 알 수 없음: 원문" 으로 드러낸다
(운영의 미완성 값 234건이 여기 해당한다).

단위 테스트 18건 추가(표본은 전부 실측·레거시 소스 인용). 편집 스모크 4건 추가.
회귀: 테스트 142/142, 편집 스모크 187건 실패 0,
DB 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 바이트 동일.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-13 12:49:38 +09:00
co-authored by Claude Opus 5
parent 59b0e457fe
commit a90dd1301e
12 changed files with 868 additions and 1 deletions
+24 -1
View File
@@ -58,6 +58,16 @@ public static class ControlRegistry
{ {
Key = "ReLoadDataMsgNoCheck", Label = "재조회 무확인", Editor = PropEditorKind.Toggle, Default = "False", Key = "ReLoadDataMsgNoCheck", Label = "재조회 무확인", Editor = PropEditorKind.Toggle, Default = "False",
}; };
/// <summary>
/// 데이터소스 배선 — 레거시가 bzMDataTableField 편집기를 붙이는 8종에만 준다
/// (TextBox.vb:1188, ComboBox.vb:927, CheckBox.vb:900, RadioButton.vb:1581,
/// DateTimePicker.vb:544, MaskedTextBox.vb:699, MCalcBox.vb:249, MPictureBox.vb:1575).
/// </summary>
public static PropertyDef DataTableField => new()
{
Key = "DataTableField", Label = "데이터 필드", Editor = PropEditorKind.DataTableField,
};
} }
private static readonly List<ControlDescriptor> all = new() private static readonly List<ControlDescriptor> all = new()
@@ -95,6 +105,7 @@ public static class ControlRegistry
new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag }, new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag },
new() { Key = "DataActionTag", Label = "액션 태그", Editor = PropEditorKind.DataActionTag }, new() { Key = "DataActionTag", Label = "액션 태그", Editor = PropEditorKind.DataActionTag },
new() { Key = "DataActionTagControl", Label = "액션 대상 컨트롤", Editor = PropEditorKind.Text }, new() { Key = "DataActionTagControl", Label = "액션 대상 컨트롤", Editor = PropEditorKind.Text },
Runtime.DataTableField,
// 레거시 TextBox.vb:63 mbRwdRsvWrdYon = True — 2022-09-28 추가된 속성이라 그 이전 서식엔 키가 없다. // 레거시 TextBox.vb:63 mbRwdRsvWrdYon = True — 2022-09-28 추가된 속성이라 그 이전 서식엔 키가 없다.
// 기본값을 알려주지 않으면 항상 '꺼짐'으로 보이고, 사용자가 껐다 켜면 명시 False 가 기록돼 기능이 죽는다. // 기본값을 알려주지 않으면 항상 '꺼짐'으로 보이고, 사용자가 껐다 켜면 명시 False 가 기록돼 기능이 죽는다.
new() { Key = "RwdRsvWrdYon", Label = "상용구 사용", Editor = PropEditorKind.Toggle, Default = "True" }, new() { Key = "RwdRsvWrdYon", Label = "상용구 사용", Editor = PropEditorKind.Toggle, Default = "True" },
@@ -117,6 +128,7 @@ public static class ControlRegistry
new() { Key = "Mask", Label = "마스크", Editor = PropEditorKind.Mask }, new() { Key = "Mask", Label = "마스크", Editor = PropEditorKind.Mask },
Runtime.IsRequiredValue, Runtime.IsRequiredValue,
new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag }, new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag },
Runtime.DataTableField,
new() { Key = "AutoHeight", Label = "자동 높이", Editor = PropEditorKind.Toggle, Default = "False" }, new() { Key = "AutoHeight", Label = "자동 높이", Editor = PropEditorKind.Toggle, Default = "False" },
Runtime.PrintOutPut, Runtime.PrintOutPut,
Runtime.PreventEditing, Runtime.PreventEditing,
@@ -132,6 +144,7 @@ public static class ControlRegistry
{ {
new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.Text }, new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.Text },
new() { Key = "Checked", Label = "기본 체크", Editor = PropEditorKind.Toggle }, new() { Key = "Checked", Label = "기본 체크", Editor = PropEditorKind.Toggle },
Runtime.DataTableField,
// 진짜 배점이다 — CheckBox.vb:352-367 게터가 저장값(mdScore)을 그대로 돌려준다(체크됐을 때). // 진짜 배점이다 — CheckBox.vb:352-367 게터가 저장값(mdScore)을 그대로 돌려준다(체크됐을 때).
new() { Key = "Score", Label = "점수", Editor = PropEditorKind.Number, Default = "0" }, new() { Key = "Score", Label = "점수", Editor = PropEditorKind.Number, Default = "0" },
Runtime.IsRequiredValue, Runtime.IsRequiredValue,
@@ -147,6 +160,7 @@ public static class ControlRegistry
{ {
new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.Text }, new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.Text },
new() { Key = "Checked", Label = "기본 선택", Editor = PropEditorKind.Toggle }, new() { Key = "Checked", Label = "기본 선택", Editor = PropEditorKind.Toggle },
Runtime.DataTableField,
// CheckBox 와 같은 구조(RadioButton.vb Score 게터가 mdScore 반환) — 낙상·욕창 평가지의 배점원 // CheckBox 와 같은 구조(RadioButton.vb Score 게터가 mdScore 반환) — 낙상·욕창 평가지의 배점원
new() { Key = "Score", Label = "점수", Editor = PropEditorKind.Number, Default = "0" }, new() { Key = "Score", Label = "점수", Editor = PropEditorKind.Number, Default = "0" },
Runtime.IsRequiredValue, Runtime.IsRequiredValue,
@@ -162,7 +176,7 @@ public static class ControlRegistry
{ {
new() { Key = "Items", Label = "선택지(줄바꿈 구분)", Editor = PropEditorKind.StringList }, new() { Key = "Items", Label = "선택지(줄바꿈 구분)", Editor = PropEditorKind.StringList },
Runtime.IsRequiredValue, Runtime.IsRequiredValue,
new() { Key = "DataTableField", Label = "데이터 필드", Editor = PropEditorKind.Text }, Runtime.DataTableField,
// 콤보의 배점은 Score 가 아니라 ItemScore 다 — ComboBox.vb Score 게터는 저장값을 읽지 않고 // 콤보의 배점은 Score 가 아니라 ItemScore 다 — ComboBox.vb Score 게터는 저장값을 읽지 않고
// ItemScore 를 "/" 로 쪼개 SelectedIndex 번째를 돌려준다. 항목 순서와 1:1 로 맞춰야 한다. // ItemScore 를 "/" 로 쪼개 SelectedIndex 번째를 돌려준다. 항목 순서와 1:1 로 맞춰야 한다.
new() { Key = "ItemScore", Label = "항목 배점", Editor = PropEditorKind.Text }, new() { Key = "ItemScore", Label = "항목 배점", Editor = PropEditorKind.Text },
@@ -193,6 +207,7 @@ public static class ControlRegistry
{ {
new() { Key = "Format", Label = "형식", Editor = PropEditorKind.Choice, Default = "Long", 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 }, new() { Key = "CustomFormat", Label = "사용자 형식", Editor = PropEditorKind.Text },
Runtime.DataTableField,
Runtime.IsRequiredValue, Runtime.IsRequiredValue,
new() { Key = "Visible", Label = "표시", Editor = PropEditorKind.Toggle, Default = "True" }, new() { Key = "Visible", Label = "표시", Editor = PropEditorKind.Toggle, Default = "True" },
Runtime.PrintOutPut, Runtime.PrintOutPut,
@@ -253,6 +268,7 @@ public static class ControlRegistry
{ {
new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag }, new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag },
new() { Key = "SizeMode", Label = "크기 모드", Editor = PropEditorKind.Choice, Default = "Normal", Choices = new[] { "Normal", "StretchImage", "AutoSize", "CenterImage", "Zoom" } }, new() { Key = "SizeMode", Label = "크기 모드", Editor = PropEditorKind.Choice, Default = "Normal", Choices = new[] { "Normal", "StretchImage", "AutoSize", "CenterImage", "Zoom" } },
Runtime.DataTableField,
// 서명란 — 이 값이 True 여야 런타임이 서명 수집 대상으로 잡는다(MPictureBox.vb:1480, 사용처 :992/:1075/:1212…). // 서명란 — 이 값이 True 여야 런타임이 서명 수집 대상으로 잡는다(MPictureBox.vb:1480, 사용처 :992/:1075/:1212…).
// 이걸 못 걸면 서명이 필요한 동의서를 SheetMe 만으로 새로 만들 수 없다. // 이걸 못 걸면 서명이 필요한 동의서를 SheetMe 만으로 새로 만들 수 없다.
new() { Key = "IsSignature", Label = "서명란", Editor = PropEditorKind.Toggle, Default = "False" }, new() { Key = "IsSignature", Label = "서명란", Editor = PropEditorKind.Toggle, Default = "False" },
@@ -273,6 +289,7 @@ public static class ControlRegistry
{ {
new() { Key = "Formula", Label = "수식", Editor = PropEditorKind.MultilineText }, new() { Key = "Formula", Label = "수식", Editor = PropEditorKind.MultilineText },
new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.Text }, new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.Text },
Runtime.DataTableField,
// 계산박스에는 Score 를 노출하지 않는다 — MCalcBox.vb Score 게터는 저장값이 아니라 // 계산박스에는 Score 를 노출하지 않는다 — MCalcBox.vb Score 게터는 저장값이 아니라
// Me.Text 를 숫자로 읽어 돌려주고, 세터는 반대로 Text 를 덮어쓴다. 배점은 이 칸이 아니라 // Me.Text 를 숫자로 읽어 돌려주고, 세터는 반대로 Text 를 덮어쓴다. 배점은 이 칸이 아니라
// 합산 대상인 CheckBox/RadioButton 쪽에 준다. // 합산 대상인 CheckBox/RadioButton 쪽에 준다.
@@ -383,6 +400,12 @@ public enum PropEditorKind
/// <summary>입력 마스크 — 프리셋 목록 + 미리보기 대화상자</summary> /// <summary>입력 마스크 — 프리셋 목록 + 미리보기 대화상자</summary>
Mask, Mask,
/// <summary>
/// 데이터소스 배선 — 전용 대화상자가 문법을 조립한다.
/// 자유 텍스트로 두면 형식을 어긴 값이 런타임에서 무음으로 실패한다(빈 catch).
/// </summary>
DataTableField,
} }
/// <summary>인스펙터 속성 정의 1건 — PropBag 의 레거시 Property 를 편집</summary> /// <summary>인스펙터 속성 정의 1건 — PropBag 의 레거시 Property 를 편집</summary>
@@ -0,0 +1,166 @@
using System.Globalization;
using System.Text.RegularExpressions;
namespace SheetMe.Core.Catalog;
/// <summary>DataTableField 값의 두 가지 형태</summary>
public enum DataTableFieldForm
{
/// <summary><c>{이름}.Select("{필터}")({행}).Item("{필드}")</c> — 레거시 전용 편집기가 만드는 형태</summary>
Select,
/// <summary><c>{이름}.Rows({행}).Item("{필드}")</c> — 손으로 쓴 형태. 운영에서 다수다</summary>
Rows,
}
/// <summary>
/// MDataTable 조회 결과를 입력 칸에 꽂는 배선 문자열 — 파싱·조립·검증.
///
/// <b>왜 전용 취급이 필요한가.</b> 런타임 파서는 문자열을 쪼개서 읽는데(ucLoadSheetBase.DataTableBinding_),
/// 형식을 어기면 <b>예외를 빈 catch 가 삼켜서 아무 표시 없이 값이 안 채워진다</b>.
/// 디자이너에서는 정상으로 보이고 임상 화면에서만 빈칸이 되므로 원인 추적이 어렵다.
/// 그래서 우리는 문법 골격을 코드가 조립하고, 저장 전에 형식을 검사한다.
///
/// <b>두 형태를 모두 다룬다.</b> 레거시 전용 편집기는 Select 형만 만들지만,
/// 운영 실측(진단 --db-dtf)은 반대다 — Rows 형 1,624건(76%) 대 Select 형 268건(12%).
/// Select 형만 읽으면 기존 배선의 대부분을 편집기가 못 열고, 형태를 함부로 바꾸면
/// Rows 형만 이해하는 보조 로더(bzDesignSheetLoader)에서 동작이 달라질 수 있다.
/// 그래서 <b>연 값의 형태를 그대로 유지</b>하고, 새로 만들 때만 레거시 편집기와 같은 Select 형을 쓴다.
/// </summary>
public sealed class DataTableFieldSpec
{
#region Member Fields
// 파서는 첫 괄호쌍과 첫 따옴표쌍만 본다 — 공백은 어디에도 넣지 않는다(레거시 조립도 공백 0개다)
private static readonly Regex SelectPattern = new(
@"^(?<name>[^.\s""]+)\.Select\((?<q>"")(?<filter>[^""]*)""\)\((?<row>\d+)\)\.Item\(""(?<field>[^""]*)""\)$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex RowsPattern = new(
@"^(?<name>[^.\s""]+)\.Rows\((?<row>\d+)\)\.Item\(""(?<field>[^""]*)""\)$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
#endregion
#region Properties
/// <summary>값 형태</summary>
public DataTableFieldForm Form { get; set; } = DataTableFieldForm.Select;
/// <summary>데이터소스(MDataTable) 컨트롤 이름 — 런타임이 대소문자까지 맞춰 찾는다</summary>
public string TableName { get; set; } = string.Empty;
/// <summary>DataTable.Select 필터 — Rows 형에서는 쓰이지 않는다</summary>
public string Filter { get; set; } = string.Empty;
/// <summary>행 번호(0부터)</summary>
public int RowIndex { get; set; }
/// <summary>컬럼명 또는 SQL 별칭 표현식</summary>
public string Field { get; set; } = string.Empty;
#endregion
#region Methods
/// <summary>기존 값 해석 — 알아볼 수 없으면 null(원문을 손대지 않기 위해 호출부가 판단한다)</summary>
public static DataTableFieldSpec? Parse(string? value)
{
if (value is null)
{
return null;
}
var text = value.Trim();
if (text.Length == 0)
{
return null;
}
var select = SelectPattern.Match(text);
if (select.Success)
{
return new DataTableFieldSpec
{
Form = DataTableFieldForm.Select,
TableName = select.Groups["name"].Value,
Filter = select.Groups["filter"].Value,
RowIndex = int.Parse(select.Groups["row"].Value, CultureInfo.InvariantCulture),
Field = select.Groups["field"].Value,
};
}
var rows = RowsPattern.Match(text);
if (rows.Success)
{
return new DataTableFieldSpec
{
Form = DataTableFieldForm.Rows,
TableName = rows.Groups["name"].Value,
RowIndex = int.Parse(rows.Groups["row"].Value, CultureInfo.InvariantCulture),
Field = rows.Groups["field"].Value,
};
}
return null;
}
/// <summary>문자열 조립 — 공백 없이(레거시 조립과 같은 형태)</summary>
public string Compose()
=> Form == DataTableFieldForm.Rows
? $"{TableName}.Rows({RowIndex.ToString(CultureInfo.InvariantCulture)}).Item(\"{Field}\")"
: $"{TableName}.Select(\"{Filter}\")({RowIndex.ToString(CultureInfo.InvariantCulture)}).Item(\"{Field}\")";
/// <summary>
/// 저장 전 검사 — 문제가 없으면 null, 있으면 사용자에게 보일 이유 한 줄.
///
/// 레거시는 검사가 전혀 없고 실패가 100% 무음이라, 여기서 막지 않으면 운영에 도달한다.
/// </summary>
public string? Validate(IReadOnlyCollection<string>? knownTables = null)
{
if (TableName.Length == 0)
{
return "데이터소스를 선택하세요.";
}
if (TableName.Contains('.', StringComparison.Ordinal))
{
// 런타임은 첫 '.' 앞을 이름으로 자른다 — 이름에 점이 있으면 영영 못 찾는다
return "데이터소스 이름에 마침표(.)를 쓸 수 없습니다.";
}
if (TableName.Contains('"', StringComparison.Ordinal) || TableName.Any(char.IsWhiteSpace))
{
return "데이터소스 이름에 공백이나 큰따옴표를 쓸 수 없습니다.";
}
if (Field.Length == 0)
{
return "필드를 입력하세요.";
}
if (Field.Contains('"', StringComparison.Ordinal))
{
// 필드는 SQL 별칭 표현식일 수 있어 넓게 허용하되(예: EMDOPRDTE||'-'||EMDOPNAME) 큰따옴표만 막는다
return "필드에 큰따옴표를 쓸 수 없습니다.";
}
if (Form == DataTableFieldForm.Select && Filter.Contains('"', StringComparison.Ordinal))
{
// 파서가 따옴표로 쪼개므로 필터 안의 따옴표는 필터·행·필드를 전부 어긋나게 만든다
return "필터에 큰따옴표를 쓸 수 없습니다.";
}
if (RowIndex < 0)
{
return "행 번호는 0 이상이어야 합니다.";
}
if (knownTables is { Count: > 0 } && !knownTables.Contains(TableName, StringComparer.Ordinal))
{
// 대소문자까지 맞아야 한다 — 런타임 Hashtable 조회가 대소문자를 구분한다.
// 실측에서도 같은 서식에 없는 이름을 가리키는 값이 20건 있었다(전부 조용히 실패한다).
return $"이 서식에 '{TableName}' 데이터소스가 없습니다 — 대소문자까지 정확해야 합니다.";
}
return null;
}
/// <summary>조립 결과가 다시 같은 값으로 해석되는지(왕복 자기검사)</summary>
public bool RoundTrips()
{
var parsed = Parse(Compose());
return parsed is not null
&& parsed.Form == Form
&& string.Equals(parsed.TableName, TableName, StringComparison.Ordinal)
&& parsed.RowIndex == RowIndex
&& string.Equals(parsed.Field, Field, StringComparison.Ordinal)
&& (Form == DataTableFieldForm.Rows || string.Equals(parsed.Filter, Filter, StringComparison.Ordinal));
}
#endregion
}
+5
View File
@@ -142,6 +142,11 @@ public partial class App : Application
return Diagnostics.DbSmoke.RunRow(args[1], args[2]); return Diagnostics.DbSmoke.RunRow(args[1], args[2]);
} }
if (args.Length >= 2 && args[0] == "--db-dtf")
{
return Diagnostics.DbSmoke.RunDataTableFieldReport(args[1]);
}
if (args.Length >= 2 && args[0] == "--db-lines") if (args.Length >= 2 && args[0] == "--db-lines")
{ {
return Diagnostics.DbSmoke.RunLineReport(args[1]); return Diagnostics.DbSmoke.RunLineReport(args[1]);
+156
View File
@@ -615,6 +615,162 @@ public static class DbSmoke
} }
} }
/// <summary>
/// DataTableField / DataTableItms 값 형태 분포(--db-dtf) — 전용 편집기가 어떤 형태를 읽어야 하는지(읽기 전용).
///
/// 레거시 편집기가 만드는 정본은 <c>{Name}.Select("{필터}")({행}).Item("{필드}")</c> 한 가지지만,
/// 손으로 쓴 <c>{Name}.Rows({행}).Item("{필드}")</c> 형이 섞여 있다. 어느 쪽이 얼마나 되는지 모르면
/// 편집기가 기존 값을 못 읽어 사용자가 열 때마다 백지에서 다시 조립하게 된다.
/// 파서 제약도 함께 센다 — 필터에 큰따옴표가 들어가면 런타임이 조용히 어긋난다.
/// </summary>
public static int RunDataTableFieldReport(string reportPath)
{
var lines = new List<string>();
try
{
var config = ConfigService.Current;
var store = new OracleLegacyFormStore(config.ConnectionString);
var serializer = new LegacyXmlSerializer();
var sheets = store.ListSheets(null, max: 3000).Where(s => s.HasDesign).ToList();
var selectForm = 0;
var rowsForm = 0;
var otherForm = new Dictionary<string, int>(StringComparer.Ordinal);
var empty = 0;
var quoteInFilter = new List<string>();
var samples = new List<string>();
var itms = new Dictionary<string, int>(StringComparer.Ordinal);
// 첫 토큰이 그 서식의 MDataTable 이름과 대소문자까지 맞는지 — 어긋나면 런타임이 무음 실패한다
var unresolved = new List<string>();
foreach (var sheet in sheets)
{
try
{
var raw = store.LoadActiveDesignRaw(sheet.ShtCod);
if (raw is null)
{
continue;
}
var document = serializer.Read(raw.Value.Xml);
var tableNames = new HashSet<string>(StringComparer.Ordinal);
void CollectTables(Core.Models.ControlElement element)
{
if (string.Equals(element.Type, "DataTable", StringComparison.Ordinal))
{
tableNames.Add(element.Id);
}
foreach (var child in element.Children)
{
CollectTables(child);
}
}
void Visit(Core.Models.ControlElement element)
{
var value = element.Props.GetText("DataTableField");
if (value is not null)
{
var trimmed = value.Trim();
if (trimmed.Length == 0)
{
empty++;
}
else if (trimmed.Contains(".Select(", StringComparison.Ordinal))
{
selectForm++;
if (samples.Count < 12)
{
samples.Add($" {sheet.ShtCod} {element.Id}: {trimmed}");
}
// C6 — 필터 안의 큰따옴표. Split(""")(") 이 앞쪽에서 잘려 전부 어긋난다
var afterSelect = trimmed.Split(".Select(\"", 2, StringSplitOptions.None);
if (afterSelect.Length == 2)
{
var filter = afterSelect[1].Split("\")(", 2, StringSplitOptions.None)[0];
if (filter.Contains('"'))
{
quoteInFilter.Add($" {sheet.ShtCod} {element.Id}: {trimmed}");
}
}
}
else if (trimmed.Contains(".Rows(", StringComparison.Ordinal))
{
rowsForm++;
if (samples.Count < 12)
{
samples.Add($" {sheet.ShtCod} {element.Id}: {trimmed}");
}
}
else
{
otherForm[trimmed] = otherForm.GetValueOrDefault(trimmed) + 1;
}
if (trimmed.Length > 0)
{
var head = trimmed.Split('.')[0];
if (head.Length > 0 && !tableNames.Contains(head) && unresolved.Count < 20)
{
unresolved.Add($" {sheet.ShtCod} {element.Id}: '{head}' — 이 서식의 데이터소스: " +
(tableNames.Count == 0 ? "(없음)" : string.Join(",", tableNames)));
}
}
}
if (element.Props.GetText("DataTableItms") is { } items && items.Trim().Length > 0)
{
var kind = items.Contains(".Select(", StringComparison.Ordinal) ? "Select형"
: items.Contains(".Rows(", StringComparison.Ordinal) ? "Rows형(콤보 안 채워짐)"
: "기타";
itms[kind] = itms.GetValueOrDefault(kind) + 1;
}
foreach (var child in element.Children)
{
Visit(child);
}
}
foreach (var page in document.Pages)
{
CollectTables(page.Root);
}
foreach (var page in document.Pages)
{
Visit(page.Root);
}
}
catch
{
// 통계용
}
}
lines.Add($"DataTableField — Select형 {selectForm}건 / Rows형 {rowsForm}건 / 기타 {otherForm.Values.Sum()}건 / 빈값 {empty}건");
if (otherForm.Count > 0)
{
lines.Add("[기타 형태]");
lines.AddRange(otherForm.OrderByDescending(p => p.Value).Take(10).Select(p => $" {p.Key} ×{p.Value}"));
}
lines.Add($"필터에 큰따옴표가 든 값(런타임 무음 실패): {quoteInFilter.Count}건");
lines.AddRange(quoteInFilter.Take(10));
lines.Add($"첫 토큰이 같은 서식의 데이터소스 이름과 안 맞는 값: {unresolved.Count}건");
lines.AddRange(unresolved.Take(10));
lines.Add("DataTableItms 형태: " + (itms.Count == 0 ? "(없음)"
: string.Join(", ", itms.OrderByDescending(p => p.Value).Select(p => $"{p.Key}×{p.Value}"))));
lines.Add("[표본]");
lines.AddRange(samples);
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 0;
}
catch (Exception ex)
{
lines.Add("실패: " + ex);
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 1;
}
}
/// <summary> /// <summary>
/// 선(MLine) 방향 일치 검사(--db-lines) — 디자이너 렌더를 바꿔야 하는지 판단하는 근거(읽기 전용). /// 선(MLine) 방향 일치 검사(--db-lines) — 디자이너 렌더를 바꿔야 하는지 판단하는 근거(읽기 전용).
/// ///
@@ -117,6 +117,9 @@ public static class DialogShots
"SELECT ChtNum, PatNam FROM P_PatMst\nWHERE ChtNum = <<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>")), "SELECT ChtNum, PatNam FROM P_PatMst\nWHERE ChtNum = <<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>")),
("04-tag-picker", () => new Views.TagPickerDialogView("동작 태그", SampleTags(), SampleTags()[1])), ("04-tag-picker", () => new Views.TagPickerDialogView("동작 태그", SampleTags(), SampleTags()[1])),
("05-mask-picker", () => new Views.MaskPickerDialogView("0000년 90월 90일")), ("05-mask-picker", () => new Views.MaskPickerDialogView("0000년 90월 90일")),
// 데이터소스 배선 — 운영 다수파인 Rows 형 값을 넣어 되읽기·형태 유지가 보이게 찍는다
("05b-datatable-field", () => new Views.DataTableFieldDialogView(
"MDataTable2.Rows(0).Item(\"ALGYON\")", new[] { "MDataTable1", "MDataTable2" })),
("06-font-manager", () => new Views.FontManagerDialogView(designer)), ("06-font-manager", () => new Views.FontManagerDialogView(designer)),
("07-register-sheet", () => new Views.RegisterSheetDialogView("S999", "표본 서식")), ("07-register-sheet", () => new Views.RegisterSheetDialogView("S999", "표본 서식")),
("08-preview", () => new Views.PreviewWindow(designer)), ("08-preview", () => new Views.PreviewWindow(designer)),
@@ -820,6 +820,43 @@ public static class EditSmoke
designer.Selection.Clear(); designer.Selection.Clear();
} }
// 20-5g) 데이터소스 배선 — 전용 행이 붙고, 이 서식의 데이터소스만 후보가 된다
{
designer.AddControlAt("DataTable", new Point(600, 320));
var source = designer.Pages[0].Controls.First(c => c.Type == "DataTable");
var bound = Find("TextBox");
designer.Selection.SetSingle(bound);
// 배선은 '무슨 값이 채워지는가'라 데이터 탭에 있다
var previousTab = designer.Inspector.SelectedTab;
designer.Inspector.SelectedTab = ViewModels.Inspector.InspectorTab.Data;
var dtfRow = designer.Inspector.Rows
.OfType<ViewModels.Inspector.DataTableFieldRowViewModel>().FirstOrDefault();
Check("배선: 전용 편집 행이 붙는다", dtfRow is not null);
if (dtfRow is null)
{
designer.Inspector.SelectedTab = previousTab;
goto skipWiring;
}
Check("배선: 값이 없으면 안내 문구", dtfRow.Summary == "(배선 없음)", dtfRow.Summary);
// 운영 다수파인 Rows 형을 읽어야 한다(1,624건, 76%)
dtfRow.ValueText = $"{source.Id}.Rows(0).Item(\"ALGYON\")";
Check("배선: Rows 형을 읽는다", dtfRow.Summary.Contains(source.Id) && dtfRow.Summary.Contains("ALGYON"),
dtfRow.Summary);
// 해석 못 하는 값은 조용히 고치지 않고 경고로 드러낸다
dtfRow.ValueText = "MDataTable1.Rows";
Check("배선: 미완성 값은 경고로 드러낸다", dtfRow.Summary.StartsWith("⚠"), dtfRow.Summary);
dtfRow.ValueText = string.Empty;
designer.Inspector.SelectedTab = previousTab;
skipWiring:
designer.Selection.SetSingle(source);
designer.DeleteSelection();
designer.Selection.Clear();
}
// 20-5d) 태그 카탈로그 — 레거시에서 주석 처리된 태그를 고를 수 있으면 안 된다 // 20-5d) 태그 카탈로그 — 레거시에서 주석 처리된 태그를 고를 수 있으면 안 된다
Check("태그 카탈로그: 미구현 3종 제외", Check("태그 카탈로그: 미구현 3종 제외",
!SheetMe.Core.Catalog.LegacyTagCatalog.DataInterfaceTags.Contains("PAT_BMI") !SheetMe.Core.Catalog.LegacyTagCatalog.DataInterfaceTags.Contains("PAT_BMI")
@@ -602,11 +602,41 @@ public sealed class InspectorViewModel : ViewModelBase
"액션 태그 선택 — 더블클릭/버튼 액션(EN_DataActionTyp)", LegacyTagCatalog.DataActionTags), "액션 태그 선택 — 더블클릭/버튼 액션(EN_DataActionTyp)", LegacyTagCatalog.DataActionTags),
PropEditorKind.SqlQuery => new QueryRowViewModel(def.Label), PropEditorKind.SqlQuery => new QueryRowViewModel(def.Label),
PropEditorKind.Mask => new MaskRowViewModel(def.Label), PropEditorKind.Mask => new MaskRowViewModel(def.Label),
// 배선 대상 목록은 '지금 이 서식'의 데이터소스다 — 런타임도 같은 서식 안에서만 이름을 찾는다
PropEditorKind.DataTableField => new DataTableFieldRowViewModel(def.Label, DataTableNames),
_ => new TextRowViewModel(def.Label), _ => new TextRowViewModel(def.Label),
}; };
AddRow(row, binding); AddRow(row, binding);
} }
/// <summary>
/// 이 서식의 데이터소스(MDataTable) 이름 — 배선 편집기의 선택지.
///
/// 레거시 편집기는 열려 있는 <b>모든</b> 디자인 서피스를 훑어 다른 서식의 데이터소스까지 섞어 보여 주고,
/// 그걸 고르면 런타임에 조용히 실패한다(bzMDataTableField.vb:18-63). 우리는 현재 문서로 한정한다.
/// </summary>
private IReadOnlyList<string> DataTableNames()
{
var names = new List<string>();
void Visit(SheetMe.Core.Models.ControlElement element)
{
if (string.Equals(element.Type, "DataTable", StringComparison.Ordinal) && element.Id.Length > 0)
{
names.Add(element.Id);
}
foreach (var child in element.Children)
{
Visit(child);
}
}
foreach (var page in designer.Document.Pages)
{
Visit(page.Root);
}
names.Sort(StringComparer.Ordinal);
return names;
}
/// <summary> /// <summary>
/// 선의 방향·굵기 변경 — 속성과 크기를 함께 맞춘다(레거시 MLine 의 세터/OnResize 이식). /// 선의 방향·굵기 변경 — 속성과 크기를 함께 맞춘다(레거시 MLine 의 세터/OnResize 이식).
/// ///
@@ -661,6 +661,62 @@ public sealed class ToggleAdvancedRowViewModel : PropertyRowViewModel
} }
} }
/// <summary>
/// 데이터소스 배선 행 — 전용 대화상자로만 편집한다.
///
/// 자유 텍스트로 두면 형식을 어긴 값이 저장되고, 런타임은 그 예외를 빈 catch 로 삼켜
/// <b>아무 표시 없이</b> 값을 비워 둔다. 디자이너에서는 정상으로 보이므로 원인 추적이 어렵다.
/// </summary>
public sealed class DataTableFieldRowViewModel : PropertyRowViewModel
{
private readonly Func<IReadOnlyList<string>> tablesOf;
/// <summary>사람이 읽는 요약 — 배선이 없으면 안내 문구</summary>
public string Summary
{
get
{
if (ValueText.Length == 0)
{
return "(배선 없음)";
}
var spec = SheetMe.Core.Catalog.DataTableFieldSpec.Parse(ValueText);
return spec is null
// 해석 못 하는 값은 원문을 그대로 보여 준다 — 조용히 고쳐 쓰지 않는다
? $"⚠ 형식을 알 수 없음: {ValueText}"
: $"{spec.TableName} · {spec.Field}" + (spec.RowIndex == 0 ? string.Empty : $" · {spec.RowIndex}행");
}
}
/// <summary>편집기 열기</summary>
public M.Framework.WPF.ICustomCommand? EditCommand { get; set; }
public DataTableFieldRowViewModel(string label, Func<IReadOnlyList<string>> tablesOf) : base(label)
{
this.tablesOf = tablesOf;
EditCommand = new M.Framework.WPF.Command((sender, e) => OnEdit());
PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(ValueText))
{
OnPropertyChanged(nameof(Summary));
}
};
}
private void OnEdit()
{
var dialog = new Views.DataTableFieldDialogView(ValueText, tablesOf())
{
Owner = System.Windows.Application.Current.MainWindow,
};
if (dialog.ShowDialog() == true && dialog.Result is not null)
{
ValueText = dialog.Result;
}
}
}
/// <summary> /// <summary>
/// 속성 추가 행 — 키 입력 후 빈 속성 생성(고급). /// 속성 추가 행 — 키 입력 후 빈 속성 생성(고급).
/// ///
@@ -0,0 +1,72 @@
<Window x:Class="SheetMe.Designer.Views.DataTableFieldDialogView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="데이터 필드 배선" Width="560" Height="520"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedWindow}">
<DockPanel Margin="14">
<TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,10"
Text="데이터소스(조회 결과)의 한 칸을 이 컨트롤에 꽂습니다. 형식이 어긋나면 런타임이 아무 경고 없이 값을 비워 두므로, 문법은 여기서 조립합니다."/>
<!-- 아래쪽 버튼 -->
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Content="배선 지우기" Padding="12,4" Click="OnClear"/>
<Button x:Name="OkButton" Content="적용" Padding="18,4" Margin="8,0,0,0"
Style="{StaticResource Primary}" Click="OnApply" IsDefault="True"/>
<Button Content="취소" Padding="18,4" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel>
<!-- 조립 결과 미리보기 + 검증 메시지 -->
<Border DockPanel.Dock="Bottom" Margin="0,12,0,0" Padding="10,8" CornerRadius="6"
Background="{DynamicResource B.Input}" BorderBrush="{DynamicResource B.InputBorder}" BorderThickness="1">
<StackPanel>
<TextBlock Text="저장될 값" FontSize="10.5" Foreground="{DynamicResource B.Muted}"/>
<TextBlock x:Name="PreviewText" FontFamily="Consolas" FontSize="12" TextWrapping="Wrap" Margin="0,3,0,0"/>
<TextBlock x:Name="ProblemText" FontSize="11" TextWrapping="Wrap" Margin="0,5,0,0"
Foreground="{DynamicResource B.Danger}" Visibility="Collapsed"/>
</StackPanel>
</Border>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="16"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- 데이터소스 -->
<DockPanel Grid.Column="0">
<TextBlock DockPanel.Dock="Top" Text="데이터소스" Margin="0,0,0,4"/>
<TextBlock x:Name="NoTableHint" DockPanel.Dock="Bottom" FontSize="11" TextWrapping="Wrap"
Margin="0,6,0,0" Foreground="{DynamicResource B.Warning}" Visibility="Collapsed"
Text="이 서식에 데이터소스가 없습니다. 팔레트에서 '데이터소스'를 먼저 놓고 쿼리를 지정하세요."/>
<ListBox x:Name="TableList" SelectionChanged="OnFormChanged" FontSize="12.5"/>
</DockPanel>
<!-- 나머지 조각 -->
<StackPanel Grid.Column="2">
<TextBlock Text="형태" Margin="0,0,0,4"/>
<ComboBox x:Name="FormBox" SelectionChanged="OnFormChanged"
ToolTip="Select 형은 레거시 편집기가 만드는 형태, Rows 형은 손으로 쓰던 형태입니다. 연 값의 형태를 그대로 유지합니다.">
<ComboBoxItem Content="Select — 필터로 행을 고름"/>
<ComboBoxItem Content="Rows — 행 번호로 직접"/>
</ComboBox>
<TextBlock x:Name="FilterLabel" Text="필터" Margin="0,10,0,4"/>
<TextBox x:Name="FilterBox" TextChanged="OnFormChanged"
ToolTip="DataTable.Select 문법 (예: CODCOD='A'). 비워 두면 전체 행입니다. 큰따옴표는 쓸 수 없습니다."/>
<TextBlock Text="행 번호" Margin="0,10,0,4"/>
<TextBox x:Name="RowBox" TextChanged="OnFormChanged" ToolTip="0부터 셉니다"/>
<TextBlock Text="필드" Margin="0,10,0,4"/>
<TextBox x:Name="FieldBox" TextChanged="OnFormChanged"
ToolTip="조회 결과의 컬럼명. SQL 별칭 표현식도 됩니다 (예: A||'-'||B). 큰따옴표는 쓸 수 없습니다."/>
<TextBlock x:Name="FieldHint" FontSize="11" TextWrapping="Wrap" Margin="0,6,0,0"
Foreground="{DynamicResource B.Muted}"
Text="필드 후보는 쿼리를 실행해야 알 수 있어 목록으로 제시하지 않습니다. 데이터소스의 쿼리를 열어 컬럼명을 확인하세요."/>
</StackPanel>
</Grid>
</DockPanel>
</Window>
@@ -0,0 +1,128 @@
using System.Globalization;
using System.Windows;
using System.Windows.Controls;
using SheetMe.Core.Catalog;
namespace SheetMe.Designer.Views;
/// <summary>
/// DataTableField 전용 편집기 — 문법 골격을 코드가 조립하고, 저장 전에 검사한다.
///
/// 레거시 편집기와 다르게 <b>기존 값을 되읽어 채운다</b>. 레거시는 열 때마다 백지에서 시작해
/// 한 조각만 고치려 해도 전부 다시 골라야 했다(fmMDataTableField.InitializeSelectedData).
/// 형태(Select/Rows)도 연 값 그대로 유지한다 — 운영 다수는 Rows 형이고(1,624건, 76%),
/// 형태를 바꾸면 그 형태만 이해하는 보조 로더에서 동작이 달라질 수 있다.
/// </summary>
public partial class DataTableFieldDialogView : Window
{
#region Member Fields
private readonly IReadOnlyList<string> tables;
private bool loading = true;
#endregion
#region Properties
/// <summary>적용 결과 — 취소면 null, '배선 지우기'면 빈 문자열</summary>
public string? Result { get; private set; }
#endregion
#region Constructors
public DataTableFieldDialogView(string? current, IReadOnlyList<string> dataTables)
{
InitializeComponent();
tables = dataTables;
foreach (var name in tables)
{
TableList.Items.Add(name);
}
NoTableHint.Visibility = tables.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
var spec = DataTableFieldSpec.Parse(current);
if (spec is not null)
{
FormBox.SelectedIndex = spec.Form == DataTableFieldForm.Rows ? 1 : 0;
TableList.SelectedItem = tables.FirstOrDefault(t => string.Equals(t, spec.TableName, StringComparison.Ordinal));
FilterBox.Text = spec.Filter;
RowBox.Text = spec.RowIndex.ToString(CultureInfo.InvariantCulture);
FieldBox.Text = spec.Field;
}
else
{
// 새 배선 — 레거시 편집기와 같은 Select 형으로 시작한다
FormBox.SelectedIndex = 0;
RowBox.Text = "0";
if (tables.Count == 1)
{
TableList.SelectedIndex = 0;
}
}
loading = false;
Refresh();
}
#endregion
#region Methods
/// <summary>
/// 현재 입력을 명세로 옮긴다 — 행 번호는 숫자가 아니면 0 으로 본다
/// (레거시도 빈 값을 0 으로 강제한다: fmMDataTableField.vb:135).
/// </summary>
private DataTableFieldSpec CurrentSpec()
{
var row = int.TryParse(RowBox.Text.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: 0;
return new DataTableFieldSpec
{
Form = FormBox.SelectedIndex == 1 ? DataTableFieldForm.Rows : DataTableFieldForm.Select,
TableName = TableList.SelectedItem as string ?? string.Empty,
Filter = FilterBox.Text,
RowIndex = row,
Field = FieldBox.Text.Trim(),
};
}
private void Refresh()
{
var spec = CurrentSpec();
var rowsForm = spec.Form == DataTableFieldForm.Rows;
// Rows 형은 필터를 아예 쓰지 않는다 — 켜 두면 입력해도 무시되는 칸이 된다
FilterBox.IsEnabled = !rowsForm;
FilterLabel.Opacity = rowsForm ? 0.45 : 1;
FilterBox.Opacity = rowsForm ? 0.45 : 1;
PreviewText.Text = spec.Compose();
var problem = spec.Validate(tables);
ProblemText.Text = problem ?? string.Empty;
ProblemText.Visibility = problem is null ? Visibility.Collapsed : Visibility.Visible;
// 레거시는 아무것도 안 고르고 확인하면 ")(" 같은 찌꺼기를 저장한다 — 그 경로를 막는다
OkButton.IsEnabled = problem is null;
}
private void OnFormChanged(object sender, RoutedEventArgs e)
{
if (!loading)
{
Refresh();
}
}
private void OnApply(object sender, RoutedEventArgs e)
{
var spec = CurrentSpec();
if (spec.Validate(tables) is not null)
{
return;
}
Result = spec.Compose();
DialogResult = true;
}
private void OnClear(object sender, RoutedEventArgs e)
{
Result = string.Empty;
DialogResult = true;
}
#endregion
}
@@ -415,6 +415,18 @@
</StackPanel> </StackPanel>
</DataTemplate> </DataTemplate>
<DataTemplate DataType="{x:Type ins:DataTableFieldRowViewModel}">
<DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
<Button DockPanel.Dock="Right" Content="편집…" Padding="8,2" Height="26" Margin="4,0,0,0"
Command="{Binding EditCommand}"
ToolTip="데이터소스 배선 편집기 — 문법을 직접 치지 않아도 됩니다"/>
<TextBlock Text="{Binding Summary}" FontSize="11" Foreground="{DynamicResource B.Muted}"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
ToolTip="{Binding ValueText}"/>
</DockPanel>
</DataTemplate>
<DataTemplate DataType="{x:Type ins:QueryRowViewModel}"> <DataTemplate DataType="{x:Type ins:QueryRowViewModel}">
<DockPanel Margin="0,2"> <DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/> <TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
@@ -0,0 +1,179 @@
using SheetMe.Core.Catalog;
namespace SheetMe.Core.Tests;
/// <summary>
/// DataTableField 배선 문자열의 파싱·조립·검증 테스트.
///
/// 표본은 지어내지 않고 운영 DB 실측(--db-dtf)과 레거시 소스에서 그대로 가져왔다.
/// 이 형식은 어겨도 런타임이 예외를 빈 catch 로 삼켜 <b>아무 표시 없이</b> 값이 안 채워지므로,
/// 여기서 막지 못하면 잘못된 배선이 임상 화면까지 간다.
/// </summary>
[TestClass]
public sealed class DataTableFieldSpecTests
{
/// <summary>레거시 전용 편집기가 만드는 정본 형태 — 공백이 하나도 없다</summary>
[TestMethod]
public void Parse_SelectForm_FromLegacyEditorOutput()
{
var spec = DataTableFieldSpec.Parse("mdt_CodDepUid.Select(\"\")(0).Item(\"CODSTRDTM\")");
Assert.IsNotNull(spec);
Assert.AreEqual(DataTableFieldForm.Select, spec.Form);
Assert.AreEqual("mdt_CodDepUid", spec.TableName);
Assert.AreEqual(string.Empty, spec.Filter);
Assert.AreEqual(0, spec.RowIndex);
Assert.AreEqual("CODSTRDTM", spec.Field);
}
/// <summary>운영 다수파(1,624건, 76%) — 손으로 쓴 Rows 형도 읽어야 한다</summary>
[TestMethod]
public void Parse_RowsForm_TheMajorityInProduction()
{
var spec = DataTableFieldSpec.Parse("MDataTable2.Rows(0).Item(\"ALGALGTYP\")");
Assert.IsNotNull(spec);
Assert.AreEqual(DataTableFieldForm.Rows, spec.Form);
Assert.AreEqual("MDataTable2", spec.TableName);
Assert.AreEqual(0, spec.RowIndex);
Assert.AreEqual("ALGALGTYP", spec.Field);
}
/// <summary>
/// 필드에는 SQL 별칭 표현식이 들어온다 — 식별자 화이트리스트로 막으면 실서식을 못 연다.
/// 실측 출처: [900]CGCH [349]퇴원요약지.
/// </summary>
[TestMethod]
public void Parse_FieldMayBeSqlExpression()
{
var spec = DataTableFieldSpec.Parse("MDataTable4.Select(\"\")(0).Item(\"EMDOPRDTE||'-'||EMDOPNAME\")");
Assert.IsNotNull(spec);
Assert.AreEqual("EMDOPRDTE||'-'||EMDOPNAME", spec.Field);
Assert.IsNull(spec.Validate());
}
/// <summary>필터가 있는 형태</summary>
[TestMethod]
public void Parse_SelectForm_WithFilterAndRow()
{
var spec = DataTableFieldSpec.Parse("MDataTable1.Select(\"CODNAM='A'\")(2).Item(\"CODCOD\")");
Assert.IsNotNull(spec);
Assert.AreEqual("CODNAM='A'", spec.Filter);
Assert.AreEqual(2, spec.RowIndex);
}
/// <summary>
/// 알아볼 수 없는 값은 null 이어야 한다 — 억지로 해석해 되쓰면 원문이 손상된다.
/// "MDataTable1.Rows" 는 실제로 운영에 234건 있는 미완성 값이고,
/// ")(" 는 레거시 편집기에서 아무것도 안 고르고 확인을 눌렀을 때 남는 찌꺼기다.
/// </summary>
[DataTestMethod]
[DataRow("MDataTable1.Rows")]
[DataRow(")(")]
[DataRow("MDataTable1")]
[DataRow("MDataTable1.Select(\"\")(0).Item(\"F\") ")] // 뒤 공백은 Trim 되지만
[DataRow("MDataTable1.Select(\"\")( 0 ).Item(\"F\")")] // 내부 공백은 파서가 못 읽는다
[DataRow("MDataTable1.select(\"\")(0).Item(\"F\")")] // 소문자 select — 런타임이 다른 가지로 샌다
public void Parse_ReturnsNull_ForShapesTheRuntimeCannotRead(string value)
{
var spec = DataTableFieldSpec.Parse(value);
// 뒤 공백만 있는 경우는 Trim 후 정상이다 — 그 한 건만 예외로 허용한다
if (value.Trim() == "MDataTable1.Select(\"\")(0).Item(\"F\")")
{
Assert.IsNotNull(spec);
return;
}
Assert.IsNull(spec, $"해석돼서는 안 되는 값이 해석됐습니다: {value}");
}
/// <summary>조립 결과가 다시 같은 값으로 읽혀야 한다</summary>
[DataTestMethod]
[DataRow(DataTableFieldForm.Select)]
[DataRow(DataTableFieldForm.Rows)]
public void Compose_RoundTrips(DataTableFieldForm form)
{
var spec = new DataTableFieldSpec
{
Form = form,
TableName = "MDataTable3",
Filter = "CODCOD='X'",
RowIndex = 1,
Field = "CODNAM",
};
Assert.IsTrue(spec.RoundTrips(), spec.Compose());
}
/// <summary>조립에는 공백이 들어가지 않는다 — 레거시 파서가 공백을 못 넘긴다</summary>
[TestMethod]
public void Compose_ContainsNoSpaces_AroundSyntax()
{
var spec = new DataTableFieldSpec { TableName = "MDataTable1", RowIndex = 0, Field = "F" };
Assert.AreEqual("MDataTable1.Select(\"\")(0).Item(\"F\")", spec.Compose());
}
/// <summary>Rows 형은 필터를 쓰지 않는다 — 값에 섞여 나가면 안 된다</summary>
[TestMethod]
public void Compose_RowsForm_DropsFilter()
{
var spec = new DataTableFieldSpec
{
Form = DataTableFieldForm.Rows,
TableName = "MDataTable1",
Filter = "무시돼야 한다",
RowIndex = 3,
Field = "F",
};
Assert.AreEqual("MDataTable1.Rows(3).Item(\"F\")", spec.Compose());
}
/// <summary>런타임을 조용히 깨뜨리는 값은 저장 전에 막는다</summary>
[TestMethod]
public void Validate_RejectsQuoteInFilter()
{
var spec = new DataTableFieldSpec
{
TableName = "MDataTable1", Filter = "CODNAM=\"A\"", RowIndex = 0, Field = "F",
};
Assert.IsNotNull(spec.Validate());
}
/// <summary>이름에 마침표가 있으면 런타임이 첫 조각만 떼어내 영영 못 찾는다</summary>
[TestMethod]
public void Validate_RejectsDotInTableName()
{
var spec = new DataTableFieldSpec { TableName = "a.b", RowIndex = 0, Field = "F" };
Assert.IsNotNull(spec.Validate());
}
/// <summary>
/// 같은 서식에 없는 데이터소스를 가리키면 막는다 — 대소문자까지 맞아야 한다.
/// 실측에서 이런 값이 20건 있었고 전부 조용히 실패한다.
/// </summary>
[TestMethod]
public void Validate_RejectsTableMissingFromThisSheet()
{
var spec = new DataTableFieldSpec { TableName = "MDataTable2", RowIndex = 0, Field = "F" };
Assert.IsNotNull(spec.Validate(new[] { "MDataTable3" }));
Assert.IsNull(spec.Validate(new[] { "MDataTable2" }));
// 대소문자가 다르면 런타임 Hashtable 조회가 실패한다
Assert.IsNotNull(spec.Validate(new[] { "mdatatable2" }));
}
/// <summary>필드가 비면 저장할 수 없다</summary>
[TestMethod]
public void Validate_RequiresField()
{
var spec = new DataTableFieldSpec { TableName = "MDataTable1", RowIndex = 0, Field = string.Empty };
Assert.IsNotNull(spec.Validate());
}
}