속성 큐레이션 근거를 재는 진단 2종 — --db-props / --db-lines

2단계(신규 서식 제작 가능 선언)에서 어떤 속성을 인스펙터에 올릴지 정해야 하는데,
그 판단을 취향이나 레거시 소스 읽기만으로 하고 싶지 않았다. 실제 서식이 무엇을 쓰는지 셌다.

--db-props : 타입별 속성 전수 집계(디자인 1,271건 / 컨트롤 164,091개).
  키별 보유율과 상위 값 분포를 함께 찍고, 현재 기술자에 없어 편집할 수 없는 키는 + 로 표시한다.
  이 목록이 곧 '레거시로는 되는데 우리로는 못 만드는 것'의 목록이다.

--db-lines : 선의 경계 모양과 Orientation 이 어긋나는 건수.
  우리 캔버스는 사각형으로 선을 그리고 EMR 은 Orientation 으로 그린다
  (MLine: Horizontal 이면 (0,0)→(Width,0), Vertical 이면 (0,0)→(0,Height)).
  두 규칙이 실제로 충돌하는지 모르면 렌더를 건드려야 하는지 알 수 없다.

첫 집계에서 이미 드러난 것:
- 선 32,599개 중 경계 모양과 Orientation 불일치 0건. 기존 서식에서는 두 규칙이 완전히 일치한다.
  → 캔버스 렌더를 바꿀 이유가 없다. 문제는 SheetMe 가 만드는 새 선뿐이다(Orientation 을 못 쓴다).
  → 세로선은 예외가 아니라 주류다: Vertical 12,721 대 Horizontal 90.
- Score 는 TextBox·CheckBox·RadioButton·CalcBox 에서 숫자지만
  ComboBox 에서는 문자열이다(-×625, ++×22, normal×10). 숫자 편집기를 붙이면 조용히 망가진다.
- 대소문자 함정이 실측으로 확인됐다: Label 은 소문자 visible 58,864건(99.7%),
  대문자 Visible 은 196건뿐. CheckBox·RadioButton 은 ControlVisible 을 따로 쓴다.
- PrintOutPut 은 사실상 항상 True(Label 54,510건 중 False 1건, Line 은 False 0건).
- IsRequiredValue 값은 True/False 가 아니라 No/Yes 다.

읽기 전용 진단이며 문서를 건드리지 않는다.
회귀: 편집 스모크 실패 0, 종이 렌더 P062 바이트 동일.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-13 12:19:21 +09:00
co-authored by Claude Opus 5
parent 8eeff42a72
commit df1a29afa2
2 changed files with 217 additions and 0 deletions
+10
View File
@@ -142,6 +142,16 @@ public partial class App : Application
return Diagnostics.DbSmoke.RunRow(args[1], args[2]);
}
if (args.Length >= 2 && args[0] == "--db-lines")
{
return Diagnostics.DbSmoke.RunLineReport(args[1]);
}
if (args.Length >= 2 && args[0] == "--db-props")
{
return Diagnostics.DbSmoke.RunPropUsageReport(args[1]);
}
if (args.Length >= 2 && args[0] == "--db-shtmst")
{
return Diagnostics.DbSmoke.RunShtMstReport(args[1]);
+207
View File
@@ -615,6 +615,213 @@ public static class DbSmoke
}
}
/// <summary>
/// 선(MLine) 방향 일치 검사(--db-lines) — 디자이너 렌더를 바꿔야 하는지 판단하는 근거(읽기 전용).
///
/// 우리 캔버스는 경계 사각형으로 선을 그리고, EMR 런타임은 Orientation 속성으로 그린다
/// (MLine.vb: Horizontal 이면 (0,0)→(Width,0), Vertical 이면 (0,0)→(0,Height)).
/// 두 규칙이 운영 데이터에서 실제로 어긋나는 건수를 세면,
/// 렌더를 고쳐야 하는지 / 속성만 노출하면 되는지가 사실로 갈린다.
/// </summary>
public static int RunLineReport(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 total = 0;
var hasOrientation = 0;
// 경계 모양(가로로 길다/세로로 길다)과 Orientation 이 어긋나는 건수
var lookVerticalDrawHorizontal = new List<string>(); // 화면엔 세로, EMR 은 가로(=1px 점) → 사라짐
var lookHorizontalDrawVertical = new List<string>();
var square = 0;
void Visit(Core.Models.ControlElement element, string sheetCod)
{
if (string.Equals(element.Type, "Line", StringComparison.Ordinal))
{
total++;
var orientation = element.Props.GetText("Orientation");
if (orientation is not null)
{
hasOrientation++;
}
var vertical = string.Equals(orientation, "Vertical", StringComparison.OrdinalIgnoreCase);
var w = element.Bounds.W;
var h = element.Bounds.H;
if (Math.Abs(w - h) < 0.5)
{
square++;
}
else if (h > w && !vertical)
{
lookVerticalDrawHorizontal.Add($" {sheetCod} {element.Id} {w}×{h} Orientation={orientation ?? "()"}");
}
else if (w > h && vertical)
{
lookHorizontalDrawVertical.Add($" {sheetCod} {element.Id} {w}×{h} Orientation=Vertical");
}
}
foreach (var child in element.Children)
{
Visit(child, sheetCod);
}
}
foreach (var sheet in sheets)
{
try
{
var raw = store.LoadActiveDesignRaw(sheet.ShtCod);
if (raw is null)
{
continue;
}
foreach (var page in serializer.Read(raw.Value.Xml).Pages)
{
Visit(page.Root, sheet.ShtCod);
}
}
catch
{
// 통계용
}
}
lines.Add($"선 {total}개, Orientation 보유 {hasOrientation}개 ({100.0 * hasOrientation / Math.Max(1, total):F1}%), 정사각 {square}개");
lines.Add($"모양은 세로인데 EMR 은 가로로 그림(사라짐): {lookVerticalDrawHorizontal.Count}개");
lines.AddRange(lookVerticalDrawHorizontal.Take(30));
lines.Add($"모양은 가로인데 EMR 은 세로로 그림: {lookHorizontalDrawVertical.Count}개");
lines.AddRange(lookHorizontalDrawVertical.Take(30));
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>
/// 타입별 속성 사용 실태 전수 집계(--db-props) — 속성 큐레이션의 근거(읽기 전용).
///
/// 어떤 키를 인스펙터에 올릴지는 취향이 아니라 실사용 빈도로 정해야 한다.
/// 함께 '기술자에 없는 키'를 표시해, 운영에서 쓰는데 우리 화면에서 편집할 수 없는 것을 드러낸다.
/// </summary>
public static int RunPropUsageReport(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 typeCounts = new Dictionary<string, int>(StringComparer.Ordinal);
// 타입 → 키 → 보유 인스턴스 수
var keyCounts = new Dictionary<string, Dictionary<string, int>>(StringComparer.Ordinal);
// 타입 → 키 → 값 → 건수
var valueCounts = new Dictionary<string, Dictionary<string, Dictionary<string, int>>>(StringComparer.Ordinal);
void Visit(Core.Models.ControlElement element)
{
var type = element.Type;
typeCounts[type] = typeCounts.GetValueOrDefault(type) + 1;
if (!keyCounts.TryGetValue(type, out var keys))
{
keys = new Dictionary<string, int>(StringComparer.Ordinal);
keyCounts[type] = keys;
valueCounts[type] = new Dictionary<string, Dictionary<string, int>>(StringComparer.Ordinal);
}
foreach (var key in element.Props.Keys)
{
keys[key] = keys.GetValueOrDefault(key) + 1;
// 값 분포는 짧은 값만 — 수식·쿼리·이미지는 집계 의미가 없다
var value = element.Props.GetText(key);
if (value is not null && value.Length <= 24)
{
if (!valueCounts[type].TryGetValue(key, out var bucket))
{
bucket = new Dictionary<string, int>(StringComparer.Ordinal);
valueCounts[type][key] = bucket;
}
bucket[value] = bucket.GetValueOrDefault(value) + 1;
}
}
foreach (var child in element.Children)
{
Visit(child);
}
}
var scanned = 0;
foreach (var sheet in sheets)
{
try
{
var raw = store.LoadActiveDesignRaw(sheet.ShtCod);
if (raw is null)
{
continue;
}
scanned++;
foreach (var page in serializer.Read(raw.Value.Xml).Pages)
{
foreach (var child in page.Root.Children)
{
Visit(child);
}
}
}
catch
{
// 통계용 — 개별 실패는 건너뛴다(왕복 검증은 --db-smoke 가 본다)
}
}
lines.Add($"디자인 {scanned}건 스캔, 컨트롤 {typeCounts.Values.Sum()}개");
foreach (var (type, count) in typeCounts.OrderByDescending(p => p.Value))
{
lines.Add(string.Empty);
var descriptor = Core.Catalog.ControlRegistry.Find(type);
var known = descriptor is null
? new HashSet<string>(StringComparer.Ordinal)
: descriptor.Properties.Select(p => p.Key).ToHashSet(StringComparer.Ordinal);
lines.Add($"[{type}] {count}개" + (descriptor is null ? " ※ 기술자 없음" : ""));
foreach (var (key, holders) in keyCounts[type].OrderByDescending(p => p.Value))
{
var share = 100.0 * holders / count;
var mark = known.Contains(key) ? " " : ""; // = 인스펙터에서 편집 불가
var values = string.Empty;
if (valueCounts[type].TryGetValue(key, out var bucket))
{
values = " " + string.Join(" ", bucket.OrderByDescending(p => p.Value).Take(4)
.Select(p => $"{p.Key}×{p.Value}"));
}
lines.Add($"{mark} {key,-28} {holders,7} ({share,5:F1}%){values}");
}
}
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>
/// 서식 마스터 등록 관련 컬럼 값 분포(--db-shtmst) — 신규 등록 기본값을 실측으로 정하기 위한 집계(읽기 전용).
///