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.
383 lines
11 KiB
C#
383 lines
11 KiB
C#
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
|
|
}
|