Files
MsystechandClaude Opus 5 f7c90979b9 쿼리 편집기 — 자동완성 위치 버그와 목록 가독성
■ 팝업이 화면 구석으로 날아가던 문제

두 가지가 겹쳐 있었다.
1. WPF Popup 은 열려 있는 동안 Placement 를 다시 계산하지 않는다. 글자를 칠 때마다
   HorizontalOffset/VerticalOffset 만 바꿔서는 처음 열린 자리에 그대로 머문다.
2. GetRectFromCharacterIndex 는 스크롤 밖이나 범위를 벗어난 위치에서 Empty 를 준다.
   그대로 쓰면 좌표가 무한대가 되어 팝업이 화면 밖으로 나간다.

기준점을 커서가 아니라 <b>완성 중인 낱말의 시작</b>으로 바꾸고(글자마다 흔들리지 않는다),
그 기준점이 달라졌을 때만 닫았다 다시 연다. Empty·무한대는 캐럿 → 원점 순으로 물러선다.

■ 별칭 없이 컬럼을 칠 때 아무것도 안 뜨던 문제

  select * FROM P_COMINF where comcht|

점을 찍어야만 컬럼을 제안하고 있었다. 그런데 별칭을 안 쓰는 쪽이 오히려 흔하고,
WHERE 절이야말로 컬럼 이름이 가장 많이 필요한 자리다.
이제 일반 위치에서도 이 쿼리가 참조하는 테이블(FROM/JOIN)의 컬럼을 먼저 제안한다.
FROM 뒤에 키워드가 오면 테이블로 보지 않고, 같은 테이블이 두 번 조인돼도 후보는 한 번만 만든다.

■ 치환 변수 목록 가독성

토큰이 60자가 넘고 앞 40자(M.CMM.HISOperatingInfo.bzPatientInfo.)가 모든 줄에서 똑같았다.
그대로 두 줄로 깔면 정작 다른 부분인 속성명이 오른쪽 끝에서 잘려 무엇이 무엇인지 구분되지 않는다.
설명 + 속성명 한 줄로 바꾸고 전체 토큰은 툴팁으로 옮겼다. 줄 수도 절반이 됐다.
DataRow 형태는 꼬리(.item("컬럼명"))를 빼고 속성명만 남긴다 — 컬럼 채우기라는 사실은 설명이 말해 준다.

■ 현재 줄 강조

긴 쿼리에서 지금 어디를 고치고 있는지 잃지 않도록 커서 줄에 옅은 띠를 깐다.
선택 중일 때는 끈다 — 선택 색과 겹치면 오히려 읽기 어렵다.

단위 테스트 6건 추가(별칭 없는 테이블 참조·중복 조인·FROM 뒤 키워드 배제·
짧은 형태에 클래스 이름 없음·길이 상한·DataRow 속성명).
회귀: 테스트 216/216, 편집 스모크 실패 0, 검증 실행 점검 10/10,
DB 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 바이트 동일.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 15:28:10 +09:00

165 lines
6.2 KiB
C#

using SheetMe.Core.Catalog;
namespace SheetMe.Core.Tests;
/// <summary>
/// 자동완성 문맥 판단 테스트.
///
/// 문맥을 잘못 잡으면 목록이 엉뚱한 것을 보여 주고, 그러면 안 쓰느니만 못하다.
/// 특히 <c>&lt;&lt;</c> 직후는 이 편집기에만 있는 문법이라 여기서 놓치면 손으로 외워 쳐야 한다.
/// </summary>
[TestClass]
public sealed class SqlCompletionTests
{
/// <summary>커서를 | 로 표시한 텍스트를 받아 분석한다 — 테스트가 읽히게</summary>
private static SqlCompletionQuery? At(string marked)
{
var caret = marked.IndexOf('|');
return SqlCompletion.Analyze(marked.Remove(caret, 1), caret);
}
[TestMethod]
public void Analyze_AfterOpenAngle_SuggestsVariables()
{
var query = At("WHERE a = <<M.CMM|");
Assert.IsNotNull(query);
Assert.AreEqual(SqlCompletionContext.Variable, query.Context);
Assert.AreEqual("M.CMM", query.Prefix);
}
/// <summary>이미 닫힌 변수 뒤에서는 변수 문맥이 아니다</summary>
[TestMethod]
public void Analyze_AfterClosedVariable_IsNotVariableContext()
{
var query = At("WHERE a = <<A.B.C>> AND na|");
Assert.IsNotNull(query);
Assert.AreNotEqual(SqlCompletionContext.Variable, query.Context);
}
[DataTestMethod]
[DataRow("SELECT * FROM P_|")]
[DataRow("SELECT * FROM t JOIN P_|")]
public void Analyze_AfterFromOrJoin_SuggestsTables(string marked)
{
var query = At(marked);
Assert.IsNotNull(query);
Assert.AreEqual(SqlCompletionContext.TableName, query.Context);
Assert.AreEqual("P_", query.Prefix);
}
/// <summary>FROM 바로 뒤(아직 한 글자도 안 침)에서도 테이블을 제안해야 한다</summary>
[TestMethod]
public void Analyze_ImmediatelyAfterFrom_SuggestsTablesWithEmptyPrefix()
{
var query = At("SELECT * FROM |");
Assert.IsNotNull(query);
Assert.AreEqual(SqlCompletionContext.TableName, query.Context);
Assert.AreEqual(string.Empty, query.Prefix);
}
[TestMethod]
public void Analyze_AfterDot_SuggestsColumnsOfQualifier()
{
var query = At("SELECT p.Cht| FROM P_PatMst p");
Assert.IsNotNull(query);
Assert.AreEqual(SqlCompletionContext.ColumnOf, query.Context);
Assert.AreEqual("p", query.Qualifier);
Assert.AreEqual("Cht", query.Prefix);
}
/// <summary>아무것도 안 친 일반 위치에서는 목록을 띄우지 않는다 — 방해가 된다</summary>
[TestMethod]
public void Analyze_EmptyPrefixInGeneralPosition_ReturnsNull()
=> Assert.IsNull(At("SELECT a, |"));
[TestMethod]
public void Analyze_TypingWord_SuggestsGeneral()
{
var query = At("SEL|");
Assert.IsNotNull(query);
Assert.AreEqual(SqlCompletionContext.General, query.Context);
Assert.AreEqual("SEL", query.Prefix);
Assert.AreEqual(0, query.ReplaceStart);
}
/// <summary>별칭을 실제 테이블로 풀어야 컬럼을 찾을 수 있다</summary>
[DataTestMethod]
[DataRow("SELECT p.x FROM P_PatMst p", "p", "P_PatMst")]
[DataRow("SELECT p.x FROM P_PatMst AS p", "p", "P_PatMst")]
[DataRow("SELECT c.x FROM P_PatMst p JOIN P_ComInf c ON p.a=c.a", "c", "P_ComInf")]
public void ResolveTable_FindsAliasTarget(string sql, string qualifier, string expected)
=> Assert.AreEqual(expected, SqlCompletion.ResolveTable(sql, qualifier));
/// <summary>별칭이 없으면 한정자 자체가 테이블 이름이다</summary>
[TestMethod]
public void ResolveTable_FallsBackToQualifier()
=> Assert.AreEqual("P_PatMst", SqlCompletion.ResolveTable("SELECT * FROM P_PatMst", "P_PatMst"));
/// <summary>
/// 별칭 자리에 키워드가 오면 별칭이 아니다 — <c>FROM P_PatMst WHERE ...</c> 에서
/// WHERE 를 별칭으로 잡으면 엉뚱한 테이블을 가리킨다.
/// </summary>
[TestMethod]
public void ResolveTable_IgnoresKeywordAsAlias()
=> Assert.AreEqual("WHERE", SqlCompletion.ResolveTable("SELECT * FROM P_PatMst WHERE a=1", "WHERE"));
[TestMethod]
public void Filter_PutsPrefixMatchesFirst()
{
var items = new[]
{
new SqlCompletionItem("PrintBackColor", SqlCompletionKind.Column),
new SqlCompletionItem("PrintOutPut", SqlCompletionKind.Column),
new SqlCompletionItem("XPrint", SqlCompletionKind.Column),
};
var filtered = SqlCompletion.Filter(items, "Print");
Assert.AreEqual(3, filtered.Count, "부분 일치도 포함해야 한다");
Assert.AreEqual("PrintOutPut", filtered[0].Text, "접두 일치 중 짧은 것이 먼저");
Assert.AreEqual("XPrint", filtered[^1].Text, "부분 일치는 뒤로");
}
/// <summary>
/// 별칭 없이 컬럼을 칠 때도 후보가 나와야 한다 — 별칭을 안 쓰는 쪽이 오히려 흔하다.
/// 이걸 못 잡으면 WHERE 절에서 아무것도 안 뜬다.
/// </summary>
[TestMethod]
public void ReferencedTables_FindsTablesWithoutAlias()
{
var tables = SqlCompletion.ReferencedTables("select * FROM P_COMINF where comcht");
CollectionAssert.AreEqual(new[] { "P_COMINF" }, tables.ToArray());
}
[TestMethod]
public void ReferencedTables_FindsJoinedTablesOnce()
{
var tables = SqlCompletion.ReferencedTables(
"SELECT * FROM P_PatMst p JOIN P_ComInf c ON p.a=c.a JOIN P_ComInf d ON p.b=d.b");
CollectionAssert.AreEqual(new[] { "P_PatMst", "P_ComInf" }, tables.ToArray(),
"같은 테이블이 두 번 조인돼도 후보는 한 번만");
}
/// <summary>FROM 뒤에 키워드가 오면 테이블로 보지 않는다</summary>
[TestMethod]
public void ReferencedTables_IgnoresKeywordAfterFrom()
=> Assert.AreEqual(0, SqlCompletion.ReferencedTables("SELECT * FROM SELECT").Count);
[TestMethod]
public void BuiltIns_CoverKeywordsAndFunctions()
{
var items = SqlCompletion.BuiltIns();
Assert.IsTrue(items.Any(i => i.Text == "SELECT" && i.Kind == SqlCompletionKind.Keyword));
Assert.IsTrue(items.Any(i => i.Text == "NVL" && i.Kind == SqlCompletionKind.Function));
}
}