From 2310971a104cce1cc1fe18cf5bc6e7a310406961 Mon Sep 17 00:00:00 2001 From: Msystech Date: Thu, 20 Aug 2026 09:35:27 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9D=B8=EC=87=84=20=EC=9A=A9=EC=A7=80?= =?UTF-8?q?=EB=A5=BC=20=EB=AC=B8=EC=84=9C=20=ED=81=AC=EA=B8=B0=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=9C=EC=8B=9C=20+=20Select=20=ED=98=95=20=EB=B0=B0?= =?UTF-8?q?=EC=84=A0=20=ED=95=84=ED=84=B0=20=EC=8B=A4=ED=96=89=20=E2=80=94?= =?UTF-8?q?=20=EB=82=A8=EC=9D=80=20=EA=B3=BC=EC=A0=9C=202=EA=B1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① PDF 높이 어긋남(735pt vs 문서 642pt) 원인 규명·수정: 인쇄가 용지(PrintTicket)를 지정하지 않아 드라이버가 폭이 비슷한 기본 용지 (540×735pt ≈ 16절 190×260mm — test1.pdf MediaBox 실측)에 문서를 얹었다. 폭이 우연히 같아 좌우는 멀쩡하고 아래에만 33mm 여백이 붙어 화면과 다른 종이가 됐다. ShowDialog 전에 PageMediaSize 를 문서 첫 페이지 DIP 크기로 제시 — 사용자가 대화상자에서 용지를 바꾸면 그 선택이 이긴다. (레거시는 사용자가 고른 용지에 문서를 맞추는 방식 (clPrintDesignSheet.vb:660)이라 이 기본값 제시와 충돌하지 않는다.) ② Select 형 DataTableField 필터(운영 268건, 12%) 실행: 지금까지 "아직 옮기지 않았습니다" 사유였다. 필터 문법을 직접 파싱하지 않고 .NET DataTable.Select 에 그대로 넘긴다 — 레거시 배선이 쓰는 바로 그 엔진이라 문법 호환을 증명할 필요가 없다. 행 선택을 순수 함수 (DataTableFieldPicker)로 분리해 DB 없이 검증(테스트 5건 — 필터·0행· 문법 오류·Rows 형·중복 컬럼명 Fill 규칙). 알려진 한계 주석: 우리 결과는 전부 문자열 컬럼이라 숫자 대소 비교 필터는 사전순이 될 수 있다(운영 필터 대부분은 동등 비교). - dotnet test 347/347 (picker 5건 추가) · --edit-smoke 실패 0 - --db-patient ①~㊱ 전건 통과 - --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변 Co-Authored-By: Claude Fable 5 --- .../Catalog/DataTableFieldPicker.cs | 95 +++++++++++++++++++ .../Services/MDataTableRunner.cs | 34 +------ src/SheetMe.Designer/Services/PrintService.cs | 12 +++ .../DataTableFieldPickerTests.cs | 73 ++++++++++++++ 4 files changed, 183 insertions(+), 31 deletions(-) create mode 100644 src/SheetMe.Core/Catalog/DataTableFieldPicker.cs create mode 100644 tests/SheetMe.Core.Tests/DataTableFieldPickerTests.cs diff --git a/src/SheetMe.Core/Catalog/DataTableFieldPicker.cs b/src/SheetMe.Core/Catalog/DataTableFieldPicker.cs new file mode 100644 index 0000000..b356923 --- /dev/null +++ b/src/SheetMe.Core/Catalog/DataTableFieldPicker.cs @@ -0,0 +1,95 @@ +using System.Data; + +namespace SheetMe.Core.Catalog; + +/// +/// 조회 결과(컬럼·행 문자열)에서 이 가리키는 한 칸을 꺼낸다 — +/// Rows 형은 행 번호로, Select 형은 .NET DataTable.Select 를 그대로 호출해서 고른다. +/// +/// 필터 문법을 직접 파싱하지 않는다. 레거시 배선의 Select 필터는 .NET +/// DataTable.Select(filterExpression) 문법 그대로다 — 같은 엔진에 넘기면 +/// 문법 호환을 우리가 증명할 필요가 없다. 파서를 새로 쓰면 LIKE·괄호·이스케이프에서 +/// 조용히 갈라진다. +/// +/// 알려진 한계: 우리 결과는 전부 문자열 컬럼이라(레거시는 Oracle 어댑터가 +/// 실타입으로 채운다) 숫자 대소 비교 필터(예: AMT > 100)는 사전순이 된다. +/// 운영 실측 필터는 대부분 동등 비교(컬럼 = '값')라 실질 영향이 좁지만, +/// 대소 비교 필터를 만나면 값이 다를 수 있다 — 그때 타입 추론을 붙인다. +/// +public static class DataTableFieldPicker +{ + #region Methods + /// 한 칸의 값 — 못 고르면 사유 + public static TagValue Pick(IReadOnlyList columns, IReadOnlyList rows, + DataTableFieldSpec spec) + { + var columnIndex = -1; + for (var i = 0; i < columns.Count; i++) + { + if (string.Equals(columns[i], spec.Field, StringComparison.OrdinalIgnoreCase)) + { + columnIndex = i; + break; + } + } + if (columnIndex < 0) + { + return new TagValue(false, $"결과에 '{spec.Field}' 컬럼이 없습니다" + + $" (있는 컬럼: {string.Join(", ", columns.Take(8))})"); + } + + if (spec.Form == DataTableFieldForm.Select && spec.Filter.Length > 0) + { + using var table = new DataTable(); + foreach (var name in columns) + { + // SELECT * 조인은 컬럼명이 겹칠 수 있다 — Oracle 어댑터의 Fill 과 같은 + // 규칙(이름1, 이름2 …)으로 비껴 담는다. spec.Field 는 첫 것과 매칭된다. + var unique = name; + var suffix = 1; + while (table.Columns.Contains(unique)) + { + unique = name + suffix.ToString(System.Globalization.CultureInfo.InvariantCulture); + suffix++; + } + table.Columns.Add(unique, typeof(string)); + } + foreach (var row in rows) + { + table.Rows.Add(row.Select(object (c) => c ?? string.Empty).ToArray()); + } + DataRow[] picked; + try + { + picked = table.Select(spec.Filter); + } + catch (Exception ex) + { + // 필터 문법이 깨졌다 — 레거시 런타임은 빈 catch 로 삼켜 빈칸이 됐다 + return new TagValue(false, $"Select 필터를 해석할 수 없습니다: {ex.Message}"); + } + if (spec.RowIndex >= picked.Length) + { + return new TagValue(false, picked.Length == 0 + ? $"필터({spec.Filter})에 맞는 행이 없습니다" + : $"{spec.RowIndex}번 행이 없습니다(필터 결과 {picked.Length}행)"); + } + var filtered = picked[spec.RowIndex][columnIndex]?.ToString() ?? string.Empty; + return filtered.Length > 0 + ? new TagValue(true, filtered) + : new TagValue(false, $"{spec.Field} 값이 비어 있습니다"); + } + + if (spec.RowIndex >= rows.Count) + { + return new TagValue(false, rows.Count == 0 + ? "조회 결과가 0행입니다" + : $"{spec.RowIndex}번 행이 없습니다(결과 {rows.Count}행)"); + } + var cell = rows[spec.RowIndex][columnIndex] ?? string.Empty; + return cell.Length > 0 + ? new TagValue(true, cell) + : new TagValue(false, $"{spec.Field} 값이 비어 있습니다"); + } + #endregion +} diff --git a/src/SheetMe.Designer/Services/MDataTableRunner.cs b/src/SheetMe.Designer/Services/MDataTableRunner.cs index 0183368..aada052 100644 --- a/src/SheetMe.Designer/Services/MDataTableRunner.cs +++ b/src/SheetMe.Designer/Services/MDataTableRunner.cs @@ -95,42 +95,14 @@ public sealed class MDataTableRunner : IDataFieldResolver // 아무 표시 없이 빈칸이 된다. 여기서는 이름을 말한다. return new TagValue(false, $"데이터소스 '{spec.TableName}' 이 이 서식에 없습니다"); } - if (spec.Form == DataTableFieldForm.Select && spec.Filter.Length > 0) - { - // Select 필터는 DataTable.Select 의 메모리 필터 문법이다(SQL 이 아니다). - // 아직 옮기지 않았다 — 무시하고 행 번호만 쓰면 다른 행의 값이 조용히 찍힌다. - return new TagValue(false, $"Select 필터를 아직 옮기지 않았습니다: {spec.Filter}"); - } - var run = Run(spec.TableName); if (!run.Ok) { return new TagValue(false, run.Error ?? "조회에 실패했습니다"); } - if (spec.RowIndex >= run.Rows.Count) - { - return new TagValue(false, run.Rows.Count == 0 - ? "조회 결과가 0행입니다" - : $"{spec.RowIndex}번 행이 없습니다(결과 {run.Rows.Count}행)"); - } - var column = -1; - for (var i = 0; i < run.Columns.Count; i++) - { - if (string.Equals(run.Columns[i], spec.Field, StringComparison.OrdinalIgnoreCase)) - { - column = i; - break; - } - } - if (column < 0) - { - return new TagValue(false, $"결과에 '{spec.Field}' 컬럼이 없습니다" - + $" (있는 컬럼: {string.Join(", ", run.Columns.Take(8))})"); - } - var cell = run.Rows[spec.RowIndex][column] ?? string.Empty; - return cell.Length > 0 - ? new TagValue(true, cell) - : new TagValue(false, $"{spec.Field} 값이 비어 있습니다"); + // 행 선택(Rows 형 번호 / Select 형 .NET DataTable.Select 필터)은 + // 순수 함수에 있다 — DB 없이 검증할 수 있어야 진단이 접속에 안 묶인다 + return DataTableFieldPicker.Pick(run.Columns, run.Rows, spec); } private MDataTableResult Execute(string name) diff --git a/src/SheetMe.Designer/Services/PrintService.cs b/src/SheetMe.Designer/Services/PrintService.cs index 72ca1be..6cf9aa6 100644 --- a/src/SheetMe.Designer/Services/PrintService.cs +++ b/src/SheetMe.Designer/Services/PrintService.cs @@ -21,6 +21,18 @@ public static class PrintService SheetMe.Core.Catalog.IDataFieldResolver? fields = null) { var dialog = new System.Windows.Controls.PrintDialog(); + // 용지를 문서 크기로 미리 지정한다 — 지정하지 않으면 드라이버가 폭이 비슷한 + // 기본 용지에 얹는다. 실측: P062(720×856DIP = 540×642pt)를 PDF 로 뽑았더니 + // MediaBox 가 540×735pt(≈16절 190×260mm)였다 — 폭이 우연히 같아 좌우는 멀쩡하고 + // 아래에만 33mm 여백이 더 붙어 화면과 다른 종이가 됐다. + // ShowDialog 전에 넣어야 대화상자의 기본값이 되고, 사용자가 대화상자에서 + // 용지를 바꾸면 그 선택이 이긴다(우리는 기본값만 제시한다). + if (designer.Pages.Count > 0) + { + var first = designer.Pages[0]; + dialog.PrintTicket.PageMediaSize = new System.Printing.PageMediaSize( + first.WidthDip, first.HeightDip); // PageMediaSize 는 1/96" (DIP) 단위다 + } if (dialog.ShowDialog() != true) { return; diff --git a/tests/SheetMe.Core.Tests/DataTableFieldPickerTests.cs b/tests/SheetMe.Core.Tests/DataTableFieldPickerTests.cs new file mode 100644 index 0000000..2280e2c --- /dev/null +++ b/tests/SheetMe.Core.Tests/DataTableFieldPickerTests.cs @@ -0,0 +1,73 @@ +using SheetMe.Core.Catalog; + +namespace SheetMe.Core.Tests; + +/// +/// 배선 한 칸 선택 — Select 형 필터는 .NET DataTable.Select 를 그대로 쓰므로 +/// 여기서는 "같은 엔진에 제대로 넘겼는가"(필터 적용·행 선택·사유)를 본다. +/// +[TestClass] +public sealed class DataTableFieldPickerTests +{ + #region Methods + private static readonly string[] Columns = { "NAM", "TYP" }; + + private static readonly string[][] Rows = + { + new[] { "감기", "M" }, + new[] { "골절", "S" }, + new[] { "당뇨", "S" }, + }; + + private static DataTableFieldSpec Select(string filter, int row, string field = "NAM") + => new() + { + TableName = "T", Form = DataTableFieldForm.Select, + Filter = filter, RowIndex = row, Field = field, + }; + + [TestMethod] + public void Select_필터가_행을_거른다() + { + // TYP='S' 인 행이 둘 — 0번은 골절, 1번은 당뇨 + Assert.AreEqual("골절", DataTableFieldPicker.Pick(Columns, Rows, Select("TYP = 'S'", 0)).Text); + Assert.AreEqual("당뇨", DataTableFieldPicker.Pick(Columns, Rows, Select("TYP = 'S'", 1)).Text); + } + + [TestMethod] + public void Select_필터에_맞는_행이_없으면_사유다() + { + var value = DataTableFieldPicker.Pick(Columns, Rows, Select("TYP = 'X'", 0)); + Assert.IsFalse(value.Resolved); + StringAssert.Contains(value.Text, "맞는 행이 없습니다"); + } + + [TestMethod] + public void Select_필터_문법이_깨지면_사유다() + { + // 레거시 런타임은 빈 catch 로 삼켜 빈칸이 됐다 — 여기서는 사유가 나온다 + var value = DataTableFieldPicker.Pick(Columns, Rows, Select("TYP === ", 0)); + Assert.IsFalse(value.Resolved); + StringAssert.Contains(value.Text, "해석할 수 없습니다"); + } + + [TestMethod] + public void Rows_형은_행_번호로_고른다() + { + var spec = new DataTableFieldSpec + { + TableName = "T", Form = DataTableFieldForm.Rows, RowIndex = 2, Field = "NAM", + }; + Assert.AreEqual("당뇨", DataTableFieldPicker.Pick(Columns, Rows, spec).Text); + } + + [TestMethod] + public void 중복_컬럼명은_Fill_규칙으로_비껴_담고_첫_컬럼과_매칭된다() + { + // SELECT * 조인에서 같은 이름이 두 번 오는 경우 — 첫 컬럼의 값이 나와야 한다 + var dup = new[] { "NAM", "NAM" }; + var rows = new[] { new[] { "첫값", "둘값" } }; + Assert.AreEqual("첫값", DataTableFieldPicker.Pick(dup, rows, Select("NAM = '첫값'", 0)).Text); + } + #endregion +}