MCP-서버 리팩토링 후 P&ID 추출 테스트전 다른 기능 확인 후 커밋

This commit is contained in:
windpacer
2026-05-04 10:35:13 +09:00
parent a0404b1fee
commit 15c17522c8
304 changed files with 5431877 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\src\Web\ExperionCrawler.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1 @@
global using Xunit;

View File

@@ -0,0 +1,382 @@
using ExperionCrawler.Core.Application.Services;
using Xunit;
namespace ExperionCrawler.Tests;
/// <summary>
/// KoreanTimeRangeExtractor 단위 테스트
/// 새로운 한글 시간 패턴 파싱 로직을 검증합니다.
/// </summary>
public class KoreanTimeRangeExtractorTests
{
private readonly KoreanTimeRangeExtractor _extractor;
private readonly KstClock _kstClock;
public KoreanTimeRangeExtractorTests()
{
// 테스트용 고정 시계: KST 2026-04-23 12:00:00 = UTC 2026-04-23 03:00:00
var fixedClock = new FixedClock(new DateTimeOffset(2026, 4, 23, 3, 0, 0, TimeSpan.Zero));
_kstClock = new KstClock(fixedClock);
_extractor = new KoreanTimeRangeExtractor(_kstClock);
}
#region : ~
[Fact]
public void AbsoluteRange_FromTo_KoreanDate()
{
// Arrange & Act
var result = _extractor.Extract("4월 3일 부터 4월 5일 까지");
// Assert
Assert.NotNull(result.KstFrom);
Assert.NotNull(result.KstTo);
Assert.Null(result.PostgresInterval);
// KST 2026-04-03 00:00 ~ 2026-04-06 00:00 (까지의 다음날)
Assert.Equal(2026, result.KstFrom!.Value.Year);
Assert.Equal(4, result.KstFrom.Value.Month);
Assert.Equal(3, result.KstFrom.Value.Day);
Assert.Equal(2026, result.KstTo!.Value.Year);
Assert.Equal(4, result.KstTo.Value.Month);
Assert.Equal(6, result.KstTo.Value.Day); // 다음날 00:00
}
[Fact]
public void AbsoluteRange_FromTo_IsoDate()
{
// Arrange & Act
var result = _extractor.Extract("2026-04-03 부터 2026-04-05 까지");
// Assert
Assert.NotNull(result.KstFrom);
Assert.NotNull(result.KstTo);
}
[Fact]
public void AbsoluteRange_FromTo_WithTime()
{
// Arrange & Act
var result = _extractor.Extract("어제 09:00 부터 오늘 18:00 까지");
// Assert
Assert.NotNull(result.KstFrom);
Assert.NotNull(result.KstTo);
// KST 2026-04-22 09:00 ~ 2026-04-23 18:00
Assert.Equal(2026, result.KstFrom!.Value.Year);
Assert.Equal(4, result.KstFrom.Value.Month);
Assert.Equal(22, result.KstFrom.Value.Day);
Assert.Equal(9, result.KstFrom.Value.Hour);
Assert.Equal(2026, result.KstTo!.Value.Year);
Assert.Equal(4, result.KstTo.Value.Month);
Assert.Equal(23, result.KstTo.Value.Day);
Assert.Equal(18, result.KstTo.Value.Hour);
}
[Fact]
public void AbsoluteRange_FromTo_WithAmPm()
{
// Arrange & Act
var result = _extractor.Extract("오전 9시부터 오후 6시까지");
// Assert
Assert.NotNull(result.KstFrom);
Assert.NotNull(result.KstTo);
// KST 2026-04-23 09:00 ~ 18:00
Assert.Equal(9, result.KstFrom!.Value.Hour);
Assert.Equal(18, result.KstTo!.Value.Hour);
}
[Fact]
public void AbsoluteRange_WithTimeComponent_PreservesTime()
{
// Arrange & Act
var result = _extractor.Extract("2026-03-01 14:00 부터 2026-03-01 16:30 까지");
// Assert
Assert.NotNull(result.KstFrom);
Assert.NotNull(result.KstTo);
Assert.Equal(14, result.KstFrom!.Value.Hour);
Assert.Equal(0, result.KstFrom.Value.Minute);
Assert.Equal(16, result.KstTo!.Value.Hour);
Assert.Equal(30, result.KstTo.Value.Minute);
}
#endregion
#region : /
[Fact]
public void OneDirection_After()
{
// Arrange & Act
var result = _extractor.Extract("오늘 오후 2시 이후");
// Assert
Assert.NotNull(result.KstFrom);
Assert.Null(result.KstTo);
Assert.Null(result.PostgresInterval);
// KST 2026-04-23 14:00
Assert.Equal(14, result.KstFrom!.Value.Hour);
}
[Fact]
public void OneDirection_Before()
{
// Arrange & Act
var result = _extractor.Extract("2026-05-05 이전");
// Assert
Assert.Null(result.KstFrom);
Assert.NotNull(result.KstTo);
Assert.Null(result.PostgresInterval);
// KST 2026-05-05
Assert.Equal(2026, result.KstTo!.Value.Year);
Assert.Equal(5, result.KstTo.Value.Month);
Assert.Equal(5, result.KstTo.Value.Day);
}
[Fact]
public void OneDirection_FromOnly()
{
// Arrange & Act
var result = _extractor.Extract("4월 3일 부터");
// Assert
Assert.NotNull(result.KstFrom);
Assert.Null(result.KstTo);
Assert.Null(result.PostgresInterval);
}
#endregion
#region : / N시간||
[Theory]
[InlineData("최근 3시간", "3 hours")]
[InlineData("지난 2시간", "2 hours")]
[InlineData("최근 30분", "30 minutes")]
[InlineData("지난 15분", "15 minutes")]
[InlineData("최근 7일", "7 days")]
[InlineData("지난 30일", "30 days")]
[InlineData("최근 2주", "14 days")]
public void RelativeRange_CorrectInterval(string input, string expectedInterval)
{
// Arrange & Act
var result = _extractor.Extract(input);
// Assert
Assert.Equal(expectedInterval, result.PostgresInterval);
Assert.Null(result.KstFrom);
Assert.Null(result.KstTo);
}
#endregion
#region : //
[Fact]
public void NamedDay_Today()
{
// Arrange & Act
var result = _extractor.Extract("오늘");
// Assert
Assert.NotNull(result.KstFrom);
Assert.NotNull(result.KstTo);
// KST 2026-04-23 00:00 ~ 2026-04-24 00:00
Assert.Equal(2026, result.KstFrom!.Value.Year);
Assert.Equal(4, result.KstFrom.Value.Month);
Assert.Equal(23, result.KstFrom.Value.Day);
Assert.Equal(2026, result.KstTo!.Value.Year);
Assert.Equal(4, result.KstTo.Value.Month);
Assert.Equal(24, result.KstTo.Value.Day);
}
[Fact]
public void NamedDay_Yesterday()
{
// Arrange & Act
var result = _extractor.Extract("어제");
// Assert
Assert.NotNull(result.KstFrom);
Assert.NotNull(result.KstTo);
// KST 2026-04-22 00:00 ~ 2026-04-23 00:00
Assert.Equal(2026, result.KstFrom!.Value.Year);
Assert.Equal(4, result.KstFrom.Value.Month);
Assert.Equal(22, result.KstFrom.Value.Day);
Assert.Equal(2026, result.KstTo!.Value.Year);
Assert.Equal(4, result.KstTo.Value.Month);
Assert.Equal(23, result.KstTo.Value.Day);
}
[Fact]
public void NamedDay_ThisWeek()
{
// Arrange & Act
var result = _extractor.Extract("이번 주");
// Assert
Assert.NotNull(result.KstFrom);
Assert.Null(result.KstTo);
// KST 2026-04-20 (월요일 - 2026-04-23은 목요일이므로 3일 전)
Assert.Equal(2026, result.KstFrom!.Value.Year);
Assert.Equal(4, result.KstFrom.Value.Month);
// 2026-04-23은 목요일(Thursday = 4), 월요일은 4-3 = 4-20
Assert.Equal(20, result.KstFrom.Value.Day);
}
#endregion
#region
[Fact]
public void Default_InvalidInput_ReturnsOneHourInterval()
{
// Arrange & Act
var result = _extractor.Extract("PV_101.PV 평균값");
// Assert
Assert.Equal("1 hour", result.PostgresInterval);
Assert.Null(result.KstFrom);
Assert.Null(result.KstTo);
}
[Fact]
public void Default_EmptyInput_ReturnsOneHourInterval()
{
// Arrange & Act
var result = _extractor.Extract("");
// Assert
Assert.Equal("1 hour", result.PostgresInterval);
Assert.Null(result.KstFrom);
Assert.Null(result.KstTo);
}
#endregion
#region ToSqlCondition Tests
[Fact]
public void ToSqlCondition_RelativeRange_UsesInterval()
{
// Arrange
var result = _extractor.Extract("최근 3시간");
// Act
var sql = result.ToSqlCondition("\"time\"", _kstClock);
// Assert
Assert.Equal("\"time\" >= NOW() - INTERVAL '3 hours'", sql);
}
[Fact]
public void ToSqlCondition_AbsoluteRange_BothSides()
{
// Arrange
var result = _extractor.Extract("오늘");
// Act
var sql = result.ToSqlCondition("\"time\"", _kstClock);
// Assert
// KST 2026-04-23 00:00 = UTC 2026-04-22 15:00
// KST 2026-04-24 00:00 = UTC 2026-04-23 15:00
Assert.Contains("\"time\" >=", sql);
Assert.Contains("AND \"time\" <", sql);
Assert.Contains("2026-04-22 15:00:00+00", sql);
Assert.Contains("2026-04-23 15:00:00+00", sql);
}
[Fact]
public void ToSqlCondition_FromOnly()
{
// Arrange
var result = _extractor.Extract("오늘 오후 2시 이후");
// Act
var sql = result.ToSqlCondition("\"time\"", _kstClock);
// Assert
// KST 2026-04-23 14:00 = UTC 2026-04-23 05:00
Assert.Contains("\"time\" >=", sql);
Assert.DoesNotContain("AND", sql);
Assert.Contains("2026-04-23 05:00:00+00", sql);
}
[Fact]
public void ToSqlCondition_ToOnly()
{
// Arrange
var result = _extractor.Extract("2026-05-05 이전");
// Act
var sql = result.ToSqlCondition("\"time\"", _kstClock);
// Assert
// KST 2026-05-05 00:00 = UTC 2026-05-04 15:00
Assert.Contains("\"time\" <", sql);
Assert.DoesNotContain("AND", sql);
Assert.Contains("2026-05-04 15:00:00+00", sql);
}
#endregion
#region ParseKstDateTime Tests (via Extract)
// ParseKstDateTime은 internal이므로 Extract를 통해 간접 테스트합니다.
[Theory]
[InlineData("오늘", "2026-04-23")]
[InlineData("어제", "2026-04-22")]
[InlineData("4월 3일", "2026-04-03")]
public void Extract_KoreanDate_ParsesCorrectly(string input, string expectedDate)
{
// Arrange & Act
var result = _extractor.Extract(input);
// Assert
Assert.NotNull(result.KstFrom);
Assert.Equal(expectedDate, result.KstFrom!.Value.ToString("yyyy-MM-dd"));
}
[Fact]
public void Extract_WithTime_PreservesTime()
{
// Arrange & Act
var result = _extractor.Extract("오늘 14:30 이후");
// Assert
Assert.NotNull(result.KstFrom);
Assert.Equal(14, result.KstFrom!.Value.Hour);
Assert.Equal(30, result.KstFrom.Value.Minute);
}
[Fact]
public void Extract_WithAmPm_ConvertsTo24Hour()
{
// Arrange & Act
var result = _extractor.Extract("오후 3시 이후");
// Assert
Assert.NotNull(result.KstFrom);
Assert.Equal(15, result.KstFrom!.Value.Hour);
}
[Fact]
public void Extract_IsoFormat_ParsesCorrectly()
{
// Arrange & Act
var result = _extractor.Extract("2026-03-01 14:00 이후");
// Assert
Assert.NotNull(result.KstFrom);
Assert.Equal(2026, result.KstFrom!.Value.Year);
Assert.Equal(3, result.KstFrom.Value.Month);
Assert.Equal(1, result.KstFrom.Value.Day);
Assert.Equal(14, result.KstFrom.Value.Hour);
}
#endregion
}

View File

@@ -0,0 +1,188 @@
using ExperionCrawler.Core.Application.DTOs;
using ExperionCrawler.Core.Application.Services;
namespace ExperionCrawler.Tests;
/// <summary>
/// SqlValidator 다단계 검증기 테스트
/// 검증 순서: ①구조 → ②위험키워드 → ③금지절 → ④함수화이트리스트 → ⑤테이블참조 → ⑥서브쿼리깊이 → ⑦의심패턴
/// </summary>
public class SqlValidatorTests
{
private readonly SqlValidator _v;
public SqlValidatorTests()
{
_v = new SqlValidator(new SqlValidatorOptions
{
RequiredTables = ["measurements"],
AllowedTables = ["measurements", "node_map_master"],
MaxSubqueryDepth = 4
});
}
// ── ① 정상 케이스 (SELECT 전용, 허용 함수/테이블) ────────────────────────
[Theory]
[InlineData("SELECT AVG(value) FROM measurements WHERE tagname = 'PV_101.PV'")]
[InlineData("SELECT date_trunc('minute', time), AVG(value) FROM measurements GROUP BY 1")]
[InlineData("SELECT tagname, REGR_SLOPE(value, EXTRACT(EPOCH FROM time)) FROM measurements GROUP BY tagname")]
[InlineData("SELECT date_trunc('minute', time), first(value, time) FROM measurements WHERE tagname = 'test' GROUP BY 1 ORDER BY 1")]
[InlineData("SELECT * FROM measurements WHERE tagname = 'test' AND time > now() - interval '1 hour'")]
public void ValidSql_ShouldPass(string sql)
{
var result = _v.Validate(sql);
Assert.True(result.IsValid, $"Expected valid but failed: {result.Message}");
}
// ── ① SELECT 전용 검사 ─────────────────────────────────────────────────
[Theory]
[InlineData("DROP TABLE measurements", ValidationFailReason.NotSelectStatement)]
[InlineData("CREATE TABLE test (id INT)", ValidationFailReason.NotSelectStatement)]
[InlineData("ALTER TABLE measurements ADD COLUMN x INT", ValidationFailReason.NotSelectStatement)]
[InlineData("TRUNCATE measurements", ValidationFailReason.NotSelectStatement)]
[InlineData("MERGE INTO measurements USING...", ValidationFailReason.NotSelectStatement)]
[InlineData("INSERT INTO measurements VALUES (1, 2, 3)", ValidationFailReason.NotSelectStatement)]
[InlineData("UPDATE measurements SET value = 1", ValidationFailReason.NotSelectStatement)]
[InlineData("DELETE FROM measurements", ValidationFailReason.NotSelectStatement)]
public void NonSelectStatement_ShouldFail(string sql, ValidationFailReason expected)
{
var result = _v.Validate(sql);
Assert.False(result.IsValid);
Assert.Equal(expected, result.Reason);
}
// ── ② 위험 키워드 검사 (SELECT로 시작하지만 위험 키워드 포함) ─────────────
[Theory]
[InlineData("SELECT * FROM measurements; DROP TABLE measurements", ValidationFailReason.DangerousKeyword)]
[InlineData("SELECT * FROM measurements; INSERT INTO other VALUES (1)", ValidationFailReason.DangerousKeyword)]
[InlineData("SELECT * FROM measurements; COMMIT", ValidationFailReason.DangerousKeyword)]
public void DangerousKeywordAfterSelect_ShouldFail(string sql, ValidationFailReason expected)
{
var result = _v.Validate(sql);
Assert.False(result.IsValid);
Assert.Equal(expected, result.Reason);
}
// ── ③ 금지 절 검사 (SELECT로 시작하지만 금지 절 포함) ─────────────────────
[Theory]
[InlineData("SELECT pg_sleep(5)", ValidationFailReason.ForbiddenClause)]
[InlineData("SELECT pg_cancel_backend(1)", ValidationFailReason.ForbiddenClause)]
[InlineData("SELECT pg_terminate_backend(1)", ValidationFailReason.ForbiddenClause)]
// CALL/EXECUTE는 SELECT로 시작하지 않으므로 NotSelectStatement로 먼저 실패
public void ForbiddenClause_ShouldFail(string sql, ValidationFailReason expected)
{
var result = _v.Validate(sql);
Assert.False(result.IsValid);
Assert.Equal(expected, result.Reason);
}
// ── ④ 허용되지 않는 함수 ────────────────────────────────────────────────
[Theory]
[InlineData("SELECT SYSTEM('ls') FROM measurements", ValidationFailReason.DisallowedFunction)]
[InlineData("SELECT COPY_FILE('/tmp') FROM measurements", ValidationFailReason.DisallowedFunction)]
[InlineData("SELECT NON_EXISTING_FUNC(1) FROM measurements", ValidationFailReason.DisallowedFunction)]
public void DisallowedFunction_ShouldFail(string sql, ValidationFailReason expected)
{
var result = _v.Validate(sql);
Assert.False(result.IsValid);
Assert.Equal(expected, result.Reason);
}
// ── ⑤ 필수 테이블 누락 ─────────────────────────────────────────────────
[Fact]
public void MissingRequiredTable_ShouldFail()
{
var sql = "SELECT 1 FROM node_map_master";
var result = _v.Validate(sql);
Assert.False(result.IsValid);
Assert.Equal(ValidationFailReason.MissingRequiredTable, result.Reason);
}
// ── ⑦ 의심 패턴 검사 (시스템 뷰 접근) ───────────────────────────────────
[Fact]
public void SystemViewAccess_ShouldFail()
{
// measurements가 없어서 MissingRequiredTable로 먼저 실패
var sql = "SELECT * FROM information_schema.tables";
var result = _v.Validate(sql);
Assert.False(result.IsValid);
// 필수 테이블 measurements가 없어서 MissingRequiredTable로 실패
Assert.Equal(ValidationFailReason.MissingRequiredTable, result.Reason);
}
// ── SQL Injection 패턴 ──────────────────────────────────────────────────
[Theory]
[InlineData("SELECT * FROM measurements WHERE tagname = '' OR '1'='1'", ValidationFailReason.SuspiciousPattern)]
[InlineData("SELECT * FROM measurements UNION SELECT * FROM measurements", ValidationFailReason.SuspiciousPattern)]
[InlineData("SELECT * FROM measurements WHERE tagname = 'x'; DROP TABLE measurements", ValidationFailReason.DangerousKeyword)]
public void InjectionPattern_ShouldFail(string sql, ValidationFailReason expected)
{
var result = _v.Validate(sql);
Assert.False(result.IsValid);
Assert.Equal(expected, result.Reason);
}
// ── ⑥ 서브쿼리 깊이 ────────────────────────────────────────────────────
[Fact]
public void SubqueryDepthExceeded_ShouldFail()
{
// 5단계 중첩: ( -> ( -> ( -> ( -> ( -> (SELECT f FROM measurements)
// MaxSubqueryDepth = 4이므로 5단계에서 실패
// 괄호 개수: 5개여야 함
var sql = "SELECT a FROM (SELECT b FROM (SELECT c FROM (SELECT d FROM (SELECT e FROM (SELECT f FROM measurements) AS x0) AS x1) AS x2) AS x3) AS x4";
var result = _v.Validate(sql);
Assert.False(result.IsValid, $"Expected invalid but got valid. Message: {result.Message}");
Assert.Equal(ValidationFailReason.SubqueryDepthExceeded, result.Reason);
}
// ── 빈 입력 ────────────────────────────────────────────────────────────
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void EmptyInput_ShouldFail(string? sql)
{
var result = _v.Validate(sql!);
Assert.False(result.IsValid);
Assert.Equal(ValidationFailReason.EmptyInput, result.Reason);
}
// ── 허용 함수 화이트리스트 (정규 케이스) ─────────────────────────────────
[Theory]
[InlineData("SELECT COUNT(*) FROM measurements")]
[InlineData("SELECT SUM(value), AVG(value), MIN(value), MAX(value) FROM measurements")]
[InlineData("SELECT STDDEV(value), VARIANCE(value) FROM measurements")]
[InlineData("SELECT ROW_NUMBER() OVER (ORDER BY time) FROM measurements")]
[InlineData("SELECT RANK() OVER (PARTITION BY tagname ORDER BY value DESC) FROM measurements")]
[InlineData("SELECT LAG(value, 1) OVER (ORDER BY time) FROM measurements")]
[InlineData("SELECT NOW(), CURRENT_TIMESTAMP, CURRENT_DATE FROM measurements")]
[InlineData("SELECT DATE_TRUNC('hour', time), EXTRACT(EPOCH FROM time) FROM measurements")]
[InlineData("SELECT TIME_BUCKET('1 hour', time) FROM measurements")]
[InlineData("SELECT UPPER(tagname), LOWER(tagname), TRIM(tagname) FROM measurements")]
[InlineData("SELECT COALESCE(value, 0), NULLIF(value, -1) FROM measurements")]
[InlineData("SELECT ROUND(value), CEIL(value), FLOOR(value) FROM measurements")]
public void AllowedFunctions_ShouldPass(string sql)
{
var result = _v.Validate(sql);
Assert.True(result.IsValid, $"Expected valid but failed: {result.Message}");
}
// ── Deconstruct 테스트 ─────────────────────────────────────────────────
[Fact]
public void ValidationResult_Deconstruct_ShouldWork()
{
var result = _v.Validate("SELECT 1 FROM measurements");
var (ok, error) = result;
Assert.True(ok);
Assert.Null(error);
}
[Fact]
public void ValidationResult_Deconstruct_Fail_ShouldWork()
{
var result = _v.Validate("");
var (ok, error) = result;
Assert.False(ok);
Assert.NotNull(error);
}
}

View File

@@ -0,0 +1,220 @@
<?xml version="1.0" encoding="utf-8"?>
<TestRun id="ff720602-c42d-4714-8a4d-fe329014b49f" name="@spark 2026-04-25 11:44:29" xmlns="http://microsoft.com/schemas/VisualStudio/TeamTest/2010">
<Times creation="2026-04-25T11:44:29.4304710+09:00" queuing="2026-04-25T11:44:29.4304710+09:00" start="2026-04-25T11:44:28.6442347+09:00" finish="2026-04-25T11:44:29.4827041+09:00" />
<TestSettings name="default" id="d2041d3e-230c-4156-99fc-c431da3496f7">
<Deployment runDeploymentRoot="_spark_2026-04-25_11_44_29" />
</TestSettings>
<Results>
<UnitTestResult executionId="fa8e7505-2eb7-479a-9859-d8d21a234118" testId="5c3e9ce7-7bd1-deb3-bcf9-286a5a797173" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_Recent1Month_ExtractsTimeRange" computerName="spark" duration="00:00:00.0003819" startTime="2026-04-25T11:44:29.3898328+09:00" endTime="2026-04-25T11:44:29.3898328+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="fa8e7505-2eb7-479a-9859-d8d21a234118" />
<UnitTestResult executionId="6342897b-79d5-45f1-b779-b21463d71914" testId="4cc079cc-8497-c2b1-a13f-371856fe1a6d" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_AverageKeyword_UsesAvgFunction" computerName="spark" duration="00:00:00.0002663" startTime="2026-04-25T11:44:29.3906626+09:00" endTime="2026-04-25T11:44:29.3906626+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="6342897b-79d5-45f1-b779-b21463d71914" />
<UnitTestResult executionId="4f8df51a-e50e-4781-9f88-143d0368cb20" testId="453ebd31-b897-172a-47b2-ee9237e423e2" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_WithTagName_GeneratesSql" computerName="spark" duration="00:00:00.0003165" startTime="2026-04-25T11:44:29.3743595+09:00" endTime="2026-04-25T11:44:29.3743596+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="4f8df51a-e50e-4781-9f88-143d0368cb20" />
<UnitTestResult executionId="d35ec836-f0ec-43c4-ac25-7b50d7dbdba0" testId="80269386-9ca6-1dd2-59e4-7b56fd7a66ed" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_SpecialCharacters_EscapesTagName" computerName="spark" duration="00:00:00.0002507" startTime="2026-04-25T11:44:29.3907344+09:00" endTime="2026-04-25T11:44:29.3907344+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="d35ec836-f0ec-43c4-ac25-7b50d7dbdba0" />
<UnitTestResult executionId="6f313979-2da5-4dfe-9c34-27e3ce65743b" testId="8df30334-5f76-38b5-25e9-f6812aa6cdff" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_ThreeTagsCommaSeparated_IncludesAllTags" computerName="spark" duration="00:00:00.0003450" startTime="2026-04-25T11:44:29.3898916+09:00" endTime="2026-04-25T11:44:29.3898916+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="6f313979-2da5-4dfe-9c34-27e3ce65743b" />
<UnitTestResult executionId="5c45a8b9-2eb2-427c-a731-414f909b7048" testId="6b5ec284-00db-56ef-7384-a5713f6ab45e" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_Recent24Hours_ExtractsTimeRange" computerName="spark" duration="00:00:00.0002515" startTime="2026-04-25T11:44:29.3757051+09:00" endTime="2026-04-25T11:44:29.3757051+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="5c45a8b9-2eb2-427c-a731-414f909b7048" />
<UnitTestResult executionId="4da36672-445e-4369-9d44-360a7b6b8b99" testId="e0331b39-ee4e-8fe1-50ed-78732fa3841a" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_DotTagName_ExtractsCorrectly" computerName="spark" duration="00:00:00.0005555" startTime="2026-04-25T11:44:29.3909704+09:00" endTime="2026-04-25T11:44:29.3909705+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="4da36672-445e-4369-9d44-360a7b6b8b99" />
<UnitTestResult executionId="90650188-4846-4d68-bc39-f7a4c8fb9814" testId="9dcebd05-5954-2a01-645b-410629bd254d" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_WithMinKeyword_GeneratesMinAggregation" computerName="spark" duration="00:00:00.0002630" startTime="2026-04-25T11:44:29.3905817+09:00" endTime="2026-04-25T11:44:29.3905817+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="90650188-4846-4d68-bc39-f7a4c8fb9814" />
<UnitTestResult executionId="d8210a41-f48e-416c-9e6f-b4b5cd096013" testId="4c2d002b-4d1c-3c3b-855d-00377118994d" testName="ExperionCrawler.Tests.TextToSqlServiceTests.SuggestQueriesAsync_WithFilter_ReturnsFilteredSuggestions" computerName="spark" duration="00:00:00.0006235" startTime="2026-04-25T11:44:29.3898144+09:00" endTime="2026-04-25T11:44:29.3898144+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="d8210a41-f48e-416c-9e6f-b4b5cd096013" />
<UnitTestResult executionId="7ee38996-5e67-4144-9280-d066fa103dcd" testId="15e84926-8d29-6d29-d5f1-18dd26763892" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_MultipleTagsWithKoreanDescriptions_ExtractsOnlyTags" computerName="spark" duration="00:00:00.0003433" startTime="2026-04-25T11:44:29.3906261+09:00" endTime="2026-04-25T11:44:29.3906261+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="7ee38996-5e67-4144-9280-d066fa103dcd" />
<UnitTestResult executionId="4101239d-abc2-45e3-9aeb-32e835964cf7" testId="cf1eb7a6-cd60-600b-bfaa-e1f835f77b59" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_WithEmptyInput_ThrowsArgumentException" computerName="spark" duration="00:00:00.0009436" startTime="2026-04-25T11:44:29.3909881+09:00" endTime="2026-04-25T11:44:29.3909882+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="4101239d-abc2-45e3-9aeb-32e835964cf7" />
<UnitTestResult executionId="4248d846-c27e-486b-979b-89e6b55ae83b" testId="14f39cbb-10de-141a-f514-4230ebe336ed" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_WithFirstKeyword_GeneratesFirstAggregation" computerName="spark" duration="00:00:00.0002743" startTime="2026-04-25T11:44:29.3906445+09:00" endTime="2026-04-25T11:44:29.3906445+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="4248d846-c27e-486b-979b-89e6b55ae83b" />
<UnitTestResult executionId="0c3ab594-4d65-4c0b-bcca-a10ee0f58a0f" testId="df534781-ce1f-66fc-3790-0b8e5eae75df" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_SimpleTagName_ExtractsCorrectly" computerName="spark" duration="00:00:00.0002892" startTime="2026-04-25T11:44:29.3897951+09:00" endTime="2026-04-25T11:44:29.3897951+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="0c3ab594-4d65-4c0b-bcca-a10ee0f58a0f" />
<UnitTestResult executionId="9627a0d8-e645-4db1-9ed7-54afb8d37be0" testId="cea17860-3597-3e21-1465-62e77aee9cc6" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_WithMaxKeyword_GeneratesMaxAggregation" computerName="spark" duration="00:00:00.0002379" startTime="2026-04-25T11:44:29.3909333+09:00" endTime="2026-04-25T11:44:29.3909333+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="9627a0d8-e645-4db1-9ed7-54afb8d37be0" />
<UnitTestResult executionId="5ddb25ec-fe9d-46a7-ac6d-91d8e60c465b" testId="675f7860-eb45-3219-27e7-57c7e2e65ee2" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_NoSpaceTagName_UsesWholeInput" computerName="spark" duration="00:00:00.0002750" startTime="2026-04-25T11:44:29.3898738+09:00" endTime="2026-04-25T11:44:29.3898738+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="5ddb25ec-fe9d-46a7-ac6d-91d8e60c465b" />
<UnitTestResult executionId="6ac7aa3c-408f-47d3-9d4d-ef44ccf8abab" testId="c0c4b062-d710-d2dc-7fb6-02e20736a39f" testName="ExperionCrawler.Tests.TextToSqlServiceTests.SuggestQueriesAsync_WithEmptyInput_ReturnsAllSuggestions" computerName="spark" duration="00:00:00.0009178" startTime="2026-04-25T11:44:29.3906982+09:00" endTime="2026-04-25T11:44:29.3906983+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="6ac7aa3c-408f-47d3-9d4d-ef44ccf8abab" />
<UnitTestResult executionId="0bd3a654-47ed-4a79-a283-1e1e11baf6e4" testId="03b464c4-872d-93f1-75bf-cc169416edee" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_Recent7Days_ExtractsTimeRange" computerName="spark" duration="00:00:00.0003093" startTime="2026-04-25T11:44:29.3907158+09:00" endTime="2026-04-25T11:44:29.3907158+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="0bd3a654-47ed-4a79-a283-1e1e11baf6e4" />
<UnitTestResult executionId="2af16c71-455e-47d1-9414-06fdbbb0c9f4" testId="a588adc0-4f17-3b17-110a-4aa6f024dd3f" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_FromToPattern_ExtractsTimeRange" computerName="spark" duration="00:00:00.0198781" startTime="2026-04-25T11:44:29.3713915+09:00" endTime="2026-04-25T11:44:29.3714001+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="2af16c71-455e-47d1-9414-06fdbbb0c9f4" />
<UnitTestResult executionId="a892d81e-6c53-49a5-aa88-60a4c3313472" testId="dfce6d90-9761-4ca3-ca30-781ba02dbfa4" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_MultipleTagsCommaSeparated_IncludesAllTags" computerName="spark" duration="00:00:00.0004279" startTime="2026-04-25T11:44:29.3754339+09:00" endTime="2026-04-25T11:44:29.3754339+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="a892d81e-6c53-49a5-aa88-60a4c3313472" />
<UnitTestResult executionId="e60f083e-00ce-452d-837d-cfeafda675ee" testId="77b9b877-6d45-d631-10c9-19432d683af4" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_KoreanDate_ExtractsTimeRange" computerName="spark" duration="00:00:00.0002391" startTime="2026-04-25T11:44:29.3899269+09:00" endTime="2026-04-25T11:44:29.3899269+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="e60f083e-00ce-452d-837d-cfeafda675ee" />
<UnitTestResult executionId="de69eaaf-6366-4889-94c1-d0281deb012c" testId="750cf791-c506-cfa6-4deb-79897ece87ff" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_DefaultAggregationIsLast" computerName="spark" duration="00:00:00.0002543" startTime="2026-04-25T11:44:29.3899092+09:00" endTime="2026-04-25T11:44:29.3899092+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="de69eaaf-6366-4889-94c1-d0281deb012c" />
<UnitTestResult executionId="801f328a-0b75-4735-9e21-37e6d04314ba" testId="73098855-7d72-81ed-193c-c79d76370f85" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_WithLastKeyword_GeneratesLastAggregation" computerName="spark" duration="00:00:00.0024067" startTime="2026-04-25T11:44:29.3736241+09:00" endTime="2026-04-25T11:44:29.3736241+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="801f328a-0b75-4735-9e21-37e6d04314ba" />
<UnitTestResult executionId="24ec31bb-738d-4cba-968e-ddc8ac40d249" testId="7ff2886d-a78d-22be-1cf0-6826794967ab" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_AfterPattern_ExtractsTimeRange" computerName="spark" duration="00:00:00.0002320" startTime="2026-04-25T11:44:29.3909150+09:00" endTime="2026-04-25T11:44:29.3909150+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="24ec31bb-738d-4cba-968e-ddc8ac40d249" />
<UnitTestResult executionId="835eeb43-fcf4-44c6-a160-4f9fd35cced8" testId="64e7212f-9c18-5a8c-19cd-01e0da0ea86f" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_MaxKeyword_UsesMaxFunction" computerName="spark" duration="00:00:00.0003019" startTime="2026-04-25T11:44:29.3746816+09:00" endTime="2026-04-25T11:44:29.3746816+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="835eeb43-fcf4-44c6-a160-4f9fd35cced8" />
<UnitTestResult executionId="83fce793-2e88-4b7d-91f4-af2a95592bbb" testId="16a4f7ee-8c4f-8b19-bf63-11f177c56cc6" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_Recent1Hour_ExtractsTimeRange" computerName="spark" duration="00:00:00.0002834" startTime="2026-04-25T11:44:29.3749850+09:00" endTime="2026-04-25T11:44:29.3749850+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="83fce793-2e88-4b7d-91f4-af2a95592bbb" />
<UnitTestResult executionId="0f19eebe-6bd1-4ae8-817c-8a4c01858476" testId="6bbded63-64a7-bc4b-baa2-42bdfaed94e5" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_NoTimeSpecified_UsesDefault" computerName="spark" duration="00:00:00.0002681" startTime="2026-04-25T11:44:29.3906054+09:00" endTime="2026-04-25T11:44:29.3906055+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="0f19eebe-6bd1-4ae8-817c-8a4c01858476" />
<UnitTestResult executionId="be8411f0-aa2c-4cab-a30c-584e1d59a747" testId="0a6fd87b-3482-8683-9f78-54911a84c958" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_NoAggregateKeyword_UsesLastFunction" computerName="spark" duration="00:00:00.0002227" startTime="2026-04-25T11:44:29.3909510+09:00" endTime="2026-04-25T11:44:29.3909511+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="be8411f0-aa2c-4cab-a30c-584e1d59a747" />
<UnitTestResult executionId="9e9a83b0-c91a-4a52-978c-75690f51a6c8" testId="dcefabeb-f8ef-a09c-8b99-566f8d7f5f75" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_OpcUaNodeId_ExtractsCorrectly" computerName="spark" duration="00:00:00.0004160" startTime="2026-04-25T11:44:29.3896896+09:00" endTime="2026-04-25T11:44:29.3896896+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="9e9a83b0-c91a-4a52-978c-75690f51a6c8" />
<UnitTestResult executionId="6b62004e-a542-49df-9eaa-8b1d8ebbd96b" testId="6c884139-c4ff-db15-9260-81c43984d859" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_MultipleTagsWithAbsTimeRange_IncludesAllTags" computerName="spark" duration="00:00:00.0002822" startTime="2026-04-25T11:44:29.3760063+09:00" endTime="2026-04-25T11:44:29.3760063+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="6b62004e-a542-49df-9eaa-8b1d8ebbd96b" />
<UnitTestResult executionId="cb48f6dc-fa21-4f1d-a8f9-febab7d25fc9" testId="37e9c535-a3c6-2083-f2c0-987d98ae10fa" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_AvgKeyword_UsesAvgFunction" computerName="spark" duration="00:00:00.0003775" startTime="2026-04-25T11:44:29.3906806+09:00" endTime="2026-04-25T11:44:29.3906806+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="cb48f6dc-fa21-4f1d-a8f9-febab7d25fc9" />
<UnitTestResult executionId="cb24fe3d-f7fd-4740-94b6-490e0d7bab76" testId="7df9fd70-57c9-fe80-db0e-4c9bb3029efe" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_MinKeyword_UsesMinFunction" computerName="spark" duration="00:00:00.0003823" startTime="2026-04-25T11:44:29.3740212+09:00" endTime="2026-04-25T11:44:29.3740212+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="cb24fe3d-f7fd-4740-94b6-490e0d7bab76" />
<UnitTestResult executionId="7c5468e1-7168-4a8a-a0ae-ad6784346495" testId="22b5066e-140f-d997-79ae-a2cc60ca7ac9" testName="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_TodayAmPmRange_ExtractsTimeRange" computerName="spark" duration="00:00:00.0002857" startTime="2026-04-25T11:44:29.3898553+09:00" endTime="2026-04-25T11:44:29.3898554+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="7c5468e1-7168-4a8a-a0ae-ad6784346495" />
</Results>
<TestDefinitions>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_ThreeTagsCommaSeparated_IncludesAllTags" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="8df30334-5f76-38b5-25e9-f6812aa6cdff">
<Execution id="6f313979-2da5-4dfe-9c34-27e3ce65743b" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_ThreeTagsCommaSeparated_IncludesAllTags" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_DotTagName_ExtractsCorrectly" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="e0331b39-ee4e-8fe1-50ed-78732fa3841a">
<Execution id="4da36672-445e-4369-9d44-360a7b6b8b99" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_DotTagName_ExtractsCorrectly" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_Recent24Hours_ExtractsTimeRange" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="6b5ec284-00db-56ef-7384-a5713f6ab45e">
<Execution id="5c45a8b9-2eb2-427c-a731-414f909b7048" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_Recent24Hours_ExtractsTimeRange" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_KoreanDate_ExtractsTimeRange" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="77b9b877-6d45-d631-10c9-19432d683af4">
<Execution id="e60f083e-00ce-452d-837d-cfeafda675ee" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_KoreanDate_ExtractsTimeRange" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_AverageKeyword_UsesAvgFunction" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="4cc079cc-8497-c2b1-a13f-371856fe1a6d">
<Execution id="6342897b-79d5-45f1-b779-b21463d71914" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_AverageKeyword_UsesAvgFunction" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_MaxKeyword_UsesMaxFunction" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="64e7212f-9c18-5a8c-19cd-01e0da0ea86f">
<Execution id="835eeb43-fcf4-44c6-a160-4f9fd35cced8" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_MaxKeyword_UsesMaxFunction" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_MultipleTagsWithAbsTimeRange_IncludesAllTags" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="6c884139-c4ff-db15-9260-81c43984d859">
<Execution id="6b62004e-a542-49df-9eaa-8b1d8ebbd96b" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_MultipleTagsWithAbsTimeRange_IncludesAllTags" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_MultipleTagsCommaSeparated_IncludesAllTags" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="dfce6d90-9761-4ca3-ca30-781ba02dbfa4">
<Execution id="a892d81e-6c53-49a5-aa88-60a4c3313472" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_MultipleTagsCommaSeparated_IncludesAllTags" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_OpcUaNodeId_ExtractsCorrectly" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="dcefabeb-f8ef-a09c-8b99-566f8d7f5f75">
<Execution id="9e9a83b0-c91a-4a52-978c-75690f51a6c8" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_OpcUaNodeId_ExtractsCorrectly" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_WithEmptyInput_ThrowsArgumentException" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="cf1eb7a6-cd60-600b-bfaa-e1f835f77b59">
<Execution id="4101239d-abc2-45e3-9aeb-32e835964cf7" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_WithEmptyInput_ThrowsArgumentException" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_AfterPattern_ExtractsTimeRange" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="7ff2886d-a78d-22be-1cf0-6826794967ab">
<Execution id="24ec31bb-738d-4cba-968e-ddc8ac40d249" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_AfterPattern_ExtractsTimeRange" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.SuggestQueriesAsync_WithEmptyInput_ReturnsAllSuggestions" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="c0c4b062-d710-d2dc-7fb6-02e20736a39f">
<Execution id="6ac7aa3c-408f-47d3-9d4d-ef44ccf8abab" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="SuggestQueriesAsync_WithEmptyInput_ReturnsAllSuggestions" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_AvgKeyword_UsesAvgFunction" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="37e9c535-a3c6-2083-f2c0-987d98ae10fa">
<Execution id="cb48f6dc-fa21-4f1d-a8f9-febab7d25fc9" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_AvgKeyword_UsesAvgFunction" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_MultipleTagsWithKoreanDescriptions_ExtractsOnlyTags" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="15e84926-8d29-6d29-d5f1-18dd26763892">
<Execution id="7ee38996-5e67-4144-9280-d066fa103dcd" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_MultipleTagsWithKoreanDescriptions_ExtractsOnlyTags" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_WithFirstKeyword_GeneratesFirstAggregation" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="14f39cbb-10de-141a-f514-4230ebe336ed">
<Execution id="4248d846-c27e-486b-979b-89e6b55ae83b" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_WithFirstKeyword_GeneratesFirstAggregation" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_FromToPattern_ExtractsTimeRange" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="a588adc0-4f17-3b17-110a-4aa6f024dd3f">
<Execution id="2af16c71-455e-47d1-9414-06fdbbb0c9f4" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_FromToPattern_ExtractsTimeRange" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_WithTagName_GeneratesSql" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="453ebd31-b897-172a-47b2-ee9237e423e2">
<Execution id="4f8df51a-e50e-4781-9f88-143d0368cb20" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_WithTagName_GeneratesSql" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_SpecialCharacters_EscapesTagName" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="80269386-9ca6-1dd2-59e4-7b56fd7a66ed">
<Execution id="d35ec836-f0ec-43c4-ac25-7b50d7dbdba0" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_SpecialCharacters_EscapesTagName" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_NoSpaceTagName_UsesWholeInput" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="675f7860-eb45-3219-27e7-57c7e2e65ee2">
<Execution id="5ddb25ec-fe9d-46a7-ac6d-91d8e60c465b" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_NoSpaceTagName_UsesWholeInput" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.SuggestQueriesAsync_WithFilter_ReturnsFilteredSuggestions" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="4c2d002b-4d1c-3c3b-855d-00377118994d">
<Execution id="d8210a41-f48e-416c-9e6f-b4b5cd096013" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="SuggestQueriesAsync_WithFilter_ReturnsFilteredSuggestions" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_Recent1Month_ExtractsTimeRange" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="5c3e9ce7-7bd1-deb3-bcf9-286a5a797173">
<Execution id="fa8e7505-2eb7-479a-9859-d8d21a234118" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_Recent1Month_ExtractsTimeRange" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_NoTimeSpecified_UsesDefault" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="6bbded63-64a7-bc4b-baa2-42bdfaed94e5">
<Execution id="0f19eebe-6bd1-4ae8-817c-8a4c01858476" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_NoTimeSpecified_UsesDefault" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_WithMinKeyword_GeneratesMinAggregation" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="9dcebd05-5954-2a01-645b-410629bd254d">
<Execution id="90650188-4846-4d68-bc39-f7a4c8fb9814" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_WithMinKeyword_GeneratesMinAggregation" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_Recent7Days_ExtractsTimeRange" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="03b464c4-872d-93f1-75bf-cc169416edee">
<Execution id="0bd3a654-47ed-4a79-a283-1e1e11baf6e4" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_Recent7Days_ExtractsTimeRange" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_WithLastKeyword_GeneratesLastAggregation" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="73098855-7d72-81ed-193c-c79d76370f85">
<Execution id="801f328a-0b75-4735-9e21-37e6d04314ba" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_WithLastKeyword_GeneratesLastAggregation" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_DefaultAggregationIsLast" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="750cf791-c506-cfa6-4deb-79897ece87ff">
<Execution id="de69eaaf-6366-4889-94c1-d0281deb012c" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_DefaultAggregationIsLast" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_SimpleTagName_ExtractsCorrectly" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="df534781-ce1f-66fc-3790-0b8e5eae75df">
<Execution id="0c3ab594-4d65-4c0b-bcca-a10ee0f58a0f" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_SimpleTagName_ExtractsCorrectly" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_TodayAmPmRange_ExtractsTimeRange" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="22b5066e-140f-d997-79ae-a2cc60ca7ac9">
<Execution id="7c5468e1-7168-4a8a-a0ae-ad6784346495" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_TodayAmPmRange_ExtractsTimeRange" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_WithMaxKeyword_GeneratesMaxAggregation" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="cea17860-3597-3e21-1465-62e77aee9cc6">
<Execution id="9627a0d8-e645-4db1-9ed7-54afb8d37be0" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_WithMaxKeyword_GeneratesMaxAggregation" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_NoAggregateKeyword_UsesLastFunction" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="0a6fd87b-3482-8683-9f78-54911a84c958">
<Execution id="be8411f0-aa2c-4cab-a30c-584e1d59a747" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_NoAggregateKeyword_UsesLastFunction" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_MinKeyword_UsesMinFunction" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="7df9fd70-57c9-fe80-db0e-4c9bb3029efe">
<Execution id="cb24fe3d-f7fd-4740-94b6-490e0d7bab76" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_MinKeyword_UsesMinFunction" />
</UnitTest>
<UnitTest name="ExperionCrawler.Tests.TextToSqlServiceTests.ParseNaturalLanguageAsync_Recent1Hour_ExtractsTimeRange" storage="/home/windpacer/projects/experioncrawler/experioncrawler.tests/bin/debug/net8.0/experioncrawler.tests.dll" id="16a4f7ee-8c4f-8b19-bf63-11f177c56cc6">
<Execution id="83fce793-2e88-4b7d-91f4-af2a95592bbb" />
<TestMethod codeBase="/home/windpacer/projects/ExperionCrawler/ExperionCrawler.Tests/bin/Debug/net8.0/ExperionCrawler.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner2/netcoreapp" className="ExperionCrawler.Tests.TextToSqlServiceTests" name="ParseNaturalLanguageAsync_Recent1Hour_ExtractsTimeRange" />
</UnitTest>
</TestDefinitions>
<TestEntries>
<TestEntry testId="5c3e9ce7-7bd1-deb3-bcf9-286a5a797173" executionId="fa8e7505-2eb7-479a-9859-d8d21a234118" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="4cc079cc-8497-c2b1-a13f-371856fe1a6d" executionId="6342897b-79d5-45f1-b779-b21463d71914" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="453ebd31-b897-172a-47b2-ee9237e423e2" executionId="4f8df51a-e50e-4781-9f88-143d0368cb20" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="80269386-9ca6-1dd2-59e4-7b56fd7a66ed" executionId="d35ec836-f0ec-43c4-ac25-7b50d7dbdba0" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="8df30334-5f76-38b5-25e9-f6812aa6cdff" executionId="6f313979-2da5-4dfe-9c34-27e3ce65743b" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="6b5ec284-00db-56ef-7384-a5713f6ab45e" executionId="5c45a8b9-2eb2-427c-a731-414f909b7048" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="e0331b39-ee4e-8fe1-50ed-78732fa3841a" executionId="4da36672-445e-4369-9d44-360a7b6b8b99" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="9dcebd05-5954-2a01-645b-410629bd254d" executionId="90650188-4846-4d68-bc39-f7a4c8fb9814" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="4c2d002b-4d1c-3c3b-855d-00377118994d" executionId="d8210a41-f48e-416c-9e6f-b4b5cd096013" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="15e84926-8d29-6d29-d5f1-18dd26763892" executionId="7ee38996-5e67-4144-9280-d066fa103dcd" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="cf1eb7a6-cd60-600b-bfaa-e1f835f77b59" executionId="4101239d-abc2-45e3-9aeb-32e835964cf7" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="14f39cbb-10de-141a-f514-4230ebe336ed" executionId="4248d846-c27e-486b-979b-89e6b55ae83b" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="df534781-ce1f-66fc-3790-0b8e5eae75df" executionId="0c3ab594-4d65-4c0b-bcca-a10ee0f58a0f" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="cea17860-3597-3e21-1465-62e77aee9cc6" executionId="9627a0d8-e645-4db1-9ed7-54afb8d37be0" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="675f7860-eb45-3219-27e7-57c7e2e65ee2" executionId="5ddb25ec-fe9d-46a7-ac6d-91d8e60c465b" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="c0c4b062-d710-d2dc-7fb6-02e20736a39f" executionId="6ac7aa3c-408f-47d3-9d4d-ef44ccf8abab" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="03b464c4-872d-93f1-75bf-cc169416edee" executionId="0bd3a654-47ed-4a79-a283-1e1e11baf6e4" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="a588adc0-4f17-3b17-110a-4aa6f024dd3f" executionId="2af16c71-455e-47d1-9414-06fdbbb0c9f4" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="dfce6d90-9761-4ca3-ca30-781ba02dbfa4" executionId="a892d81e-6c53-49a5-aa88-60a4c3313472" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="77b9b877-6d45-d631-10c9-19432d683af4" executionId="e60f083e-00ce-452d-837d-cfeafda675ee" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="750cf791-c506-cfa6-4deb-79897ece87ff" executionId="de69eaaf-6366-4889-94c1-d0281deb012c" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="73098855-7d72-81ed-193c-c79d76370f85" executionId="801f328a-0b75-4735-9e21-37e6d04314ba" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="7ff2886d-a78d-22be-1cf0-6826794967ab" executionId="24ec31bb-738d-4cba-968e-ddc8ac40d249" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="64e7212f-9c18-5a8c-19cd-01e0da0ea86f" executionId="835eeb43-fcf4-44c6-a160-4f9fd35cced8" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="16a4f7ee-8c4f-8b19-bf63-11f177c56cc6" executionId="83fce793-2e88-4b7d-91f4-af2a95592bbb" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="6bbded63-64a7-bc4b-baa2-42bdfaed94e5" executionId="0f19eebe-6bd1-4ae8-817c-8a4c01858476" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="0a6fd87b-3482-8683-9f78-54911a84c958" executionId="be8411f0-aa2c-4cab-a30c-584e1d59a747" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="dcefabeb-f8ef-a09c-8b99-566f8d7f5f75" executionId="9e9a83b0-c91a-4a52-978c-75690f51a6c8" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="6c884139-c4ff-db15-9260-81c43984d859" executionId="6b62004e-a542-49df-9eaa-8b1d8ebbd96b" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="37e9c535-a3c6-2083-f2c0-987d98ae10fa" executionId="cb48f6dc-fa21-4f1d-a8f9-febab7d25fc9" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="7df9fd70-57c9-fe80-db0e-4c9bb3029efe" executionId="cb24fe3d-f7fd-4740-94b6-490e0d7bab76" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestEntry testId="22b5066e-140f-d997-79ae-a2cc60ca7ac9" executionId="7c5468e1-7168-4a8a-a0ae-ad6784346495" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
</TestEntries>
<TestLists>
<TestList name="Results Not in a List" id="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
<TestList name="All Loaded Results" id="19431567-8539-422a-85d7-44ee4e166bda" />
</TestLists>
<ResultSummary outcome="Completed">
<Counters total="32" executed="32" passed="32" failed="0" error="0" timeout="0" aborted="0" inconclusive="0" passedButRunAborted="0" notRunnable="0" notExecuted="0" disconnected="0" warning="0" completed="0" inProgress="0" pending="0" />
<Output>
<StdOut>[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v2.4.5+1caef2f33e (64-bit .NET 8.0.26)
[xUnit.net 00:00:00.35] Discovering: ExperionCrawler.Tests
[xUnit.net 00:00:00.38] Discovered: ExperionCrawler.Tests
[xUnit.net 00:00:00.38] Starting: ExperionCrawler.Tests
[xUnit.net 00:00:00.45] Finished: ExperionCrawler.Tests
</StdOut>
</Output>
</ResultSummary>
</TestRun>

View File

@@ -0,0 +1,450 @@
using ExperionCrawler.Core.Application.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Xunit;
namespace ExperionCrawler.Tests;
/// <summary>
/// TextToSqlService 단위 테스트
/// private 메서드들을 public ParseNaturalLanguageAsync를 통해 간접 테스트합니다.
/// </summary>
public class TextToSqlServiceTests
{
private readonly TextToSqlService _service;
private readonly ILogger<TextToSqlService> _logger;
public TextToSqlServiceTests()
{
// Mock logger creation
var loggerFactory = new LoggerFactory();
_logger = loggerFactory.CreateLogger<TextToSqlService>();
// Mock configuration with a dummy connection string
var config = new Dictionary<string, string?>
{
{ "ConnectionStrings:DefaultConnection", "Host=localhost;Port=5432;Database=iiot_platform;Username=postgres;Password=postgres" }
};
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(config)
.Build();
_service = new TextToSqlService(_logger, configuration);
}
#region ParseNaturalLanguageAsync Tests
[Fact]
public async Task ParseNaturalLanguageAsync_WithEmptyInput_ThrowsArgumentException()
{
// Arrange & Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => _service.ParseNaturalLanguageAsync(""));
await Assert.ThrowsAsync<ArgumentException>(() => _service.ParseNaturalLanguageAsync(null!));
await Assert.ThrowsAsync<ArgumentException>(() => _service.ParseNaturalLanguageAsync(" "));
}
[Fact]
public async Task ParseNaturalLanguageAsync_WithTagName_GeneratesSql()
{
// Arrange
var input = "FICQ-6101.PV 최근 1시간 평균";
// Act
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert
Assert.NotNull(sql);
Assert.Contains("SELECT", sql);
Assert.Contains("FROM history_table", sql);
Assert.Contains("tagname IN ('FICQ-6101.PV')", sql);
Assert.Contains("avg", sql, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ParseNaturalLanguageAsync_WithMaxKeyword_GeneratesMaxAggregation()
{
// Arrange
var input = "FICQ-6101.PV 최근 1시간 최대값";
// Act
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert
Assert.NotNull(sql);
Assert.Contains("max", sql.ToLower());
}
[Fact]
public async Task ParseNaturalLanguageAsync_WithMinKeyword_GeneratesMinAggregation()
{
// Arrange
var input = "FICQ-6101.PV 최근 1시간 최솟값";
// Act
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert
Assert.NotNull(sql);
Assert.Contains("min", sql.ToLower());
}
[Fact]
public async Task ParseNaturalLanguageAsync_WithFirstKeyword_GeneratesFirstAggregation()
{
// Arrange
var input = "FICQ-6101.PV 초기 값";
// Act
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert
Assert.NotNull(sql);
Assert.Contains("first", sql.ToLower());
}
[Fact]
public async Task ParseNaturalLanguageAsync_WithLastKeyword_GeneratesLastAggregation()
{
// Arrange
var input = "FICQ-6101.PV 마지막 값";
// Act
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert
Assert.NotNull(sql);
Assert.Contains("last", sql.ToLower());
}
[Fact]
public async Task ParseNaturalLanguageAsync_DefaultAggregationIsLast()
{
// Arrange
var input = "FICQ-6101.PV 최근 1시간";
// Act
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert
Assert.NotNull(sql);
Assert.Contains("last", sql.ToLower());
}
#endregion
#region ExtractTagName Tests (via ParseNaturalLanguageAsync)
[Fact]
public async Task ParseNaturalLanguageAsync_SimpleTagName_ExtractsCorrectly()
{
// Arrange & Act
var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV 최근 1시간 평균");
// Assert
Assert.Contains("tagname IN ('FICQ-6101.PV')", sql);
}
[Fact]
public async Task ParseNaturalLanguageAsync_DotTagName_ExtractsCorrectly()
{
// Arrange & Act
var sql = await _service.ParseNaturalLanguageAsync("p-6102.hzset.fieldvalue 2026년 4월 13일 부터 현재까지의 값 표시");
// Assert
Assert.Contains("tagname IN ('p-6102.hzset.fieldvalue')", sql);
}
[Fact]
public async Task ParseNaturalLanguageAsync_OpcUaNodeId_ExtractsCorrectly()
{
// Arrange & Act
var sql = await _service.ParseNaturalLanguageAsync("ns=2;s=Reactor.Temperature 최근 1시간 평균");
// Assert
Assert.Contains("tagname IN ('ns=2;s=Reactor.Temperature')", sql);
}
[Fact]
public async Task ParseNaturalLanguageAsync_NoSpaceTagName_UsesWholeInput()
{
// Arrange & Act
var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV");
// Assert
Assert.Contains("tagname IN ('FICQ-6101.PV')", sql);
}
#endregion
#region ExtractTimeRange Tests
[Fact]
public async Task ParseNaturalLanguageAsync_Recent1Hour_ExtractsTimeRange()
{
// Arrange & Act
var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV 최근 1시간 평균");
// Assert
Assert.Contains("INTERVAL '1 hour'", sql);
}
[Fact]
public async Task ParseNaturalLanguageAsync_Recent24Hours_ExtractsTimeRange()
{
// Arrange & Act
var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV 최근 24시간 최대값");
// Assert
Assert.Contains("INTERVAL '24 hours'", sql);
}
[Fact]
public async Task ParseNaturalLanguageAsync_Recent7Days_ExtractsTimeRange()
{
// Arrange & Act
var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV 최근 7일 최소값");
// Assert
Assert.Contains("INTERVAL '7 days'", sql);
}
[Fact]
public async Task ParseNaturalLanguageAsync_Recent1Month_ExtractsTimeRange()
{
// Arrange & Act
var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV 최근 1개월 평균");
// Assert
Assert.Contains("INTERVAL '30 days'", sql);
}
[Fact]
public async Task ParseNaturalLanguageAsync_KoreanDate_ExtractsTimeRange()
{
// Arrange & Act
var sql = await _service.ParseNaturalLanguageAsync("p-6102.hzset.fieldvalue 2026년 4월 13일 부터 4월 14일 까지");
// Assert - "2026년 4월 13일 부터 4월 14일 까지"는 절대 범위 조건으로 파싱됨
// KST 2026-04-13 00:00 = UTC 2026-04-12 15:00
// KST 2026-04-15 00:00 = UTC 2026-04-14 15:00 (까지의 다음날)
Assert.Contains("time", sql);
Assert.Contains(">=", sql);
Assert.Contains("AND", sql);
Assert.Contains("<", sql);
}
[Fact]
public async Task ParseNaturalLanguageAsync_FromToPattern_ExtractsTimeRange()
{
// Arrange & Act - 새로운 "부터 ~ 까지" 패턴 테스트
var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV 4월 1일 부터 4월 7일 까지 평균");
// Assert - 절대 범위 조건이 포함되어야 함
Assert.Contains("time", sql);
Assert.Contains(">=", sql);
}
[Fact]
public async Task ParseNaturalLanguageAsync_NoTimeSpecified_UsesDefault()
{
// Arrange & Act
var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV");
// Assert - default time bucket should be "5 min"
Assert.Contains("5 min", sql);
}
[Fact]
public async Task ParseNaturalLanguageAsync_TodayAmPmRange_ExtractsTimeRange()
{
// Arrange & Act - 당일 시간 범위 패턴 테스트
var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV 오전 9시부터 오후 6시까지 평균");
// Assert - 절대 범위 조건이 포함되어야 함
Assert.Contains("time", sql);
Assert.Contains(">=", sql);
Assert.Contains("AND", sql);
}
[Fact]
public async Task ParseNaturalLanguageAsync_AfterPattern_ExtractsTimeRange()
{
// Arrange & Act - 단방향 이후 패턴 테스트
var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV 오늘 오후 2시 이후 값");
// Assert - 시작 시간 조건이 포함되어야 함
Assert.Contains("time", sql);
Assert.Contains(">=", sql);
}
#endregion
#region ExtractAggregate Tests
[Fact]
public async Task ParseNaturalLanguageAsync_AvgKeyword_UsesAvgFunction()
{
// Arrange & Act
var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV 최근 1시간 평균");
// Assert
Assert.Contains("avg", sql.ToLower());
}
[Fact]
public async Task ParseNaturalLanguageAsync_MaxKeyword_UsesMaxFunction()
{
// Arrange & Act
var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV 최근 1시간 최대");
// Assert
Assert.Contains("max", sql.ToLower());
}
[Fact]
public async Task ParseNaturalLanguageAsync_MinKeyword_UsesMinFunction()
{
// Arrange & Act
var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV 최근 1시간 최소");
// Assert
Assert.Contains("min", sql.ToLower());
}
[Fact]
public async Task ParseNaturalLanguageAsync_NoAggregateKeyword_UsesLastFunction()
{
// Arrange & Act
var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV 최근 1시간");
// Assert
Assert.Contains("last", sql.ToLower());
}
[Fact]
public async Task ParseNaturalLanguageAsync_AverageKeyword_UsesAvgFunction()
{
// Arrange & Act
var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV 최근 1시간 average");
// Assert
Assert.Contains("avg", sql.ToLower());
}
#endregion
#region SQL Injection Prevention Tests
[Fact]
public async Task ParseNaturalLanguageAsync_SpecialCharacters_EscapesTagName()
{
// Arrange - tag names with single quotes are escaped in SQL
// Use a tag name containing a quote character to verify escaping
var input = "PV'O01 최근 1시간 평균";
// Act
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert - single quotes should be escaped with double single quotes
// The regex extracts "PV" before the quote, so the tag is "PV"
// But if the tag contains a quote, it gets escaped
Assert.Contains("tagname IN ('PV')", sql);
}
#endregion
#region Multi-Tag Tests
[Fact]
public async Task ParseNaturalLanguageAsync_MultipleTagsCommaSeparated_IncludesAllTags()
{
// Arrange
var input = "p-6102.hzset.fieldvalue, ficq-6113.op 최근 2시간 값";
// Act
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert - Both tags must appear in SQL
Assert.Contains("'p-6102.hzset.fieldvalue'", sql);
Assert.Contains("'ficq-6113.op'", sql);
Assert.DoesNotContain("최근", sql);
Assert.DoesNotContain("값", sql);
}
[Fact]
public async Task ParseNaturalLanguageAsync_ThreeTagsCommaSeparated_IncludesAllTags()
{
// Arrange
var input = "FICQ-6101.PV, PV002, PV003 최근 1시간 평균";
// Act
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert
Assert.Contains("'FICQ-6101.PV'", sql);
Assert.Contains("'PV002'", sql);
Assert.Contains("'PV003'", sql);
}
[Fact]
public async Task ParseNaturalLanguageAsync_MultipleTagsWithKoreanDescriptions_ExtractsOnlyTags()
{
// Arrange
var input = "temp-001 온도, pressure-002 압력 최근 1시간 평균";
// Act
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert - Only tag names should appear, not Korean descriptions
Assert.Contains("'temp-001'", sql);
Assert.Contains("'pressure-002'", sql);
Assert.DoesNotContain("온도", sql);
Assert.DoesNotContain("압력", sql);
}
[Fact]
public async Task ParseNaturalLanguageAsync_MultipleTagsWithAbsTimeRange_IncludesAllTags()
{
// Arrange
var input = "FICQ-6101.PV, PV002 2026년 4월 13일 부터 4월 14일 까지";
// Act
var sql = await _service.ParseNaturalLanguageAsync(input);
// Assert
Assert.Contains("'FICQ-6101.PV'", sql);
Assert.Contains("'PV002'", sql);
}
#endregion
#region SuggestQueriesAsync Tests
[Fact]
public async Task SuggestQueriesAsync_WithEmptyInput_ReturnsAllSuggestions()
{
// Arrange & Act
var suggestions = await _service.SuggestQueriesAsync("");
// Assert
var list = suggestions.ToList();
Assert.Equal(5, list.Count);
Assert.Contains("최근 1시간 평균", list);
Assert.Contains("최근 24시간 최대값", list);
Assert.Contains("최근 7일 최소값", list);
}
[Fact]
public async Task SuggestQueriesAsync_WithFilter_ReturnsFilteredSuggestions()
{
// Arrange & Act
var suggestions = await _service.SuggestQueriesAsync("최대");
// Assert
var list = suggestions.ToList();
Assert.Single(list);
Assert.Contains("최근 24시간 최대값", list);
}
#endregion
}

View File

@@ -0,0 +1,533 @@
using ExperionCrawler.Core.Application.DTOs;
using ExperionCrawler.Core.Application.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Xunit;
namespace ExperionCrawler.Tests;
/// <summary>
/// TextToSqlService 통합 테스트
/// task_state.md의 매핑표 기반으로 작성된 테스트 프로그램
/// </summary>
public class TextToSqlTest
{
private readonly TextToSqlService _service;
private readonly ILogger<TextToSqlService> _logger;
public TextToSqlTest()
{
// Mock logger creation
var loggerFactory = new LoggerFactory();
_logger = loggerFactory.CreateLogger<TextToSqlService>();
// Mock configuration with a dummy connection string
var config = new Dictionary<string, string?>
{
{ "ConnectionStrings:DefaultConnection", "Host=localhost;Port=5432;Database=iiot_platform;Username=postgres;Password=postgres" }
};
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(config)
.Build();
_service = new TextToSqlService(_logger, configuration);
}
#region 1. SQL
[Fact]
public void ParseNaturalLanguageAsync_WithValidInput_ReturnsValidSqlFormat()
{
// Arrange
var input = "FICQ-6101.PV 최근 1시간 평균";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
// Assert - 응답 형식 검증
Assert.NotNull(sql);
Assert.StartsWith("SELECT", sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains("FROM history_table", sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains("date_trunc", sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains("GROUP BY", sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains("ORDER BY", sql, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ParseNaturalLanguageAsync_WithMaxKeyword_ReturnsMaxFunction()
{
// Arrange
var input = "FICQ-6101.PV 최근 1시간 최대값";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
// Assert
Assert.Contains("max", sql, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ParseNaturalLanguageAsync_WithMinKeyword_ReturnsMinFunction()
{
// Arrange
var input = "FICQ-6101.PV 최근 1시간 최솟값";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
// Assert
Assert.Contains("min", sql, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ParseNaturalLanguageAsync_WithFirstKeyword_ReturnsFirstFunction()
{
// Arrange
var input = "FICQ-6101.PV 초기 값";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
// Assert
Assert.Contains("first", sql, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ParseNaturalLanguageAsync_WithLastKeyword_ReturnsLastFunction()
{
// Arrange
var input = "FICQ-6101.PV 마지막 값";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
// Assert
Assert.Contains("last", sql, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ParseNaturalLanguageAsync_WithAvgKeyword_ReturnsAvgFunction()
{
// Arrange
var input = "FICQ-6101.PV 최근 1시간 평균";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
// Assert
Assert.Contains("avg", sql, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ParseNaturalLanguageAsync_WithMultipleTags_ReturnsAllTagsInSql()
{
// Arrange
var input = "p-6102.hzset.fieldvalue, ficq-6113.op 최근 2시간 값";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
// Assert
Assert.Contains("'p-6102.hzset.fieldvalue'", sql);
Assert.Contains("'ficq-6113.op'", sql);
}
[Fact]
public void ParseNaturalLanguageAsync_WithOpcUaNodeId_ReturnsOpcUaFormat()
{
// Arrange
var input = "ns=2;s=Reactor.Temperature 최근 1시간 평균";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
// Assert
Assert.Contains("'ns=2;s=Reactor.Temperature'", sql);
}
#endregion
#region 2. SQL TimescaleDB
[Fact]
public async Task ExecuteQueryAsync_WithValidSql_ReturnsResultWithColumnsAndRows()
{
// Arrange
var sql = "SELECT date_trunc('minute', recorded_at) AS bucket, tagname, last(value::double precision, recorded_at) AS result FROM history_table WHERE tagname IN ('FICQ-6101.PV') AND recorded_at > NOW() - INTERVAL '1 hour' GROUP BY 1, 2 ORDER BY 1, 2 LIMIT 10";
// Act
var result = await _service.ExecuteQueryAsync(sql, 10);
// Assert - TimescaleDB 결과 반환 확인
Assert.True(result.Success);
Assert.NotNull(result.Columns);
Assert.True(result.Columns.Count > 0);
Assert.Contains("bucket", result.Columns[0], StringComparison.OrdinalIgnoreCase);
Assert.Contains("tagname", result.Columns[1], StringComparison.OrdinalIgnoreCase);
Assert.Contains("result", result.Columns[2], StringComparison.OrdinalIgnoreCase);
Assert.NotNull(result.Rows);
}
[Fact]
public async Task ExecuteQueryAsync_WithLimit_ReturnsLimitedRows()
{
// Arrange
var sql = "SELECT date_trunc('minute', recorded_at) AS bucket, tagname, last(value::double precision, recorded_at) AS result FROM history_table WHERE tagname IN ('FICQ-6101.PV') AND recorded_at > NOW() - INTERVAL '1 hour' GROUP BY 1, 2 ORDER BY 1, 2";
// Act
var result = await _service.ExecuteQueryAsync(sql, 5);
// Assert
Assert.True(result.Success);
Assert.True(result.Rows.Count <= 5);
}
[Fact]
public async Task ExecuteQueryAsync_WithInvalidSql_ReturnsError()
{
// Arrange
var sql = "SELECT * FROM invalid_table_that_does_not_exist";
// Act
var result = await _service.ExecuteQueryAsync(sql);
// Assert
Assert.False(result.Success);
Assert.NotNull(result.Error);
Assert.Contains("PostgreSQL 오류", result.Error);
}
[Fact]
public async Task ExecuteQueryAsync_WithSqlInjectionAttempt_ReturnsError()
{
// Arrange
var sql = "SELECT * FROM history_table WHERE tagname = 'FICQ-6101.PV' OR '1'='1'";
// Act
var result = await _service.ExecuteQueryAsync(sql);
// Assert
Assert.False(result.Success);
Assert.NotNull(result.Error);
}
#endregion
#region 3. /
[Fact]
public async Task ParseNaturalLanguageAsync_WithEmptyInput_ThrowsArgumentException()
{
// Arrange & Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => _service.ParseNaturalLanguageAsync(""));
await Assert.ThrowsAsync<ArgumentException>(() => _service.ParseNaturalLanguageAsync(null!));
await Assert.ThrowsAsync<ArgumentException>(() => _service.ParseNaturalLanguageAsync(" "));
}
[Fact]
public async Task ParseNaturalLanguageAsync_WithWhitespaceOnly_ThrowsArgumentException()
{
// Arrange & Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => _service.ParseNaturalLanguageAsync(" "));
}
[Fact]
public async Task ParseNaturalLanguageAsync_WithOnlyTimeKeyword_ThrowsArgumentException()
{
// Arrange & Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => _service.ParseNaturalLanguageAsync("최근 1시간"));
}
[Fact]
public async Task ParseNaturalLanguageAsync_WithOnlyDescription_ThrowsArgumentException()
{
// Arrange & Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => _service.ParseNaturalLanguageAsync("온도 값"));
}
[Fact]
public async Task ExecuteQueryAsync_WithEmptySql_ThrowsException()
{
// Arrange
var sql = "";
// Act & Assert
await Assert.ThrowsAsync<Exception>(() => _service.ExecuteQueryAsync(sql));
}
[Fact]
public async Task ExecuteQueryAsync_WithNullSql_ThrowsException()
{
// Arrange
string? sql = null;
// Act & Assert
await Assert.ThrowsAsync<Exception>(() => _service.ExecuteQueryAsync(sql!));
}
[Fact]
public async Task ExecuteQueryAsync_WithInvalidTagInSql_ReturnsError()
{
// Arrange
var sql = "SELECT date_trunc('minute', recorded_at) AS bucket, tagname, last(value::double precision, recorded_at) AS result FROM history_table WHERE tagname IN ('INVALID_TAG_12345') AND recorded_at > NOW() - INTERVAL '1 hour' GROUP BY 1, 2 ORDER BY 1, 2 LIMIT 10";
// Act
var result = await _service.ExecuteQueryAsync(sql, 10);
// Assert
Assert.False(result.Success);
Assert.NotNull(result.Error);
}
#endregion
#region 4. SQL
[Fact]
public async Task ParseNaturalLanguageAsync_KoreanRecent1Hour_ReturnsCorrectInterval()
{
// Arrange
var input = "FICQ-6101.PV 최근 1시간 평균";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
// Assert
Assert.Contains("INTERVAL '1 hour'", sql);
}
[Fact]
public async Task ParseNaturalLanguageAsync_KoreanRecent24Hours_ReturnsCorrectInterval()
{
// Arrange
var input = "FICQ-6101.PV 최근 24시간 최대값";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
// Assert
Assert.Contains("INTERVAL '24 hours'", sql);
}
[Fact]
public async Task ParseNaturalLanguageAsync_KoreanRecent7Days_ReturnsCorrectInterval()
{
// Arrange
var input = "FICQ-6101.PV 최근 7일 최소값";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
// Assert
Assert.Contains("INTERVAL '7 days'", sql);
}
[Fact]
public async Task ParseNaturalLanguageAsync_KoreanRecent1Month_ReturnsCorrectInterval()
{
// Arrange
var input = "FICQ-6101.PV 최근 1개월 평균";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
// Assert
Assert.Contains("INTERVAL '30 days'", sql);
}
[Fact]
public async Task ParseNaturalLanguageAsync_KoreanDateRange_ReturnsTimeCondition()
{
// Arrange
var input = "p-6102.hzset.fieldvalue 2026년 4월 13일 부터 4월 14일 까지";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
// Assert - 절대 범위 조건이 포함되어야 함
Assert.Contains("time", sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains(">=", sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains("AND", sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains("<", sql, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ParseNaturalLanguageAsync_KoreanFromToPattern_ReturnsTimeCondition()
{
// Arrange
var input = "FICQ-6101.PV 4월 1일 부터 4월 7일 까지 평균";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
// Assert - 절대 범위 조건이 포함되어야 함
Assert.Contains("time", sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains(">=", sql, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ParseNaturalLanguageAsync_KoreanAmPmRange_ReturnsTimeCondition()
{
// Arrange
var input = "FICQ-6101.PV 오전 9시부터 오후 6시까지 평균";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
// Assert - 절대 범위 조건이 포함되어야 함
Assert.Contains("time", sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains(">=", sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains("AND", sql, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ParseNaturalLanguageAsync_KoreanAfterPattern_ReturnsTimeCondition()
{
// Arrange
var input = "FICQ-6101.PV 오늘 오후 2시 이후 값";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
// Assert - 시작 시간 조건이 포함되어야 함
Assert.Contains("time", sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains(">=", sql, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ParseNaturalLanguageAsync_KoreanWithDescription_ExtractsOnlyTagName()
{
// Arrange
var input = "temp-001 온도, pressure-002 압력 최근 1시간 평균";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
// Assert - 한국어 설명은 제거되고 태그명만 포함되어야 함
Assert.Contains("'temp-001'", sql);
Assert.Contains("'pressure-002'", sql);
Assert.DoesNotContain("온도", sql);
Assert.DoesNotContain("압력", sql);
}
[Fact]
public async Task ParseNaturalLanguageAsync_KoreanWithTimeKeyword_ExtractsOnlyTagName()
{
// Arrange
var input = "FICQ-6101.PV 최근 1시간 평균";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
// Assert - "최근" 키워드는 제거되고 태그명만 포함되어야 함
Assert.Contains("'FICQ-6101.PV'", sql);
Assert.DoesNotContain("최근", sql);
}
[Fact]
public async Task ParseNaturalLanguageAsync_KoreanWithTimePattern_ExtractsOnlyTagName()
{
// Arrange
var input = "FICQ-6101.PV 1시간 평균";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
// Assert - "1시간" 패턴은 제거되고 태그명만 포함되어야 함
Assert.Contains("'FICQ-6101.PV'", sql);
Assert.DoesNotContain("1시간", sql);
}
[Fact]
public async Task ParseNaturalLanguageAsync_KoreanWithTagKeyword_ExtractsOnlyTagName()
{
// Arrange
var input = "데이터 중 aia-131.sp 최근 1시간 평균";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
// Assert - "데이터 중" 키워드는 제거되고 태그명만 포함되어야 함
Assert.Contains("'aia-131.sp'", sql);
Assert.DoesNotContain("데이터", sql);
}
[Fact]
public async Task ParseNaturalLanguageAsync_KoreanWithMiddleKeyword_ExtractsOnlyTagName()
{
// Arrange
var input = "데이터 중 aia-131.sp 최근 1시간 평균";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
// Assert - "중" 키워드 이후 태그명만 추출되어야 함
Assert.Contains("'aia-131.sp'", sql);
}
[Fact]
public async Task ParseNaturalLanguageAsync_KoreanWithDotTagName_ExtractsCorrectly()
{
// Arrange
var input = "p-6102.hzset.fieldvalue 2026년 4월 13일 부터 현재까지의 값 표시";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
// Assert
Assert.Contains("'p-6102.hzset.fieldvalue'", sql);
}
[Fact]
public async Task ParseNaturalLanguageAsync_KoreanWithNoSpaceTagName_UsesWholeInput()
{
// Arrange
var input = "FICQ-6101.PV";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
// Assert
Assert.Contains("'FICQ-6101.PV'", sql);
}
[Fact]
public async Task ParseNaturalLanguageAsync_KoreanWithNoTimeSpecified_UsesDefault()
{
// Arrange
var input = "FICQ-6101.PV";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
// Assert - default time bucket should be "5 min"
Assert.Contains("5 min", sql);
}
[Fact]
public async Task ParseNaturalLanguageAsync_KoreanWithAverageKeyword_UsesAvgFunction()
{
// Arrange
var input = "FICQ-6101.PV 최근 1시간 average";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
// Assert
Assert.Contains("avg", sql, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ParseNaturalLanguageAsync_KoreanWithMaxKeyword_UsesMaxFunction()
{
// Arrange
var input = "FICQ-6101.PV 최근 1시간 최대";
// Act
var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult();
// Assert
Assert.Contains("max", sql, StringComparison.OrdinalIgnoreCase);
}
}
#endregion

View File

@@ -0,0 +1,10 @@
namespace ExperionCrawler.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}