- P&ID: 연결 분석 API, Prefix 규칙 관리, 카테고리 분류, DXF 그래프 빌드 - LLM: 대화 요약, tool card 영구 보존, 시계열 차트(uPlot), 에이전트 모드 - KB: 청크 미리보기, Field Instrument Inference, 인증/Qdrant 클라이언트 - MCP: 서버 기능 확장, 파이프라인 수정, timeout 개선 - Frontend: P&ID UI, LLM UI, KB UI, OPC UA Write 탭 추가 - 설정: AGENTS.md, plant_context, README, opencode.json 업데이트 - 정리: 진단 체크리스트 문서 삭제
534 lines
17 KiB
C#
534 lines
17 KiB
C#
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 async Task ParseNaturalLanguageAsync_WithValidInput_ReturnsValidSqlFormat()
|
|
{
|
|
// Arrange
|
|
var input = "FICQ-6101.PV 최근 1시간 평균";
|
|
|
|
// Act
|
|
var sql = await _service.ParseNaturalLanguageAsync(input);
|
|
|
|
// 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 async Task ParseNaturalLanguageAsync_WithMaxKeyword_ReturnsMaxFunction()
|
|
{
|
|
// Arrange
|
|
var input = "FICQ-6101.PV 최근 1시간 최대값";
|
|
|
|
// Act
|
|
var sql = await _service.ParseNaturalLanguageAsync(input);
|
|
|
|
// Assert
|
|
Assert.Contains("max", sql, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ParseNaturalLanguageAsync_WithMinKeyword_ReturnsMinFunction()
|
|
{
|
|
// Arrange
|
|
var input = "FICQ-6101.PV 최근 1시간 최솟값";
|
|
|
|
// Act
|
|
var sql = await _service.ParseNaturalLanguageAsync(input);
|
|
|
|
// Assert
|
|
Assert.Contains("min", sql, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ParseNaturalLanguageAsync_WithFirstKeyword_ReturnsFirstFunction()
|
|
{
|
|
// Arrange
|
|
var input = "FICQ-6101.PV 초기 값";
|
|
|
|
// Act
|
|
var sql = await _service.ParseNaturalLanguageAsync(input);
|
|
|
|
// Assert
|
|
Assert.Contains("first", sql, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ParseNaturalLanguageAsync_WithLastKeyword_ReturnsLastFunction()
|
|
{
|
|
// Arrange
|
|
var input = "FICQ-6101.PV 마지막 값";
|
|
|
|
// Act
|
|
var sql = await _service.ParseNaturalLanguageAsync(input);
|
|
|
|
// Assert
|
|
Assert.Contains("last", sql, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ParseNaturalLanguageAsync_WithAvgKeyword_ReturnsAvgFunction()
|
|
{
|
|
// Arrange
|
|
var input = "FICQ-6101.PV 최근 1시간 평균";
|
|
|
|
// Act
|
|
var sql = await _service.ParseNaturalLanguageAsync(input);
|
|
|
|
// Assert
|
|
Assert.Contains("avg", sql, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ParseNaturalLanguageAsync_WithMultipleTags_ReturnsAllTagsInSql()
|
|
{
|
|
// Arrange
|
|
var input = "p-6102.hzset.fieldvalue, ficq-6113.op 최근 2시간 값";
|
|
|
|
// Act
|
|
var sql = await _service.ParseNaturalLanguageAsync(input);
|
|
|
|
// Assert
|
|
Assert.Contains("'p-6102.hzset.fieldvalue'", sql);
|
|
Assert.Contains("'ficq-6113.op'", sql);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ParseNaturalLanguageAsync_WithOpcUaNodeId_ReturnsOpcUaFormat()
|
|
{
|
|
// Arrange
|
|
var input = "ns=2;s=Reactor.Temperature 최근 1시간 평균";
|
|
|
|
// Act
|
|
var sql = await _service.ParseNaturalLanguageAsync(input);
|
|
|
|
// 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_ThrowsArgumentException()
|
|
{
|
|
// Arrange
|
|
var sql = "";
|
|
|
|
// Act & Assert
|
|
await Assert.ThrowsAsync<ArgumentException>(() => _service.ExecuteQueryAsync(sql));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExecuteQueryAsync_WithNullSql_ThrowsArgumentNullException()
|
|
{
|
|
// Arrange
|
|
string? sql = null;
|
|
|
|
// Act & Assert
|
|
await Assert.ThrowsAsync<ArgumentNullException>(() => _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 = await _service.ParseNaturalLanguageAsync(input);
|
|
|
|
// Assert
|
|
Assert.Contains("INTERVAL '1 hour'", sql);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ParseNaturalLanguageAsync_KoreanRecent24Hours_ReturnsCorrectInterval()
|
|
{
|
|
// Arrange
|
|
var input = "FICQ-6101.PV 최근 24시간 최대값";
|
|
|
|
// Act
|
|
var sql = await _service.ParseNaturalLanguageAsync(input);
|
|
|
|
// Assert
|
|
Assert.Contains("INTERVAL '24 hours'", sql);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ParseNaturalLanguageAsync_KoreanRecent7Days_ReturnsCorrectInterval()
|
|
{
|
|
// Arrange
|
|
var input = "FICQ-6101.PV 최근 7일 최소값";
|
|
|
|
// Act
|
|
var sql = await _service.ParseNaturalLanguageAsync(input);
|
|
|
|
// Assert
|
|
Assert.Contains("INTERVAL '7 days'", sql);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ParseNaturalLanguageAsync_KoreanRecent1Month_ReturnsCorrectInterval()
|
|
{
|
|
// Arrange
|
|
var input = "FICQ-6101.PV 최근 1개월 평균";
|
|
|
|
// Act
|
|
var sql = await _service.ParseNaturalLanguageAsync(input);
|
|
|
|
// 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 = await _service.ParseNaturalLanguageAsync(input);
|
|
|
|
// 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 = await _service.ParseNaturalLanguageAsync(input);
|
|
|
|
// 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 = await _service.ParseNaturalLanguageAsync(input);
|
|
|
|
// 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 = await _service.ParseNaturalLanguageAsync(input);
|
|
|
|
// 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 = await _service.ParseNaturalLanguageAsync(input);
|
|
|
|
// 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 = await _service.ParseNaturalLanguageAsync(input);
|
|
|
|
// 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 = await _service.ParseNaturalLanguageAsync(input);
|
|
|
|
// 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 = await _service.ParseNaturalLanguageAsync(input);
|
|
|
|
// 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 = await _service.ParseNaturalLanguageAsync(input);
|
|
|
|
// 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 = await _service.ParseNaturalLanguageAsync(input);
|
|
|
|
// Assert
|
|
Assert.Contains("'p-6102.hzset.fieldvalue'", sql);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ParseNaturalLanguageAsync_KoreanWithNoSpaceTagName_UsesWholeInput()
|
|
{
|
|
// Arrange
|
|
var input = "FICQ-6101.PV";
|
|
|
|
// Act
|
|
var sql = await _service.ParseNaturalLanguageAsync(input);
|
|
|
|
// Assert
|
|
Assert.Contains("'FICQ-6101.PV'", sql);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ParseNaturalLanguageAsync_KoreanWithNoTimeSpecified_UsesDefault()
|
|
{
|
|
// Arrange
|
|
var input = "FICQ-6101.PV";
|
|
|
|
// Act
|
|
var sql = await _service.ParseNaturalLanguageAsync(input);
|
|
|
|
// 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 = await _service.ParseNaturalLanguageAsync(input);
|
|
|
|
// Assert
|
|
Assert.Contains("avg", sql, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ParseNaturalLanguageAsync_KoreanWithMaxKeyword_UsesMaxFunction()
|
|
{
|
|
// Arrange
|
|
var input = "FICQ-6101.PV 최근 1시간 최대";
|
|
|
|
// Act
|
|
var sql = await _service.ParseNaturalLanguageAsync(input);
|
|
|
|
// Assert
|
|
Assert.Contains("max", sql, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
}
|
|
#endregion
|