Major project initialization and feature implementation: **Core Features:** - OPC UA client for Honeywell Experion HS R530 integration - Real-time data streaming and history data retrieval - Text-to-SQL query engine with TimeScaleDB - JSON-based node configuration system - SQLite database with migration support **Architecture:** - Clean architecture with Domain, Application, Infrastructure layers - ASP.NET Core Web API frontend - Web UI with real-time visualization - PKI-based OPC UA authentication (TLS) **Infrastructure Components:** - ExperionOpcClient: OPC UA connection management - ExperionRealtimeService: Real-time data streaming - ExperionHistoryService: Historical data queries - TextToSqlService: Natural language to SQL queries - SqlValidator: SQL injection prevention **Database:** - TimescaleDB integration (recommended) or SQLite fallback - Entity Framework Core with Extenstion methods - OPCTag, KeyValue tables for data storage **Security:** - Certificate-based OPC UA endpoint security - SSL/TLS encryption for database connections - Output param binding injection prevention **Testing:** - Unit tests for TextToSqlService and SqlValidator - Integration tests for Korean time range extraction See REVIEW_REQUEST.md for detailed code review information.
451 lines
14 KiB
C#
451 lines
14 KiB
C#
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
|
|
}
|