#nullable enable using Cysharp.Threading.Tasks; using Newtonsoft.Json.Linq; using NUnit.Framework; using System; using System.Collections.Generic; using UnityEngine; using UVC.Data; using UVC.Log; namespace UVC.Tests.Data { [TestFixture] public class MQTTPipeLineTests { private MQTTPipeLine pipeLine; private Dictionary handlers; private Dictionary dataMasks; private readonly string[] topicNames = { "AGV", "CARRIER", "STOCKER_STACK", "ALL" }; [SetUp] public void Setup() { // 기본 테스트 환경 설정 pipeLine = new MQTTPipeLine("localhost", 1883); pipeLine.UseMockup = true; // 테스트에서는 항상 MockMQTTService 사용 // 핸들러와 데이터 마스크 초기화 handlers = new Dictionary(); dataMasks = new Dictionary(); // 각 토픽별 핸들러와 데이터 마스크 설정 foreach (var topic in topicNames) { handlers[topic] = new TestDataHandler(); dataMasks[topic] = CreateDataMaskForTopic(topic); } } public async UniTask TestAll() { Setup(); Debug.Log("===== MQTTPipeLine 테스트 시작 ====="); // 하나씩 테스트 해야 함 //await RunTestAsync(nameof(ExecutePipeLine_AllTopics_RegistersAndHandlesMessages), ExecutePipeLine_AllTopics_RegistersAndHandlesMessages); //await RunTestAsync(nameof(RemoveTopic_ShouldStopReceivingMessages), RemoveTopic_ShouldStopReceivingMessages); //await RunTestAsync(nameof(UpdatedDataOnly_ShouldOnlyCallHandlerForUpdatedData), UpdatedDataOnly_ShouldOnlyCallHandlerForUpdatedData); //await RunTestAsync(nameof(UpdatedDataOnly_WithMockMQTTService_ShouldOnlyReceiveUpdatedData), UpdatedDataOnly_WithMockMQTTService_ShouldOnlyReceiveUpdatedData); //RunTest(nameof(OnTopicMessage_ValidJsonObject_CallsHandler), OnTopicMessage_ValidJsonObject_CallsHandler); //RunTest(nameof(OnTopicMessage_JsonArray_CallsHandler), OnTopicMessage_JsonArray_CallsHandler); //RunTest(nameof(OnTopicMessage_EmptyMessage_DoesNotCallHandler), OnTopicMessage_EmptyMessage_DoesNotCallHandler); RunTest(nameof(OnTopicMessage_InvalidJson_DoesNotCallHandler), OnTopicMessage_InvalidJson_DoesNotCallHandler); Debug.Log("===== MQTTPipeLine 테스트 완료 ====="); } private void RunTest(string testName, Action testAction) { try { Debug.Log($"테스트 시작: {testName}"); testAction(); Debug.Log($"테스트 성공: {testName}"); } catch (Exception ex) { Debug.LogError($"테스트 실패: {testName}\n{ex.Message}\n{ex.StackTrace}"); } } private async UniTask RunTestAsync(string testName, Func testAction) { try { Debug.Log($"테스트 시작: {testName}"); await testAction(); Debug.Log($"테스트 성공: {testName}"); } catch (Exception ex) { Debug.LogError($"테스트 실패: {testName}\n{ex.Message}\n{ex.StackTrace}"); } } [TearDown] public void TearDown() { // 테스트 완료 후 리소스 정리 pipeLine.Stop(); pipeLine.Dispose(); } private DataMask CreateDataMaskForTopic(string topic) { var dataMask = new DataMask(); dataMask.ObjectName = topic; // 각 토픽별로 ObjectIdKey 설정 switch (topic) { case "AGV": dataMask.ObjectIdKey = "VHL_NAME"; dataMask.ObjectName = "AGV"; dataMask["VHL_NAME"] = "HFF09CNA8013"; dataMask["AGV_IDX"] = 12; dataMask["B_INSTALL"] = "Y"; dataMask["NODE_ID"] = 235; dataMask["REAL_ID"] = 235; dataMask["VHL_STATE"] = 11; dataMask["BAY_LIST"] = "21;"; dataMask["X"] = 118301; dataMask["Y"] = 20177; dataMask["MODE"] = 1; dataMask["BATT"] = 75; dataMask["SUB_GOAL"] = 211; dataMask["FINAL_GOAL"] = 1006; dataMask["TIMESTAMP"] = "2025-03-25T12:00:00.980Z"; dataMask["DEGREE"] = 181.2; dataMask["STOP_STATE"] = 0; dataMask["JOB_ID"] = "2F24217_289_7038296224059039"; dataMask["DESTINATION_PORT"] = "HFF09MPI0200_LIP01"; dataMask["SOURCE_PORT"] = "HFF09AGM0100_UOP01"; dataMask["FROM"] = "HFF09AGM0100,NULL,0201012"; dataMask["TO"] = "HFF09MPI0200,HFF09MPI0200_LIP01,NULL"; dataMask["TRANSPORT_JOB_TIMESTAMP"] = "2025-03-25T05:40:19.000Z"; dataMask["FACTOR"] = 69.3; dataMask["AGV_FACTOR_TIMESTAMP"] = DateTime.Now; break; case "CARRIER": dataMask.ObjectIdKey = "MAIN_CARR_ID"; dataMask.ObjectName = "Carrier"; dataMask["MAIN_CARR_ID"] = "2F02365"; dataMask["SUB_CARR_ID"] = "2F02365,2F70671,2F28723"; dataMask["CARR_SEQ"] = "3"; dataMask["CARR_USE"] = "EMPTY"; dataMask["CURRENTPORT"] = "HFF09CNV0300_ABP3003"; dataMask["MOVESTATUS"] = "ARRIVED"; dataMask["MOVEFLAG"] = "0"; dataMask["PROD_DETAIL_CODE"] = "E3A"; dataMask["ASSIGN_LOT_QTY"] = "0"; dataMask["CARR_SIZE_TYPE"] = "STACK3"; dataMask["ABNM_VALUE"] = "0"; dataMask["LINE_ID"] = "FM0I"; dataMask["TIMESTAMP"] = DateTime.Now; dataMask["GOOD_QTY"] = "0"; dataMask["CURRENTLOCATION"] = "HFF09CNV0300"; break; case "STOCKER_STACK": dataMask.ObjectIdKey = "STOCKER_NAME"; dataMask.ObjectName = "StokerStack"; dataMask["STOCKER_NAME"] = "HFF09AGN0300"; dataMask["CAPACITY"] = "89.57"; dataMask["MAXIMUM_CAPACITY"] = "834"; dataMask["TRAY_CAPACITY"] = "83.25"; dataMask["MAXIMUM_TRAY_CAPACITY"] = "2502"; dataMask["RACK_LOAD_COUNT"] = "747"; dataMask["RACK_EMPTY_COUNT"] = "87"; dataMask["RESERVATED_RETURN_COUNT"] = "5"; dataMask["TRAY_COUNT"] = "2083"; dataMask["TRAY_REWORK_COUNT_AVG"] = "3"; dataMask["TRAY_REWORK_COUNT_MAX"] = "153"; dataMask["TRAY_REWORK_COUNT_MIN"] = "0"; dataMask["RACK_DISABLE_COUNT"] = "4"; dataMask["KOR_EQP_NAME"] = "상온Aging #03"; dataMask["ENG_EQP_NAME"] = "상온Aging #03"; dataMask["TIMESTAMP"] = DateTime.Now; dataMask["STEP"] = new List() { new DataMask { ObjectIdKey = "STOCKER_NAME", ObjectName = "StokerStep", ["STOCKER_NAME"] = "HFF09AGN0300", ["STEP_ID"] = "8106", ["RACK_STEP_COUNT"] = "88", ["TOTAL"] = "834", ["STEP_CAPACITY"] = "10.55", }, }; break; case "ALL": // ALL 토픽은 ObjectIdKey 없음 dataMask["AGV"] = new List() { new DataMask { ["VHL_NAME"] = "HFF09CNA8053", ["AGV_IDX"] = 52, ["X"] = 223316, ["Y"] = 218171, ["B_INSTALL"] = "Y", ["NODE_ID"] = 235, ["REAL_ID"] = 235, ["VHL_STATE"] = 11, ["BAY_LIST"] = "21;", ["MODE"] = 1, ["BATT"] = 75, ["SUB_GOAL"] = 211, ["FINAL_GOAL"] = 1006, ["TIMESTAMP"] = "2025-03-25T12:00:00.980Z", ["DEGREE"] = 181.2, ["STOP_STATE"] = 0, ["JOB_ID"] = "2F24217_289_7038296224059039", ["DESTINATION_PORT"] = "HFF09MPI0200_LIP01", ["SOURCE_PORT"] = "HFF09AGM0100_UOP01", ["FROM"] = "HFF09AGM0100,NULL,0201012", ["TO"] = "HFF09MPI0200,HFF09MPI0200_LIP01,NULL", ["TRANSPORT_JOB_TIMESTAMP"] = "2025-03-25T05:40:19.000Z", ["FACTOR"] = 69.3, } }; dataMask["CARRIER"] = new List() { new DataMask { ["MAIN_CARR_ID"] = "2F02365", ["SUB_CARR_ID"] = "2F02365,2F70671,2F28723", ["CARR_SEQ"] = "3", ["CARR_USE"] = "EMPTY", ["CURRENTPORT"] = "HFF09CNV0300_ABP3003", ["MOVESTATUS"] = "ARRIVED", ["MOVEFLAG"] = "0", ["PROD_DETAIL_CODE"] = "E3A", ["ASSIGN_LOT_QTY"] = "0", ["CARR_SIZE_TYPE"] = "STACK3", ["ABNM_VALUE"] = "0", ["LINE_ID"] = "FM0I", ["TIMESTAMP"] = DateTime.Now, ["GOOD_QTY"] = "0", ["CURRENTLOCATION"] = "HFF09CNV0300", } }; dataMask["CARRIER"] = new List() { new DataMask { ["STOCKER_NAME"] = "HFF09AGN0300", ["CAPACITY"] = "89.57", ["MAXIMUM_CAPACITY"] = "834", ["TRAY_CAPACITY"] = "83.25", ["MAXIMUM_TRAY_CAPACITY"] = "2502", ["RACK_LOAD_COUNT"] = "747", ["RACK_EMPTY_COUNT"] = "87", ["RESERVATED_RETURN_COUNT"] = "5", ["TRAY_COUNT"] = "2083", ["TRAY_REWORK_COUNT_AVG"] = "3", ["TRAY_REWORK_COUNT_MAX"] = "153", ["TRAY_REWORK_COUNT_MIN"] = "0", ["RACK_DISABLE_COUNT"] = "4", ["KOR_EQP_NAME"] = "상온Aging #03", ["ENG_EQP_NAME"] = "상온Aging #03", ["TIMESTAMP"] = DateTime.Now, ["STEP"] = new List { new DataMask { ["STOCKER_NAME"] = "HFF09AGN0300", ["STEP_ID"] = "8106", ["RACK_STEP_COUNT"] = "88", ["TOTAL"] = "834", ["STEP_CAPACITY"] = "10.55", }, } } }; break; } return dataMask; } [Test] public async UniTask ExecutePipeLine_AllTopics_RegistersAndHandlesMessages() { // Arrange - 파이프라인 설정 foreach (var topic in topicNames) { // 필요한 UpdatedDataOnly 설정 bool updatedDataOnly = topic != "ALL"; var pipelineInfo = new MQTTPipeLineInfo(topic, updatedDataOnly) .setDataMapper(new DataMapper(dataMasks[topic])) .setHandler(handlers[topic].HandleData); pipeLine.Add(pipelineInfo); } Debug.Log("파이프라인 설정 완료."); // Act - 파이프라인 실행 pipeLine.Execute(); Debug.Log("파이프라인 Execute."); // Assert - 일정 시간 기다린 후 각 핸들러가 호출되었는지 확인 await UniTask.Delay(1500); // 각 토픽별로 핸들러가 호출되었는지 확인 foreach (var topic in topicNames) { Assert.IsTrue(handlers[topic].CallCount > 0, $"{topic} 토픽의 핸들러가 호출되지 않았습니다."); if (topic != "ALL") { // ALL을 제외한 토픽은 ObjectIdKey가 설정되어 있어야 함 Assert.IsNotNull(handlers[topic].LastDataObject, $"{topic} 토픽의 핸들러에 전달된 DataObject가 null입니다."); } } } [Test] public async UniTask RemoveTopic_ShouldStopReceivingMessages() { // Arrange // AGV 토픽만 등록 var agvInfo = new MQTTPipeLineInfo("AGV", true) .setDataMapper(new DataMapper(dataMasks["AGV"])) .setHandler(handlers["AGV"].HandleData); pipeLine.Add(agvInfo); pipeLine.Execute(); // 메시지가 수신되도록 잠시 대기 await UniTask.Delay(1000); // 초기 호출 횟수 저장 int initialCallCount = handlers["AGV"].CallCount; Assert.IsTrue(initialCallCount > 0, "초기 AGV 토픽의 핸들러가 호출되지 않았습니다."); // Act pipeLine.Remove("AGV"); // AGV 토픽 제거 // 핸들러 초기화 handlers["AGV"].Reset(); // 충분한 시간 대기 await UniTask.Delay(1500); // Assert Assert.AreEqual(0, handlers["AGV"].CallCount, "토픽을 제거했지만 핸들러가 여전히 호출되고 있습니다."); } [Test] public async UniTask UpdatedDataOnly_ShouldOnlyCallHandlerForUpdatedData() { // Arrange - 파이프라인 설정 // TestMQTTPipeLine을 사용하여 직접 메시지를 보낼 수 있게 함 var testPipeLine = new TestMQTTPipeLine(); // UpdatedDataOnly가 true인 AGV 토픽 추가 var agvInfo = new MQTTPipeLineInfo("AGV", true) .setDataMapper(new DataMapper(dataMasks["AGV"])) .setHandler(handlers["AGV"].HandleData); testPipeLine.Add(agvInfo); // Mock 데이터 생성 (업데이트가 있는 데이터) string jsonWithUpdates = "{\"VHL_NAME\":\"HFF09CNA8053\",\"AGV_IDX\":52,\"X\":223316,\"Y\":218171}"; // Act // 첫 번째 메시지 전송 (초기 데이터) testPipeLine.TestOnTopicMessage("AGV", jsonWithUpdates); // 동일한 메시지 다시 전송 (변경 없음) testPipeLine.TestOnTopicMessage("AGV", jsonWithUpdates); // Assert // UpdatedDataOnly가 true이므로 두 번째 메시지는 핸들러를 호출하지 않아야 함 // 실제로는 DataRepository가 필요하므로 완전한 테스트는 어려움 // 여기서는 최소한 첫 번째 메시지가 처리되었는지 확인 Assert.IsTrue(handlers["AGV"].CallCount > 0, "AGV 토픽의 핸들러가 호출되지 않았습니다."); } [Test] public void OnTopicMessage_ValidJsonObject_CallsHandler() { // Arrange var testPipeLine = new TestMQTTPipeLine(); foreach (var topic in topicNames) { bool updatedDataOnly = topic != "ALL"; var pipelineInfo = new MQTTPipeLineInfo(topic, updatedDataOnly) .setDataMapper(new DataMapper(dataMasks[topic])) .setHandler(handlers[topic].HandleData); testPipeLine.Add(pipelineInfo); } // Act // 각 토픽에 대해 유효한 JSON 메시지 전송 testPipeLine.TestOnTopicMessage("AGV", "{\"VHL_NAME\":\"HFF09CNA8053\",\"AGV_IDX\":52}"); testPipeLine.TestOnTopicMessage("CARRIER", "{\"MAIN_CARR_ID\":\"2F02365\",\"SUB_CARR_ID\":\"2F02365,2F70671,2F28723\"}"); testPipeLine.TestOnTopicMessage("STOCKER_STACK", "{\"STOCKER_NAME\":\"HFF09AGN0300\",\"CAPACITY\":\"89.57\"}"); testPipeLine.TestOnTopicMessage("ALL", "{\"key\":\"value\"}"); // Assert // 각 토픽의 핸들러가 호출되었는지 확인 foreach (var topic in topicNames) { Assert.AreEqual(1, handlers[topic].CallCount, $"{topic} 토픽의 핸들러가 정확히 한 번 호출되어야 합니다."); } } [Test] public void OnTopicMessage_JsonArray_CallsHandler() { // Arrange var testPipeLine = new TestMQTTPipeLine(); var pipelineInfo = new MQTTPipeLineInfo("AGV", true) .setDataMapper(new DataMapper(dataMasks["AGV"])) .setHandler(handlers["AGV"].HandleData); testPipeLine.Add(pipelineInfo); // JSON 배열 메시지 생성 string jsonArrayMessage = "[{\"VHL_NAME\":\"HFF09CNA8053\",\"AGV_IDX\":52},{\"VHL_NAME\":\"HFF09CNA8033\",\"AGV_IDX\":32}]"; // Act testPipeLine.TestOnTopicMessage("AGV", jsonArrayMessage); // Assert Assert.AreEqual(1, handlers["AGV"].CallCount, "AGV 토픽의 핸들러가 정확히 한 번 호출되어야 합니다."); Assert.IsNotNull(handlers["AGV"].LastDataObject, "핸들러에 전달된 DataObject가 null입니다."); } [Test] public void OnTopicMessage_EmptyMessage_DoesNotCallHandler() { // Arrange var testPipeLine = new TestMQTTPipeLine(); var pipelineInfo = new MQTTPipeLineInfo("AGV", true) .setDataMapper(new DataMapper(dataMasks["AGV"])) .setHandler(handlers["AGV"].HandleData); testPipeLine.Add(pipelineInfo); // Act testPipeLine.TestOnTopicMessage("AGV", ""); // 빈 메시지 전송 // Assert Assert.AreEqual(0, handlers["AGV"].CallCount, "빈 메시지가 전달되었을 때 핸들러가 호출되지 않아야 합니다."); } [Test] public void OnTopicMessage_InvalidJson_DoesNotCallHandler() { // Arrange var testPipeLine = new TestMQTTPipeLine(); var pipelineInfo = new MQTTPipeLineInfo("AGV", true) .setDataMapper(new DataMapper(dataMasks["AGV"])) .setHandler(handlers["AGV"].HandleData); testPipeLine.Add(pipelineInfo); // Act - 잘못된 JSON 형식의 메시지 전송 testPipeLine.TestOnTopicMessage("AGV", "{invalid json}"); // Assert Assert.AreEqual(0, handlers["AGV"].CallCount, "잘못된 JSON 형식의 메시지가 전달되었을 때 핸들러가 호출되지 않아야 합니다."); } [Test] public async UniTask UpdatedDataOnly_WithMockMQTTService_ShouldOnlyReceiveUpdatedData() { // Arrange // 1. 데이터 핸들러를 통해 수신된 데이터 추적 var handler = new UpdatedDataTrackingHandler(); // 2. AGV 토픽을 위한 데이터 마스크 설정 var dataMask = new DataMask(); dataMask.ObjectName = "AGV"; dataMask.ObjectIdKey = "VHL_NAME"; // 3. MQTTPipeLine 설정 (MockMQTTService 사용) var pipeline = new MQTTPipeLine("localhost", 1883); pipeline.UseMockup = true; // MockMQTTService 사용 설정 // 4. UpdatedDataOnly=true로 토픽 등록 var pipelineInfo = new MQTTPipeLineInfo("AGV", true) .setDataMapper(new DataMapper(dataMask)) .setHandler(handler.HandleData); pipeline.Add(pipelineInfo); // Act // 파이프라인 실행 - 이것이 MockMQTTService를 통해 메시지를 보내기 시작 pipeline.Execute(); // 첫 번째 데이터 세트가 수신될 때까지 대기 await UniTask.Delay(1500); // 첫 번째 데이터 세트의 콜백 수를 기록 int initialCallCount = handler.CallCount; Assert.IsTrue(initialCallCount > 0, "첫 번째 메시지 세트가 수신되지 않았습니다."); // AGV 항목 수 저장 int initialAgvCount = handler.ReceivedAgvItems.Count; Assert.IsTrue(initialAgvCount > 0, "첫 번째 메시지에 AGV 항목이 없습니다."); // 첫 번째 콜백에서 수신된 업데이트 항목 수 기록 int firstUpdatedCount = handler.LastUpdatedCount; Assert.IsTrue(firstUpdatedCount > 0, "첫 번째 메시지에 업데이트된 데이터가 없습니다."); // 다음 데이터 세트가 도착하기를 기다림 await UniTask.Delay(3000); // 두 번째 메시지가 도착했는지 확인 int finalCallCount = handler.CallCount; Assert.IsTrue(finalCallCount > initialCallCount, "두 번째 메시지 세트가 수신되지 않았습니다."); // Assert // 기본 검사: 모든 호출에서 업데이트된 데이터만 전송되었는지 확인 foreach (int updatedCount in handler.UpdatedCounts) { Assert.IsTrue(updatedCount > 0, "업데이트된 데이터가 없는 콜백이 있습니다."); } // 두 번째 메시지에서 처음 도착한 AGV 항목과 추가/변경된 항목이 있는지 확인 // MockMQTTService는 매번 다른 데이터 세트를 보내므로 이런 차이가 있어야 함 bool hasChanges = handler.ReceivedAgvItems.Count > initialAgvCount || handler.HasUpdatedExistingItems; Assert.IsTrue(hasChanges, "두 번째 메시지에서 변경된 데이터가 감지되지 않았습니다."); // 정리 pipeline.Stop(); pipeline.Dispose(); } // UpdatedDataOnly 테스트를 위한 특수 핸들러 public class UpdatedDataTrackingHandler { public int CallCount { get; private set; } = 0; public List UpdatedCounts { get; private set; } = new List(); public int LastUpdatedCount { get; private set; } = 0; public HashSet ReceivedAgvItems { get; private set; } = new HashSet(); public bool HasUpdatedExistingItems { get; private set; } = false; public void HandleData(IDataObject? dataObject) { CallCount++; ULog.Debug($"UpdatedDataTrackingHandler 호출됨. CallCount: {CallCount}"); if (dataObject != null) { // 업데이트 개수 기록 LastUpdatedCount = dataObject.UpdatedCount; UpdatedCounts.Add(dataObject.UpdatedCount); // AGV 데이터 분석 if (dataObject is DataArray agvData) { foreach (var item in agvData) { if (item is DataObject obj && obj.Name == "AGV" && obj["VHL_NAME"] != null) { string vhlName = obj["VHL_NAME"].ToString(); // 이미 존재하는 항목이 업데이트된 경우 if (ReceivedAgvItems.Contains(vhlName)) { HasUpdatedExistingItems = true; } // 새로운 항목 추가 ReceivedAgvItems.Add(vhlName); } } } } } } } // MQTTPipeLine의 OnTopicMessage 메서드를 테스트하기 위한 확장 클래스 public class TestMQTTPipeLine : MQTTPipeLine { public TestMQTTPipeLine() : base("localhost", 1883) { UseMockup = true; } public void TestOnTopicMessage(string topic, string message) { // private 메서드에 접근하기 위한 래퍼 typeof(MQTTPipeLine).GetMethod("OnTopicMessage", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.Invoke(this, new object[] { topic, message }); } } // 테스트용 데이터 핸들러 클래스 public class TestDataHandler { public int CallCount { get; private set; } = 0; public IDataObject? LastDataObject { get; private set; } = null; public void HandleData(IDataObject? dataObject) { CallCount++; LastDataObject = dataObject; } public void Reset() { CallCount = 0; LastDataObject = null; } } }