diff --git a/Assets/Resources/Prefabs/SampleProject/UI/InfoWindow.prefab b/Assets/Resources/Prefabs/SampleProject/UI/InfoWindow.prefab index 319795c7..cd5c3e12 100644 --- a/Assets/Resources/Prefabs/SampleProject/UI/InfoWindow.prefab +++ b/Assets/Resources/Prefabs/SampleProject/UI/InfoWindow.prefab @@ -133,142 +133,6 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 2480dc463c63bf945a9488183ffe66d0, type: 3} m_Name: m_EditorClassIdentifier: ---- !u!1 &695708929815469789 -GameObject: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - serializedVersion: 6 - m_Component: - - component: {fileID: 7457226219413938759} - - component: {fileID: 4416386235926923171} - - component: {fileID: 7184852704645535964} - m_Layer: 5 - m_Name: ValueText - m_TagString: Untagged - m_Icon: {fileID: 0} - m_NavMeshLayer: 0 - m_StaticEditorFlags: 0 - m_IsActive: 1 ---- !u!224 &7457226219413938759 -RectTransform: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 695708929815469789} - m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: 0, y: 0, z: 0} - m_LocalScale: {x: 1, y: 1, z: 1} - m_ConstrainProportionsScale: 0 - m_Children: [] - m_Father: {fileID: 4802890858156259540} - m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} - m_AnchorMin: {x: 0, y: 0} - m_AnchorMax: {x: 1, y: 1} - m_AnchoredPosition: {x: 45, y: -7.5} - m_SizeDelta: {x: -100, y: -25} - m_Pivot: {x: 0.5, y: 0.5} ---- !u!222 &4416386235926923171 -CanvasRenderer: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 695708929815469789} - m_CullTransparentMesh: 1 ---- !u!114 &7184852704645535964 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 695708929815469789} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3} - m_Name: - m_EditorClassIdentifier: - m_Material: {fileID: 0} - m_Color: {r: 1, g: 1, b: 1, a: 1} - m_RaycastTarget: 1 - m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} - m_Maskable: 1 - m_OnCullStateChanged: - m_PersistentCalls: - m_Calls: [] - m_text: New Text - m_isRightToLeft: 0 - m_fontAsset: {fileID: 11400000, guid: 08cebd004d97ca742ac80400f37f4eed, type: 2} - m_sharedMaterial: {fileID: 4860575619018115804, guid: 08cebd004d97ca742ac80400f37f4eed, type: 2} - m_fontSharedMaterials: [] - m_fontMaterial: {fileID: 0} - m_fontMaterials: [] - m_fontColor32: - serializedVersion: 2 - rgba: 4278190080 - m_fontColor: {r: 0, g: 0, b: 0, a: 1} - m_enableVertexGradient: 0 - m_colorMode: 3 - m_fontColorGradient: - topLeft: {r: 1, g: 1, b: 1, a: 1} - topRight: {r: 1, g: 1, b: 1, a: 1} - bottomLeft: {r: 1, g: 1, b: 1, a: 1} - bottomRight: {r: 1, g: 1, b: 1, a: 1} - m_fontColorGradientPreset: {fileID: 0} - m_spriteAsset: {fileID: 0} - m_tintAllSprites: 0 - m_StyleSheet: {fileID: 0} - m_TextStyleHashCode: -1183493901 - m_overrideHtmlColors: 0 - m_faceColor: - serializedVersion: 2 - rgba: 4294967295 - m_fontSize: 10 - m_fontSizeBase: 10 - m_fontWeight: 400 - m_enableAutoSizing: 0 - m_fontSizeMin: 18 - m_fontSizeMax: 72 - m_fontStyle: 0 - m_HorizontalAlignment: 1 - m_VerticalAlignment: 256 - m_textAlignment: 65535 - m_characterSpacing: 0 - m_wordSpacing: 0 - m_lineSpacing: 0 - m_lineSpacingMax: 0 - m_paragraphSpacing: 0 - m_charWidthMaxAdj: 0 - m_TextWrappingMode: 1 - m_wordWrappingRatios: 0.4 - m_overflowMode: 4 - m_linkedTextComponent: {fileID: 0} - parentLinkedComponent: {fileID: 0} - m_enableKerning: 0 - m_ActiveFontFeatures: 6e72656b - m_enableExtraPadding: 0 - checkPaddingRequired: 0 - m_isRichText: 1 - m_EmojiFallbackSupport: 1 - m_parseCtrlCharacters: 1 - m_isOrthographic: 1 - m_isCullingEnabled: 0 - m_horizontalMapping: 0 - m_verticalMapping: 0 - m_uvLineOffset: 0 - m_geometrySortingOrder: 0 - m_IsTextObjectScaleStatic: 0 - m_VertexBufferAutoSizeReduction: 0 - m_useMaxVisibleDescender: 1 - m_pageToDisplay: 1 - m_margin: {x: 0, y: 0, z: 0, w: 0} - m_isUsingLegacyAnimationComponent: 0 - m_isVolumetricText: 0 - m_hasFontAssetChanged: 0 - m_baseMaterial: {fileID: 0} - m_maskOffset: {x: 0, y: 0, z: 0, w: 0} --- !u!1 &1267781917027001920 GameObject: m_ObjectHideFlags: 0 @@ -453,7 +317,6 @@ RectTransform: - {fileID: 2993317773174661490} - {fileID: 6308980257678838100} - {fileID: 6685372230643413407} - - {fileID: 7457226219413938759} m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0.5, y: 0.5} @@ -481,10 +344,10 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 620e52b408949c340adef1110323cb7c, type: 3} m_Name: m_EditorClassIdentifier: - labelText: {fileID: 4202672738064507978} - valueText: {fileID: 7184852704645535964} + text: {fileID: 4202672738064507978} closeButton: {fileID: 5925304901667948221} - screenOffset: {x: 0, y: 0} + screenOffset: {x: 10, y: 10} + menuBarHeight: 70 --- !u!1 &7346391167643616437 GameObject: m_ObjectHideFlags: 0 @@ -497,7 +360,7 @@ GameObject: - component: {fileID: 9166711732528497215} - component: {fileID: 4202672738064507978} m_Layer: 5 - m_Name: 'LabelText ' + m_Name: 'text ' m_TagString: Untagged m_Icon: {fileID: 0} m_NavMeshLayer: 0 @@ -519,8 +382,8 @@ RectTransform: m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} - m_AnchoredPosition: {x: -52.5, y: -7.5} - m_SizeDelta: {x: -115, y: -25} + m_AnchoredPosition: {x: 0, y: -5} + m_SizeDelta: {x: -20, y: -30} m_Pivot: {x: 0.5, y: 0.5} --- !u!222 &9166711732528497215 CanvasRenderer: diff --git a/Assets/Scenes/SampleProject.unity b/Assets/Scenes/SampleProject.unity index af0c41de..2893f250 100644 --- a/Assets/Scenes/SampleProject.unity +++ b/Assets/Scenes/SampleProject.unity @@ -438,6 +438,7 @@ MonoBehaviour: panSpeed: 20 rotationSpeed: 300 zoomSpeed: 10 + maxPanDelta: 50 --- !u!1 &410087039 GameObject: m_ObjectHideFlags: 0 @@ -1137,6 +1138,10 @@ PrefabInstance: propertyPath: m_Name value: Toolbox objectReference: {fileID: 0} + - target: {fileID: 8263263242933281924, guid: 43766caa723360d4c97983845e6749fe, type: 3} + propertyPath: m_Color.a + value: 1 + objectReference: {fileID: 0} - target: {fileID: 8849628700159893901, guid: 43766caa723360d4c97983845e6749fe, type: 3} propertyPath: m_Pivot.x value: 0 @@ -1845,6 +1850,10 @@ PrefabInstance: serializedVersion: 3 m_TransformParent: {fileID: 2030316712} m_Modifications: + - target: {fileID: 3914245719037572856, guid: 036e56b4c097fc8409ffced10ff53562, type: 3} + propertyPath: menuBarHeight + value: 70 + objectReference: {fileID: 0} - target: {fileID: 3914245719037572856, guid: 036e56b4c097fc8409ffced10ff53562, type: 3} propertyPath: worldOffset.x value: 60 @@ -1855,35 +1864,35 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 3914245719037572856, guid: 036e56b4c097fc8409ffced10ff53562, type: 3} propertyPath: screenOffset.x - value: 0 + value: 10 objectReference: {fileID: 0} - target: {fileID: 3914245719037572856, guid: 036e56b4c097fc8409ffced10ff53562, type: 3} propertyPath: screenOffset.y - value: 0 + value: 10 objectReference: {fileID: 0} - target: {fileID: 4802890858156259540, guid: 036e56b4c097fc8409ffced10ff53562, type: 3} propertyPath: m_Pivot.x - value: 0.5 + value: 0 objectReference: {fileID: 0} - target: {fileID: 4802890858156259540, guid: 036e56b4c097fc8409ffced10ff53562, type: 3} propertyPath: m_Pivot.y - value: 0.5 + value: 1 objectReference: {fileID: 0} - target: {fileID: 4802890858156259540, guid: 036e56b4c097fc8409ffced10ff53562, type: 3} propertyPath: m_AnchorMax.x - value: 0.5 + value: 0 objectReference: {fileID: 0} - target: {fileID: 4802890858156259540, guid: 036e56b4c097fc8409ffced10ff53562, type: 3} propertyPath: m_AnchorMax.y - value: 0.5 + value: 1 objectReference: {fileID: 0} - target: {fileID: 4802890858156259540, guid: 036e56b4c097fc8409ffced10ff53562, type: 3} propertyPath: m_AnchorMin.x - value: 0.5 + value: 0 objectReference: {fileID: 0} - target: {fileID: 4802890858156259540, guid: 036e56b4c097fc8409ffced10ff53562, type: 3} propertyPath: m_AnchorMin.y - value: 0.5 + value: 1 objectReference: {fileID: 0} - target: {fileID: 4802890858156259540, guid: 036e56b4c097fc8409ffced10ff53562, type: 3} propertyPath: m_SizeDelta.x diff --git a/Assets/Scripts/SampleProject/AppMain.cs b/Assets/Scripts/SampleProject/AppMain.cs index 5bd7fb0d..4edc12a5 100644 --- a/Assets/Scripts/SampleProject/AppMain.cs +++ b/Assets/Scripts/SampleProject/AppMain.cs @@ -3,6 +3,7 @@ using System; using UnityEngine; using UVC.Core; using UVC.Data; +using UVC.Tests; namespace SampleProject { diff --git a/Assets/Scripts/UVC/Data/DataObject.cs b/Assets/Scripts/UVC/Data/DataObject.cs index d262f9fd..c74e572d 100644 --- a/Assets/Scripts/UVC/Data/DataObject.cs +++ b/Assets/Scripts/UVC/Data/DataObject.cs @@ -314,6 +314,17 @@ namespace UVC.Data return defaultValue; } + /// + /// 지정된 문자열을 배정밀도 부동 소수점 숫자로 변환합니다. + /// + /// 변환할 숫자의 문자열 표현입니다. + /// 입력 문자열에서 구문 분석된 배정밀도 부동 소수점 숫자 또는 변환이 실패하면 0.0을 반환합니다. + /// + public double GetLong(string v) + { + return GetDouble(v, 0.0); + } + /// /// 지정된 속성의 값을 DateTime으로 변환하여 반환합니다. /// @@ -511,7 +522,6 @@ namespace UVC.Data { return string.Join(", ", this.Select(kvp => $"{kvp.Key}:{kvp.Value}")); } - } diff --git a/Assets/Scripts/UVC/Data/DataRepository.cs b/Assets/Scripts/UVC/Data/DataRepository.cs index 37aa5912..6609ef66 100644 --- a/Assets/Scripts/UVC/Data/DataRepository.cs +++ b/Assets/Scripts/UVC/Data/DataRepository.cs @@ -1,4 +1,5 @@ -using Newtonsoft.Json; +using Cysharp.Threading.Tasks; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; @@ -78,7 +79,7 @@ namespace UVC.Data obj.UpdateDifferent(dataObject); var newDataObject = dataObject; if (updatedDataOnly) newDataObject = obj.GetUpdatedObject(); - NotifyDataUpdate(key, newDataObject); + UniTask.Post(() => NotifyDataUpdate(key, newDataObject)); return newDataObject; } } diff --git a/Assets/Scripts/UVC/Data/DataValidator.cs b/Assets/Scripts/UVC/Data/DataValidator.cs index f4436d7b..5bcbb4fb 100644 --- a/Assets/Scripts/UVC/Data/DataValidator.cs +++ b/Assets/Scripts/UVC/Data/DataValidator.cs @@ -41,9 +41,9 @@ namespace UVC.Data { /// /// 유효성 검사 규칙의 목록입니다. - /// 각 사전은 속성 이름(string)과 해당 속성의 검증 함수(Func<object, bool>)를 포함합니다. + /// 각 사전은 속성 이름(string)과 해당 속성의 검증 함수(Func)를 포함합니다. /// - private List>> validaters = new List>>(); + private List>> validaters = new List>>(); /// /// 대용량 JSON 데이터를 스트리밍 방식으로 처리할지 여부를 나타내는 속성입니다. @@ -91,10 +91,10 @@ namespace UVC.Data /// }); /// /// - public void AddValidator(string propertyName, Func validator) + public void AddValidator(string propertyName, Func validator) { // 유효성 검사기를 추가합니다. - validaters.Add(new Dictionary> { { propertyName, validator } }); + validaters.Add(new Dictionary> { { propertyName, validator } }); } /// @@ -207,7 +207,7 @@ namespace UVC.Data continue; // 속성이 없으면 검증을 건너뜀 } // 속성 값에 대한 검증을 수행 - if (!kvp.Value(data[kvp.Key])) + if (!kvp.Value(ConvertJTokenToObject(data[kvp.Key]))) { // 검증에 실패하면 false를 반환 return false; @@ -354,5 +354,32 @@ namespace UVC.Data return GetValidData(sourceArray); } } + + private object? ConvertJTokenToObject(JToken? token) + { + if(token == null || token.Type == JTokenType.Null) + { + return null; // null 처리 + } + switch (token.Type) + { + case JTokenType.String: + return token.ToString(); + case JTokenType.Integer: + return token.ToObject(); + case JTokenType.Float: + return token.ToObject(); + case JTokenType.Boolean: + return token.ToObject(); + //case JTokenType.Object: + // return new DataObject((JObject)token); + //case JTokenType.Array: + // JArray array = (JArray)token; + // return new DataArray(array); + default: + return token.ToString(); + } + } + } } diff --git a/Assets/Scripts/UVC/Data/HttpPipeLine.cs b/Assets/Scripts/UVC/Data/HttpPipeLine.cs index 2701ce84..618ae884 100644 --- a/Assets/Scripts/UVC/Data/HttpPipeLine.cs +++ b/Assets/Scripts/UVC/Data/HttpPipeLine.cs @@ -158,7 +158,7 @@ namespace UVC.Data /// 모든 HTTP 요청 처리는 백그라운드 스레드에서 수행되며, 핸들러만 메인 스레드에서 호출됩니다. /// /// 지정된 키가 등록되어 있지 않은 경우 - public async UniTask Excute(string key, bool switchToMainThread = false) + public async UniTask Excute(string key) { if (!infoList.ContainsKey(key)) { @@ -179,6 +179,7 @@ namespace UVC.Data { // 단일 실행 로직 호출 await ExecuteSingle(key, info); + await UniTask.SwitchToMainThread(); } else { @@ -187,15 +188,11 @@ namespace UVC.Data StartRepeat(key).Forget(); } } - finally + catch (Exception ex) { - // ExecuteSingle 또는 StartRepeat.Forget() 후 메인 스레드로 복귀하는 것은 선택 사항 - // 여기서는 원래 실행 컨텍스트로 돌아가기 위해 메인 스레드로 전환 - // 만약 계속해서 백그라운드 스레드에서 실행하려면 이 코드를 제거할 수 있음 - if (switchToMainThread) - { - await UniTask.SwitchToMainThread(); - } + // 예외가 발생한 경우에도 메인 스레드로 복귀 + await UniTask.SwitchToMainThread(); + throw; // 예외 재발생 } } @@ -236,13 +233,13 @@ namespace UVC.Data try { string result = string.Empty; - if (!UseMockup) + if (UseMockup) { - result = await HttpRequester.Request(info.Url, info.Method, info.Body, info.Headers); + result = await MockHttpRequester.Request(info.Url, info.Method, info.Body, info.Headers); } else { - result = await MockHttpRequester.Request(info.Url, info.Method, info.Body, info.Headers); + result = await HttpRequester.Request(info.Url, info.Method, info.Body, info.Headers); } // 응답 처리 전에 다시 취소 요청 확인 @@ -267,10 +264,8 @@ namespace UVC.Data string errorMessage = responseResult.Message!; // UI 스레드에서 실패 핸들러 호출 - MainThreadDispatcher.Instance.SendToMainThread(() => - { - info.FailHandler.Invoke(errorMessage); - }); + await UniTask.SwitchToMainThread(); + info.FailHandler.Invoke(errorMessage); } return; } @@ -293,10 +288,8 @@ namespace UVC.Data if (info.FailHandler != null) { // UI 스레드에서 실패 핸들러 호출 - MainThreadDispatcher.Instance.SendToMainThread(() => - { - info.FailHandler.Invoke("Data is not Valid"); - }); + await UniTask.SwitchToMainThread(); + info.FailHandler.Invoke("Data is not Valid"); } return; } @@ -310,10 +303,8 @@ namespace UVC.Data if (info.FailHandler != null) { // UI 스레드에서 실패 핸들러 호출 - MainThreadDispatcher.Instance.SendToMainThread(() => - { - info.FailHandler.Invoke("Data is not Valid"); - }); + await UniTask.SwitchToMainThread(); + info.FailHandler.Invoke("Data is not Valid"); } return; } @@ -350,10 +341,8 @@ namespace UVC.Data if (info.FailHandler != null) { // UI 스레드에서 실패 핸들러 호출 - MainThreadDispatcher.Instance.SendToMainThread(() => - { - info.FailHandler.Invoke("Data is not Valid"); - }); + await UniTask.SwitchToMainThread(); + info.FailHandler.Invoke("Data is not Valid"); } return; } @@ -368,10 +357,8 @@ namespace UVC.Data if (info.FailHandler != null) { // UI 스레드에서 실패 핸들러 호출 - MainThreadDispatcher.Instance.SendToMainThread(() => - { - info.FailHandler.Invoke("Data is not Valid"); - }); + await UniTask.SwitchToMainThread(); + info.FailHandler.Invoke("Data is not Valid"); } return; } @@ -441,15 +428,14 @@ namespace UVC.Data // 로컬 변수로 복사하여 클로저에서 안전하게 사용 var handlerData = dataObject; // UI 스레드에서 성공 핸들러 호출 - MainThreadDispatcher.Instance.SendToMainThread(() => - { - info.SuccessHandler.Invoke(handlerData); - }); + await UniTask.SwitchToMainThread(); + info.SuccessHandler.Invoke(handlerData); } return; } } + if (dataObject != null) { if (info.SuccessHandler != null) @@ -457,10 +443,8 @@ namespace UVC.Data // 로컬 변수로 복사하여 클로저에서 안전하게 사용 var handlerData = dataObject; // UI 스레드에서 성공 핸들러 호출 - MainThreadDispatcher.Instance.SendToMainThread(() => - { - info.SuccessHandler.Invoke(handlerData); - }); + await UniTask.SwitchToMainThread(); + info.SuccessHandler.Invoke(handlerData); } } else @@ -468,10 +452,8 @@ namespace UVC.Data if (info.FailHandler != null) { // UI 스레드에서 실패 핸들러 호출 - MainThreadDispatcher.Instance.SendToMainThread(() => - { - info.FailHandler.Invoke("Data is Null"); - }); + await UniTask.SwitchToMainThread(); + info.FailHandler.Invoke("Data is Null"); } } @@ -531,7 +513,6 @@ namespace UVC.Data if (!infoList.ContainsKey(key)) { throw new KeyNotFoundException($"No HTTP request found with key '{key}'."); - return; } HttpPipeLineInfo info = infoList[key]; @@ -619,7 +600,7 @@ namespace UVC.Data /// public async UniTask StopRepeat(string key) { - CancellationTokenSource cts = null; + CancellationTokenSource? cts = null; lock (repeatTokenSources) // 스레드 안전성 확보 { if (repeatTokenSources.TryGetValue(key, out cts) && !cts.IsCancellationRequested) diff --git a/Assets/Scripts/UVC/Data/MQTTPipeLine.cs b/Assets/Scripts/UVC/Data/MQTTPipeLine.cs index c3754c77..ef03c6f7 100644 --- a/Assets/Scripts/UVC/Data/MQTTPipeLine.cs +++ b/Assets/Scripts/UVC/Data/MQTTPipeLine.cs @@ -1,9 +1,11 @@ #nullable enable +using Cysharp.Threading.Tasks; using Newtonsoft.Json.Linq; using SampleProject.Config; using System; using System.Collections.Generic; +using UnityEngine; using UVC.Log; using UVC.network; using UVC.Tests; @@ -189,69 +191,74 @@ namespace UVC.Data /// 이 메서드는 수신된 메시지의 형식(JSON 객체 또는 배열)에 따라 적절한 파싱을 수행하고, /// 등록된 데이터 매퍼를 통해 메시지를 변환한 후, 해당 토픽에 등록된 핸들러에게 전달합니다. /// 'UpdatedDataOnly' 설정에 따라 데이터가 변경된 경우에만 핸들러를 호출할 수도 있습니다. + /// 메시지 처리는 백그라운드 스레드에서 수행되며, 핸들러는 메인 스레드에서 호출됩니다. /// private void OnTopicMessage(string topic, string message) { - if (infoList.ContainsKey(topic)) + // 메시지 처리를 백그라운드 스레드에서 실행하여 메인 스레드 부하를 줄입니다. + UniTask.RunOnThreadPool(() => { - MQTTPipeLineInfo info = infoList[topic]; - IDataObject? dataObject = null; - - message = message.Trim(); - if (!string.IsNullOrEmpty(message)) + if (infoList.ContainsKey(topic)) { - try + MQTTPipeLineInfo info = infoList[topic]; + IDataObject? dataObject = null; + + message = message.Trim(); + if (!string.IsNullOrEmpty(message)) { - if (message.StartsWith("{")) + try { - JObject source = JObject.Parse(message); - if (info.Validator != null && !info.Validator.IsValid(source)) + if (message.StartsWith("{")) { - return; // 유효성 검사 실패 시 핸들러 호출을 중단 - } - if (info.DataMapper != null) dataObject = info.DataMapper.Map(source); - } - else if (message.StartsWith("[")) - { - JArray source = JArray.Parse(message); - if (info.Validator != null) - { - JArray? validSource = info.Validator.GetValidData(source); - if (validSource != null && validSource.Count > 0) - { - // 유효한 데이터가 있는 경우에만 매핑 - if (info.DataMapper != null) dataObject = info.DataMapper.Map(validSource); - } - else + JObject source = JObject.Parse(message); + if (info.Validator != null && !info.Validator.IsValid(source)) { return; // 유효성 검사 실패 시 핸들러 호출을 중단 } - } - else - { if (info.DataMapper != null) dataObject = info.DataMapper.Map(source); } - } + else if (message.StartsWith("[")) + { + JArray source = JArray.Parse(message); + if (info.Validator != null) + { + JArray? validSource = info.Validator.GetValidData(source); + if (validSource != null && validSource.Count > 0) + { + // 유효한 데이터가 있는 경우에만 매핑 + if (info.DataMapper != null) dataObject = info.DataMapper.Map(validSource); + } + else + { + return; // 유효성 검사 실패 시 핸들러 호출을 중단 + } + } + else + { + if (info.DataMapper != null) dataObject = info.DataMapper.Map(source); + } + } - if (dataObject != null) dataObject = DataRepository.Instance.AddData(topic, dataObject, info.UpdatedDataOnly); + if (dataObject != null) dataObject = DataRepository.Instance.AddData(topic, dataObject, info.UpdatedDataOnly); - // 갱신 된 데이터가 있는 경우 핸들러 호출 - if (info.UpdatedDataOnly) - { - if (dataObject != null && dataObject.UpdatedCount > 0) info.Handler?.Invoke(dataObject); + // 핸들러 호출이 필요한지 확인 + bool shouldInvoke = !info.UpdatedDataOnly || (dataObject != null && dataObject.UpdatedCount > 0); + + if (shouldInvoke) + { + // 핸들러를 메인 스레드에서 안전하게 호출 + UniTask.Post(() => info.Handler?.Invoke(dataObject)); + } } - else + catch (Exception ex) { - info.Handler?.Invoke(dataObject); + // 예외 발생 시 로깅 또는 처리 + // 예외 로깅도 메인 스레드에서 처리하여 Unity API 호출에 대한 스레드 안정성 확보 + UniTask.Post(() => ULog.Error($"Error processing message for topic '{topic}': {ex.Message}", ex)); } } - catch (Exception ex) - { - // 예외 발생 시 로깅 또는 처리 - ULog.Error($"Error processing message for topic '{topic}': {ex.Message}", ex); - } } - } + }).Forget(); } /// diff --git a/Assets/Scripts/UVC/Factory/Component/AGV.cs b/Assets/Scripts/UVC/Factory/Component/AGV.cs index 3d0f36b2..1e2b5492 100644 --- a/Assets/Scripts/UVC/Factory/Component/AGV.cs +++ b/Assets/Scripts/UVC/Factory/Component/AGV.cs @@ -1,21 +1,31 @@ using System.Collections.Generic; -using TMPro; using UnityEngine; -using UnityEngine.EventSystems; using UVC.Data; namespace UVC.Factory.Component { + /// + /// 씬에 표시되는 개별 AGV(무인 운반차)를 제어하는 클래스입니다. + /// FactoryObject를 상속받아, AGV의 데이터 처리, 3D 모델의 이동 및 회전, 정보 표시 기능을 구현합니다. + /// + /// + /// 이 클래스는 AGVManager에 의해 동적으로 생성되고 관리됩니다. + /// AGVManager로부터 실시간 데이터를 받아 ProcessData 메서드에서 처리하고, + /// Unity의 Update 메서드에서 매 프레임마다 부드러운 시각적 이동을 구현합니다. + /// public class AGV : FactoryObject { + // 서버에서 받은 좌표(예: 밀리미터 단위)를 Unity 씬의 단위(미터)로 변환하기 위한 스케일 값입니다. + // 예를 들어, 서버 좌표 1000이 Unity에서 1미터가 되려면 0.001f로 설정합니다. private float scaleFactor = 0.0005f; // Unity에서 사용하는 단위로 변환하기 위한 스케일 팩터 - // 목표 위치와 회전 값 + // 데이터로부터 수신한 AGV의 목표 위치와 목표 회전값입니다. + // AGV는 현재 위치에서 이 목표 지점을 향해 부드럽게 움직입니다. private Vector3 targetPosition; private Quaternion targetRotation; - // 움직임과 회전의 부드러움을 조절할 속도 변수 - // Unity 인스펙터 창에서 실시간으로 값을 조절하며 테스트할 수 있습니다. + // 움직임과 회전의 부드러움을 조절할 속도 변수입니다. + // Unity 인스펙터 창에서 실시간으로 값을 조절하며 최적의 움직임을 찾을 수 있습니다. [Tooltip("목표 지점까지의 이동 속도를 조절합니다.")] public float moveSpeed = 1.0f; @@ -25,11 +35,16 @@ namespace UVC.Factory.Component [Tooltip("이 거리(미터)를 초과하면 보간 없이 즉시 위치를 변경합니다.")] public float teleportDistanceThreshold = 5.0f; // 5미터 이상 차이나면 순간이동 + /// + /// AGV 객체가 생성될 때 처음 한 번 호출되는 초기화 메서드입니다. + /// private void Start() { + // 시작 시에는 현재 위치를 목표 위치로 설정하여 의도치 않은 움직임을 방지합니다. targetPosition = transform.position; targetRotation = transform.rotation; + // 사용자가 AGV를 클릭했을 때 정보창에 표시될 데이터 항목과 순서를 정의합니다. DataOrderedMask = new List { "VHL_NAME", @@ -43,26 +58,32 @@ namespace UVC.Factory.Component } /// - /// 내부 상태를 업데이트하고 정렬된 마스크에 정의된 특정 키에 대한 작업을 수행하여 제공된 데이터 객체를 처리합니다. - // - /// 이 메서드는 초기화되지 않은 경우 내부 데이터 상태를 업데이트하고, 제공된 - /// 에 정의된 키를 반복하여 제공된 - /// 객체의 일치하는 항목에 대한 작업을 수행합니다. 에 처리에 필요한 키가 포함되어 있는지 - /// 확인합니다. - /// 처리할 데이터 객체입니다. null이 아니어야 하며 정렬된 마스크와 관련된 키-값 쌍을 포함해야 합니다. + /// AGVManager로부터 새로운 데이터를 받았을 때 호출되는 핵심 메서드입니다. + /// 받은 데이터를 기반으로 AGV의 내부 상태와 목표 위치를 갱신합니다. + /// + /// + /// 이 메서드는 FactoryObject의 추상 메서드를 재정의한 것입니다. + /// AGV의 위치, 회전, 상태 등 모든 동적인 정보는 이 메서드를 통해 업데이트됩니다. + /// + /// AGV의 최신 정보가 담긴 데이터 객체입니다. protected override void ProcessData(DataObject newData) { + // 처음 데이터를 받는 경우 (data가 null일 때) if (data == null) { + // 새 데이터로 위치와 회전을 즉시 설정합니다. UpdatePositionAndRotation(newData); + // 받은 데이터를 내부 데이터 저장소에 저장합니다. data = newData; // 시작 시에는 현재 위치를 목표 위치로 설정하여 움직이지 않도록 합니다. targetPosition = transform.position; targetRotation = transform.rotation; } - else + else // 이미 데이터가 있는 경우 (업데이트) { + // 새 데이터를 기반으로 목표 위치와 회전을 갱신합니다. UpdatePositionAndRotation(newData); + // 기존 데이터(data)에 새로운 데이터(newData)의 내용을 덮어씁니다. foreach (var keyValue in newData) { if (data.ContainsKey(keyValue.Key)) @@ -73,8 +94,13 @@ namespace UVC.Factory.Component } } + /// + /// 데이터 객체로부터 위치(X, Y) 및 각도(DEGREE) 값을 읽어와 AGV의 목표 위치와 회전을 설정합니다. + /// + /// 위치와 각도 정보가 포함된 데이터 객체입니다. private void UpdatePositionAndRotation(DataObject newData) { + // 처음 데이터를 받는 경우, 받은 데이터로 즉시 위치를 설정합니다. if (data == null) { float x = newData.GetFloat("X") * scaleFactor; @@ -83,11 +109,13 @@ namespace UVC.Factory.Component transform.position = new Vector3(x, 0, y); transform.rotation = rotation; } - else + else // 이후 업데이트의 경우 { + // 새 데이터로부터 목표 위치와 회전값을 계산합니다. float x = data.GetFloat("X") * scaleFactor; float y = data.GetFloat("Y") * scaleFactor; Quaternion rotation = Quaternion.Euler(0, data.GetFloat("DEGREE"), 0); + float newX = newData.GetFloat("X") * scaleFactor; float newY = newData.GetFloat("Y") * scaleFactor; Quaternion newRotation = Quaternion.Euler(0, newData.GetFloat("DEGREE"), 0); @@ -116,10 +144,16 @@ namespace UVC.Factory.Component } } + /// + /// Unity에 의해 매 프레임마다 호출되는 메서드입니다. + /// AGV의 현재 위치/회전을 목표 위치/회전으로 부드럽게 이동시키는 시각적 처리를 담당합니다. + /// void Update() { + // 현재 위치가 목표 위치와 다를 경우에만 이동 로직을 실행합니다. if (transform.position != targetPosition) { + // 목표 지점과의 거리가 매우 가까우면 (0.01미터 미만) 그냥 목표 위치로 설정하여 미세한 떨림을 방지합니다. if (Vector3.Distance(transform.position, targetPosition) < 0.1f) { // 현재 위치와 목표 위치 사이의 거리가 임계값을 초과하면 순간이동합니다. @@ -127,20 +161,23 @@ namespace UVC.Factory.Component } else { - // 매 프레임마다 현재 위치에서 목표 위치로 부드럽게 이동시킵니다. + // Vector3.Lerp를 사용하여 현재 위치에서 목표 위치로 부드럽게 이동시킵니다. + // Time.deltaTime * moveSpeed는 프레임 속도에 관계없이 일정한 속도를 보장합니다. transform.position = Vector3.Lerp(transform.position, targetPosition, Time.deltaTime * moveSpeed); } } + // 현재 회전이 목표 회전과 다를 경우에만 회전 로직을 실행합니다. if (transform.rotation != targetRotation) { + // 목표 회전과의 각도 차이가 매우 작으면 (0.1도 미만) 그냥 목표 회전으로 설정합니다. if (Quaternion.Angle(transform.rotation, targetRotation) < 0.1f) { transform.rotation = targetRotation; } else { - // 매 프레임마다 현재 회전에서 목표 회전으로 부드럽게 회전시킵니다. + // Quaternion.Slerp를 사용하여 현재 회전에서 목표 회전으로 부드럽게 회전시킵니다. transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, Time.deltaTime * rotationSpeed); } } diff --git a/Assets/Scripts/UVC/Factory/Component/AGVManager.cs b/Assets/Scripts/UVC/Factory/Component/AGVManager.cs index e4074b26..9ff80ae5 100644 --- a/Assets/Scripts/UVC/Factory/Component/AGVManager.cs +++ b/Assets/Scripts/UVC/Factory/Component/AGVManager.cs @@ -10,18 +10,77 @@ using UVC.Data; namespace UVC.Factory.Component { + /// + /// 씬에 존재하는 모든 AGV(Automated Guided Vehicle, 무인 운반차) 객체를 총괄 관리하는 싱글톤 클래스입니다. + /// 외부 데이터 소스(예: MQTT)로부터 AGV의 실시간 데이터를 수신하여, 씬에 AGV를 동적으로 생성, 업데이트, 제거하는 역할을 담당합니다. + /// + /// + /// 이 매니저는 `SingletonScene`을 상속받아 구현되었으므로, `AGVManager.Instance`를 통해 씬의 어디에서든 쉽게 접근할 수 있습니다. + /// 씬이 초기화될 때(`OnSceneInitialized`) MQTT 파이프라인에 연결하여 'AGV' 토픽의 데이터를 구독하기 시작합니다. + /// 수신된 데이터(`DataArray`)를 분석하여 새로 추가된 AGV, 제거된 AGV, 정보가 변경된 AGV를 감지하고 각각에 맞는 처리를 비동기적으로 수행합니다. + /// + /// + /// 이 매니저는 자동으로 작동하므로 다른 스크립트에서 직접 호출할 일은 거의 없습니다. + /// 대신 이 매니저가 어떻게 AGV 객체를 생성하고 데이터를 전달하는지, 그리고 AGV 객체는 그 데이터를 어떻게 처리하는지를 이해하는 것이 중요합니다. + /// + /// 1. **데이터 수신**: 서버로부터 다음과 같은 JSON 데이터 배열을 수신했다고 가정합니다. + /// (실제로는 DataArray 객체로 변환되어 전달됩니다.) + /// ```json + /// [ + /// { "VHL_NAME": "AGV-01", "X": 105, "Y": 210, "DEGREE": 90.0, "VHL_STATE": "RUN" }, + /// { "VHL_NAME": "AGV-02", "X": 150, "Y": 300, "DEGREE": 180.0, "VHL_STATE": "IDLE" } + /// ] + /// ``` + /// + /// 2. **매니저의 동작**: `OnUpdateDataAsync` 메서드가 이 데이터를 받아 처리합니다. + /// - `agvList`에 "AGV-01"이 없으면, `CreateAGV`를 호출하여 프리팹으로부터 새로운 AGV 게임 오브젝트를 생성합니다. + /// - 생성된 AGV 객체의 `UpdateData` 메서드를 호출하여 위치, 각도 등의 데이터를 전달합니다. + /// - 만약 기존에 있던 "AGV-01"의 데이터가 변경된 것이라면(ModifiedList), `agvList`에서 해당 AGV를 찾아 `UpdateData`만 호출합니다. + /// + /// 3. **AGV 객체의 반응**: AGV.cs 에서는 `UpdateData` 메서드를 통해 받은 데이터로 자신의 상태를 갱신합니다. + /// (AGV.cs 파일에 포함될 수 있는 예시 코드) + /// + /// public class AGV : FactoryObject + /// { + /// // ... (moveSpeed, rotationSpeed 등 변수 선언) + /// + /// protected override void ProcessData(DataObject newData) + /// { + /// // AGVManager로부터 전달받은 데이터에 위치(X, Y)나 각도(DEGREE) 정보가 있는지 확인 + /// if (newData.ContainsKey("X") && newData.ContainsKey("Y") && newData.ContainsKey("DEGREE")) + /// { + /// // 데이터로부터 목표 위치와 회전값 계산 + /// float x = newData.GetFloat("X"); + /// float y = newData.GetFloat("Y"); + /// float degree = newData.GetFloat("DEGREE"); + /// + /// // Unity 월드 좌표계에 맞게 변환 (예시: Z를 Y로 사용, 스케일 조정) + /// Vector3 targetPosition = new Vector3(x * 0.01f, 0, y * 0.01f); + /// Quaternion targetRotation = Quaternion.Euler(0, degree, 0); + /// + /// // 부드러운 이동 및 회전을 위해 Lerp 또는 다른 방법을 사용하여 현재 위치/회전을 업데이트 + /// // (이 로직은 보통 Unity의 Update 메서드에서 처리됩니다) + /// this.targetPosition = targetPosition; + /// this.targetRotation = targetRotation; + /// } + /// + /// // 상태(VHL_STATE)에 따라 AGV의 색상이나 애니메이션 변경 + /// if (newData.ContainsKey("VHL_STATE")) + /// { + /// string status = newData.GetString("VHL_STATE"); + /// // ... (상태에 따른 시각적 처리) + /// } + /// } + /// } + /// + /// public class AGVManager : SingletonScene { private readonly string prefabPath = "Prefabs/SampleProject/Factory/AGV"; private GameObject? prefab; - - - private List agvList; - - /// /// AGVManager의 초기화 메서드입니다. /// Awake 메서드에서 호출되며, MonoBehaviour가 생성될 때 한 번만 실행됩니다. @@ -32,6 +91,10 @@ namespace UVC.Factory.Component SceneMain.Instance.Initialized += OnSceneInitialized; } + /// + /// 씬이 완전히 초기화된 후 호출됩니다. + /// AGV 데이터를 수신하기 위한 MQTT 파이프라인을 설정합니다. + /// private void OnSceneInitialized() { //데이터를 어떤 형식으로 받을지 정의합니다. @@ -53,33 +116,50 @@ namespace UVC.Factory.Component dataMask["JOB_ID"] = ""; dataMask["TIMESTAMP"] = DateTime.Now; + // MQTT 파이프라인 정보를 생성합니다. + // 'AGV' 토픽을 구독하고, 받은 데이터는 위에서 정의한 dataMask로 매핑하며, + // 데이터 유효성 검사를 위해 DataValidator를 설정합니다. + // 데이터가 업데이트되면 OnUpdateData 메서드를 호출하여 처리합니다. + + DataValidator validator = new DataValidator(); + validator.AddValidator("JOB_ID", value => value != null); + var pipelineInfo = new MQTTPipeLineInfo("AGV") .setDataMapper(new DataMapper(dataMask)) - .setHandler(OnUpdateData); + .setValidator(validator) + .setHandler(OnUpdateData); - // MQTTPipeLine에 파이프라인 정보를 추가합니다. + // 생성한 파이프라인 정보를 전역 MQTT 파이프라인에 추가합니다. AppMain.Instance.MQTTPipeLine.Add(pipelineInfo); } + /// + /// 데이터 수신 시 호출되는 공개 핸들러입니다. + /// 비동기 처리 메서드인 OnUpdateDataAsync를 호출합니다. + /// + /// 수신된 데이터 객체 (일반적으로 DataArray 형태) public void OnUpdateData(IDataObject? data) { OnUpdateDataAsync(data).Forget(); } /// - /// 기존 데이터와 비교하여 변경된 부분만 전달 됩니다. + /// 수신된 AGV 데이터 배열을 비동기적으로 처리하여 씬에 반영합니다. + /// 추가, 제거, 수정된 AGV 데이터를 각각 구분하여 처리합니다. /// - /// + /// 수신된 데이터 (DataArray) public async UniTask OnUpdateDataAsync(IDataObject? data) { if (data == null) return; DataArray? arr = data as DataArray; if (arr == null || arr.Count == 0) return; + // 데이터 배열에서 추가, 제거, 수정된 항목 리스트를 가져옵니다. var AddedItems = arr.AddedItems; var RemovedItems = arr.RemovedItems; var ModifiedList = arr.ModifiedList; + // 새로 추가된 AGV 처리 foreach (var item in AddedItems.ToList()) { AGV? agv = await CreateAGV(item); @@ -90,6 +170,7 @@ namespace UVC.Factory.Component } } + // 제거된 AGV 처리 foreach (var item in RemovedItems.ToList()) { AGV agv = agvList.Find(x => x.Info != null && x.Info.Name == item.GetString("VHL_NAME")); @@ -100,6 +181,7 @@ namespace UVC.Factory.Component } } + // 정보가 수정된 AGV 처리 foreach (var item in ModifiedList.ToList()) { AGV agv = agvList.Find(x => x.Info != null && x.Info.Name == item.GetString("VHL_NAME")); @@ -111,6 +193,11 @@ namespace UVC.Factory.Component } + /// + /// AGV 프리팹을 사용하여 새로운 AGV 게임 오브젝트를 생성하고 초기화합니다. + /// + /// 신규 AGV의 정보가 담긴 DataObject + /// 생성 및 초기화된 AGV 컴포넌트, 실패 시 null private async UniTask CreateAGV(DataObject data) { if (prefab == null) @@ -129,6 +216,7 @@ namespace UVC.Factory.Component return null; } var agv = prefabInstance.GetComponent(); + // AGV의 고정 정보(Info)를 설정합니다. 이 정보는 FactoryObjectManager 등에서 사용될 수 있습니다. agv.Info = new FactoryObjectInfo( data.GetString("VHL_NAME"), data.GetString("NODE_ID"), @@ -139,6 +227,10 @@ namespace UVC.Factory.Component return agv; } + /// + /// AGVManager가 파괴될 때 호출됩니다. + /// MQTT 파이프라인에서 'AGV' 핸들러를 제거하여 메모리 누수를 방지합니다. + /// protected override void OnDestroy() { base.OnDestroy(); diff --git a/Assets/Scripts/UVC/Factory/Component/FactoryObject.cs b/Assets/Scripts/UVC/Factory/Component/FactoryObject.cs index a43df9cb..30058035 100644 --- a/Assets/Scripts/UVC/Factory/Component/FactoryObject.cs +++ b/Assets/Scripts/UVC/Factory/Component/FactoryObject.cs @@ -9,11 +9,115 @@ using UVC.UI.Info; namespace UVC.Factory.Component { /// - /// + /// 팩토리 내의 상호작용 가능한 3D 객체를 위한 추상 기본 클래스입니다. + /// 이 클래스는 Unity GameObject를 데이터(DataObject)와 연결하고, 사용자 상호작용(예: 클릭)을 처리하며, + /// 데이터 변경에 따라 객체의 상태(예: 색상, 애니메이션)를 업데이트하는 기능을 제공합니다. /// + /// + /// 팩토리 객체는 고유한 정보(Info)와 동적 데이터(data)를 가집니다. + /// 사용자가 객체를 클릭하면 OnPointerClick 이벤트가 발생하여 InfoWindow에 관련 데이터를 표시할 수 있습니다. + /// ProcessData 메서드를 재정의하여 데이터 변경 시 특정 로직을 수행하도록 구현해야 합니다. + /// + /// + /// 다음은 FactoryObject를 상속받아 'MachineObject'라는 구체적인 클래스를 만드는 예제입니다. + /// 이 예제에서는 데이터로 받은 'status' 값에 따라 머신의 색상을 변경합니다. + /// + /// using UnityEngine; + /// using UVC.Data; + /// using UVC.Factory.Component; + /// + /// // FactoryObject를 상속받는 MachineObject 클래스 정의 + /// public class MachineObject : FactoryObject + /// { + /// private Renderer objectRenderer; + /// + /// private void Awake() + /// { + /// // 색상을 변경할 렌더러 컴포넌트를 미리 찾아둡니다. + /// objectRenderer = GetComponent(); + /// } + /// + /// // ProcessData 메서드를 재정의하여 데이터 처리 로직을 구현합니다. + /// protected override void ProcessData(DataObject newData) + /// { + /// // 'status' 키가 데이터에 포함되어 있는지 확인합니다. + /// if (newData.ContainsKey("status")) + /// { + /// // 'status' 값을 문자열로 가져옵니다. + /// string status = newData.GetString("status", "off"); + /// + /// // 상태 값에 따라 머티리얼의 색상을 변경합니다. + /// switch (status) + /// { + /// case "running": + /// objectRenderer.material.color = Color.green; + /// break; + /// case "warning": + /// objectRenderer.material.color = Color.yellow; + /// break; + /// case "error": + /// objectRenderer.material.color = Color.red; + /// break; + /// default: + /// objectRenderer.material.color = Color.gray; + /// break; + /// } + /// } + /// } + /// } + /// + /// // 아래는 MachineObject를 생성하고 데이터를 업데이트하는 예시입니다. + /// public class MachineManager : MonoBehaviour + /// { + /// void Start() + /// { + /// // 1. 게임 오브젝트를 생성하고 MachineObject 컴포넌트를 추가합니다. + /// GameObject machineGo = new GameObject("Drilling Machine"); + /// machineGo.AddComponent(); // 렌더링을 위한 기본 컴포넌트 + /// machineGo.AddComponent(); + /// machineGo.AddComponent(); // 클릭 이벤트를 위한 콜라이더 + /// MachineObject machine = machineGo.AddComponent(); + /// + /// // 2. 객체 정보(Info)를 설정합니다. + /// machine.Info = new FactoryObjectInfo + /// { + /// Id = "MC-001", + /// Name = "Drilling Machine" + /// }; + /// + /// // 3. 초기 데이터를 생성하고 UpdateData를 통해 전달합니다. + /// // 이 시점에 ProcessData가 호출되어 머신 색상이 녹색으로 변경됩니다. + /// var initialData = new DataObject + /// { + /// { "status", "running" }, + /// { "temperature", 85.5f }, + /// { "operator", "Admin" } + /// }; + /// machine.UpdateData(initialData); + /// + /// // 4. 5초 후 데이터 변경을 시뮬레이션합니다. + /// // 상태가 'error'로 변경되면 ProcessData가 다시 호출되어 색상이 빨간색으로 바뀝니다. + /// StartCoroutine(SimulateError(machine)); + /// } + /// + /// private System.Collections.IEnumerator SimulateError(MachineObject machine) + /// { + /// yield return new WaitForSeconds(5); + /// + /// var errorData = new DataObject { { "status", "error" } }; + /// machine.UpdateData(errorData); + /// } + /// } + /// + /// public abstract class FactoryObject : InteractiveObject { protected FactoryObjectInfo? info; + + /// + /// 팩토리 객체의 고유한 식별 정보(ID, 이름 등)를 가져오거나 설정합니다. + /// 이 정보는 객체를 관리하고 UI에 표시하는 데 사용됩니다. + /// public FactoryObjectInfo? Info { get => info; @@ -33,7 +137,8 @@ namespace UVC.Factory.Component protected List? dataOrderedMask; /// - /// InfoWindow에 표시할 데이터의 순서와 항목을 지정하는 마스크입니다. + /// 객체 클릭 시 정보창(InfoWindow)에 표시될 데이터의 순서와 항목을 지정하는 마스크입니다. + /// 이 리스트에 포함된 키의 데이터만 순서대로 표시됩니다. null이거나 비어있으면 모든 데이터를 표시합니다. /// public List? DataOrderedMask { @@ -54,10 +159,11 @@ namespace UVC.Factory.Component /// /// 포인터 클릭 이벤트를 처리하고 관련 데이터가 포함된 정보 창을 표시합니다. /// - /// 이 메서드는 정보 창이 현재 표시되어 있는지, 그리고 - /// 유효한 데이터가 있는지 확인합니다. 데이터가 마스크를 사용하여 정렬된 경우 마스크된 데이터만 표시되고, 그렇지 않은 경우 - /// 사용 가능한 모든 데이터가 표시됩니다. 정보 창은 현재 - /// 변환을 기준으로 배치됩니다. + /// + /// 이 메서드는 `InteractiveObject`로부터 상속받아 재정의되었습니다. + /// 객체에 유효한 데이터가 있을 경우, `InfoWindow`를 통해 사용자에게 데이터를 보여줍니다. + /// `DataOrderedMask`가 설정되어 있으면 해당 순서대로, 그렇지 않으면 모든 데이터를 표시합니다. + /// /// 포인터 클릭과 관련된 이벤트 데이터입니다. public override void OnPointerClick(PointerEventData eventData) { @@ -85,9 +191,11 @@ namespace UVC.Factory.Component } /// - /// 변경된 데이터만 업데이트합니다. + /// 외부로부터 받은 새로운 데이터로 객체의 상태를 업데이트합니다. + /// 이 메서드는 내부적으로 `ProcessData`를 호출하여 실제 데이터 처리 로직을 수행합니다. + /// MQTTPipeLineInfo.updatedDataOnly가 true인 경우, 데이터가 변경된 경우에만 호출됩니다. /// - /// + /// 업데이트할 새로운 데이터가 포함된 IDataObject 객체입니다. public void UpdateData(IDataObject? newData) { if(newData == null) return; @@ -100,19 +208,29 @@ namespace UVC.Factory.Component /// 지정된 데이터 객체를 처리합니다. 이 메서드는 파생 클래스에서 재정의되어 /// 사용자 지정 데이터 처리 로직을 구현하도록 설계되었습니다. /// - /// 파생 클래스는 매개변수에 대한 특정 처리 동작을 제공하기 위해 - /// 이 메서드를 재정의해야 합니다. - /// 사용하기 전에 매개변수의 유효성을 검사해야 합니다. + /// + /// `UpdateData`가 호출될 때 실행됩니다. 파생 클래스에서는 이 메서드를 재정의하여 + /// 데이터 값에 따라 객체의 색상, 애니메이션, 동작 등을 변경하는 코드를 작성해야 합니다. + /// /// 처리할 데이터 객체입니다. null일 수 없습니다. protected virtual void ProcessData(DataObject newData) {} + /// + /// 객체의 위치를 가져옵니다. 월드 좌표 또는 로컬 좌표로 반환할 수 있습니다. + /// + /// true이면 월드 좌표, false이면 부모 기준의 로컬 좌표를 반환합니다. + /// 객체의 Vector3 위치 값입니다. public Vector3 GetPosition(bool world = false) { if (!world) return transform.position; return transform.TransformPoint(transform.position); } - + /// + /// 객체의 진입점 위치를 가져옵니다. 주로 다른 객체(예: AGV)가 이 객체로 접근할 때 목표 지점으로 사용됩니다. + /// + /// true이면 월드 좌표, false이면 부모 기준의 로컬 좌표를 반환합니다. + /// 객체의 진입점 Vector3 위치 값입니다. public Vector3 GetEntrancePosition(bool world = false) { if (!world) return transform.position; diff --git a/Assets/Scripts/UVC/Factory/Component/FactoryObjectInfo.cs b/Assets/Scripts/UVC/Factory/Component/FactoryObjectInfo.cs index c6999ecd..76dc741b 100644 --- a/Assets/Scripts/UVC/Factory/Component/FactoryObjectInfo.cs +++ b/Assets/Scripts/UVC/Factory/Component/FactoryObjectInfo.cs @@ -1,32 +1,84 @@ namespace UVC.Factory.Component { + /// + /// 팩토리 내의 각 객체(FactoryObject)에 대한 고정적인 식별 정보를 저장하는 데이터 클래스입니다. + /// 이 클래스는 객체의 이름, ID, 위치, 구역, 층과 같이 한 번 설정된 후에는 거의 변경되지 않는 정적 데이터를 담는 데 사용됩니다. + /// + /// + /// 이 정보는 FactoryObject가 생성될 때 할당되며, FactoryObjectManager를 통해 시스템 전체에서 객체를 식별하고 관리하는 데 사용됩니다. + /// 예를 들어, 특정 ID를 가진 기계를 찾거나 특정 구역에 있는 모든 센서를 필터링하는 등의 작업에 활용될 수 있습니다. + /// + /// + /// 다음은 `FactoryObjectInfo`를 생성하고 `FactoryObject`에 할당하는 간단한 예제입니다. + /// + /// using UnityEngine; + /// using UVC.Factory.Component; + /// + /// public class FactorySetup : MonoBehaviour + /// { + /// void Start() + /// { + /// // 1. 씬에 새로운 게임 오브젝트를 생성하고, 사용자 정의 팩토리 객체 컴포넌트(예: MachineObject)를 추가합니다. + /// GameObject machineGo = new GameObject("CNC Machine"); + /// MachineObject machine = machineGo.AddComponent(); // MachineObject는 FactoryObject를 상속받는 클래스라고 가정합니다. + /// + /// // 2. 이 기계에 대한 식별 정보를 담는 FactoryObjectInfo 인스턴스를 생성합니다. + /// var machineInfo = new FactoryObjectInfo( + /// name: "CNC-Machine-05", + /// id: "MC-005", + /// position: "Row 3, Column 2", + /// area: "Machining Area 1", + /// floor: "1F" + /// ); + /// + /// // 3. 생성된 정보 객체를 MachineObject의 Info 속성에 할당합니다. + /// // 이 과정을 통해 3D 모델(GameObject)과 메타데이터(FactoryObjectInfo)가 연결됩니다. + /// machine.Info = machineInfo; + /// + /// // 이제 이 객체는 FactoryObjectManager에 등록되고, ID나 이름으로 검색할 수 있게 됩니다. + /// Debug.Log($"새로운 기계 등록: {machine.Info.ToString()}"); + /// } + /// } + /// + /// // MachineObject는 다음과 같이 FactoryObject를 상속하여 구현할 수 있습니다. + /// public class MachineObject : FactoryObject { /* ... */ } + /// + /// public class FactoryObjectInfo { /// - /// 이름 + /// 객체의 이름입니다. UI에 표시되거나 사람이 식별할 수 있는 이름으로 사용됩니다. (예: "Main Conveyor Belt") /// public string Name { get; set; } /// - /// 아이디 + /// 객체의 고유 식별자(ID)입니다. 시스템에서 객체를 유일하게 구분하는 데 사용됩니다. (예: "CVB-001") /// public string Id { get; set; } /// - /// 위치 + /// 팩토리 내에서 객체의 물리적인 위치나 좌표를 설명합니다. (예: "A-12") /// public string Position { get; set; } /// - /// 구역 + /// 객체가 속한 구역이나 공정을 나타냅니다. (예: "조립 라인 1") /// public string Area { get; set; } /// - /// 층 + /// 객체가 위치한 건물의 층을 나타냅니다. (예: "1F", "B2") /// public string Floor { get; set; } + /// + /// 모든 속성을 초기화하는 생성자입니다. + /// + /// 객체 이름 + /// 고유 ID + /// 위치 정보 + /// 구역 정보 + /// 층 정보 public FactoryObjectInfo(string name, string id, string position, string area, string floor) { Name = name; @@ -36,12 +88,21 @@ Floor = floor; } + /// + /// 다른 FactoryObjectInfo 객체와 모든 속성 값이 동일한지 비교합니다. + /// + /// 비교할 다른 FactoryObjectInfo 객체입니다. + /// 모든 속성이 같으면 true, 그렇지 않으면 false를 반환합니다. public bool Equals(FactoryObjectInfo other) { if (other == null) return false; return Name == other.Name && Id == other.Id && Position == other.Position && Area == other.Area && Floor == other.Floor; } + /// + /// 객체의 정보를 요약된 문자열 형태로 반환합니다. 디버깅이나 로깅에 유용합니다. + /// + /// 객체의 모든 속성 정보를 포함하는 문자열입니다. public override string ToString() { return $"Name:{Name},Id:{Id},Position:{Position},Area:{Area},Floor:{Floor}"; diff --git a/Assets/Scripts/UVC/Factory/Component/FactoryObjectManager.cs b/Assets/Scripts/UVC/Factory/Component/FactoryObjectManager.cs index daf47538..e3a9808d 100644 --- a/Assets/Scripts/UVC/Factory/Component/FactoryObjectManager.cs +++ b/Assets/Scripts/UVC/Factory/Component/FactoryObjectManager.cs @@ -5,10 +5,83 @@ using UVC.Core; namespace UVC.Factory.Component { + /// + /// 씬에 존재하는 모든 FactoryObject 인스턴스를 관리하는 싱글톤 클래스입니다. + /// 이 매니저를 통해 팩토리 객체를 등록, 등록 해제 및 검색할 수 있습니다. + /// + /// + /// SingletonScene을 상속받아 구현되었으므로, `FactoryObjectManager.Instance`를 통해 씬의 어디에서든 쉽게 접근할 수 있습니다. + /// FactoryObject는 Info 속성이 설정될 때 자동으로 이 매니저에 등록되며, 파괴될 때 자동으로 등록 해제됩니다. + /// 따라서 개발자가 직접 Register/Unregister 메서드를 호출할 일은 거의 없습니다. + /// + /// + /// 다음은 FactoryObjectManager를 사용하여 씬에 있는 특정 기계를 찾고 제어하는 예제입니다. + /// + /// using UnityEngine; + /// using UVC.Factory.Component; + /// using UVC.Data; + /// + /// public class ControlPanel : MonoBehaviour + /// { + /// void Start() + /// { + /// // 가정: 씬 어딘가에 이미 FactoryObject(예: MachineObject)가 생성되어 있고, + /// // Info 속성이 설정되어 FactoryObjectManager에 등록된 상태입니다. + /// // + /// // var machine = new MachineObject(); + /// // machine.Info = new FactoryObjectInfo("Drill-01", "MC-1024", ...); + /// } + /// + /// // UI 버튼 클릭 시 호출될 함수 + /// public void OnFindAndStartMachineButtonClick() + /// { + /// // 1. ID를 사용하여 FactoryObjectManager에서 특정 기계를 찾습니다. + /// FactoryObject machine = FactoryObjectManager.Instance.FindById("MC-1024"); + /// + /// if (machine != null) + /// { + /// Debug.Log($"기계를 찾았습니다: {machine.Info.Name}"); + /// + /// // 2. 찾은 기계의 데이터를 업데이트하여 가동시킵니다. + /// var command = new DataObject { { "status", "running" } }; + /// machine.UpdateData(command); + /// } + /// else + /// { + /// Debug.LogWarning("ID 'MC-1024'에 해당하는 기계를 찾을 수 없습니다."); + /// } + /// } + /// + /// // 특정 구역의 모든 객체를 찾는 예시 + /// public void OnFindAllObjectsInArea_A_ButtonClick() + /// { + /// // "Area-A" 구역에 있는 모든 객체를 찾습니다. + /// List objectsInArea = FactoryObjectManager.Instance.FindByArea("Area-A"); + /// + /// Debug.Log($"'Area-A' 구역에서 {objectsInArea.Count}개의 객체를 찾았습니다."); + /// + /// foreach (var obj in objectsInArea) + /// { + /// Debug.Log($"- 객체 이름: {obj.Info.Name}, ID: {obj.Info.Id}"); + /// } + /// } + /// } + /// + /// public class FactoryObjectManager : SingletonScene { + /// + /// 등록된 모든 FactoryObject를 저장하는 딕셔너리입니다. + /// Key는 객체의 식별 정보인 FactoryObjectInfo이고, Value는 FactoryObject 인스턴스입니다. + /// public Dictionary FactoryObjects { get; private set; } = new Dictionary(); + /// + /// 새로운 FactoryObject를 매니저에 등록합니다. + /// 일반적으로 FactoryObject의 Info 속성이 설정될 때 자동으로 호출됩니다. + /// + /// 등록할 FactoryObject 인스턴스입니다. + /// factoryObject 또는 factoryObject.Info가 null일 때 발생합니다. public void RegisterFactoryObject(FactoryObject factoryObject) { if (factoryObject == null || factoryObject.Info == null) @@ -21,6 +94,11 @@ namespace UVC.Factory.Component } } + /// + /// 매니저에서 FactoryObject의 등록을 해제합니다. + /// 일반적으로 FactoryObject가 파괴될 때(OnDestroy) 자동으로 호출됩니다. + /// + /// 등록 해제할 객체의 FactoryObjectInfo입니다. public void UnregisterFactoryObject(FactoryObjectInfo factoryObjectInfo) { if (factoryObjectInfo == null) @@ -31,6 +109,12 @@ namespace UVC.Factory.Component FactoryObjects.Remove(factoryObjectInfo); } + /// + /// FactoryObjectInfo를 사용하여 등록된 FactoryObject 인스턴스를 가져옵니다. + /// + /// 찾고자 하는 객체의 FactoryObjectInfo입니다. + /// 발견된 FactoryObject 인스턴스. 없으면 null을 반환합니다. + /// factoryObjectInfo가 null일 때 발생합니다. public FactoryObject? GetFactoryObject(FactoryObjectInfo factoryObjectInfo) { if (factoryObjectInfo == null) @@ -41,6 +125,11 @@ namespace UVC.Factory.Component return factoryObject; } + /// + /// 객체의 이름(Name)으로 FactoryObject를 검색합니다. 대소문자를 구분하지 않습니다. + /// + /// 찾고자 하는 객체의 이름입니다. + /// 처음으로 발견된 일치하는 FactoryObject. 없으면 null을 반환합니다. public FactoryObject? FindByName(string name) { foreach (var kvp in FactoryObjects) @@ -53,6 +142,11 @@ namespace UVC.Factory.Component return null; } + /// + /// 객체의 고유 ID로 FactoryObject를 검색합니다. 대소문자를 구분하지 않습니다. + /// + /// 찾고자 하는 객체의 ID입니다. + /// 처음으로 발견된 일치하는 FactoryObject. 없으면 null을 반환합니다. public FactoryObject? FindById(string id) { foreach (var kvp in FactoryObjects) @@ -65,6 +159,11 @@ namespace UVC.Factory.Component return null; } + /// + /// 객체의 위치(Position) 정보로 FactoryObject를 검색합니다. 대소문자를 구분하지 않습니다. + /// + /// 찾고자 하는 객체의 위치 정보입니다. + /// 처음으로 발견된 일치하는 FactoryObject. 없으면 null을 반환합니다. public FactoryObject? FindByPosition(string position) { foreach (var kvp in FactoryObjects) @@ -77,6 +176,11 @@ namespace UVC.Factory.Component return null; } + /// + /// 특정 구역(Area)에 속한 모든 FactoryObject를 검색합니다. 대소문자를 구분하지 않습니다. + /// + /// 찾고자 하는 구역의 이름입니다. + /// 발견된 모든 FactoryObject의 리스트. 없으면 빈 리스트를 반환합니다. public List FindByArea(string area) { List foundObjects = new List(); @@ -90,6 +194,11 @@ namespace UVC.Factory.Component return foundObjects; } + /// + /// 특정 층(Floor)에 속한 모든 FactoryObject를 검색합니다. 대소문자를 구분하지 않습니다. + /// + /// 찾고자 하는 층의 이름입니다. + /// 발견된 모든 FactoryObject의 리스트. 없으면 빈 리스트를 반환합니다. public List FindByFloor(string floor) { List foundObjects = new List(); diff --git a/Assets/Scripts/UVC/Tests/Data/HttpPipeLineTests.cs b/Assets/Scripts/UVC/Tests/Data/HttpPipeLineTests.cs index c79096d5..f65f9868 100644 --- a/Assets/Scripts/UVC/Tests/Data/HttpPipeLineTests.cs +++ b/Assets/Scripts/UVC/Tests/Data/HttpPipeLineTests.cs @@ -18,7 +18,7 @@ namespace UVC.Tests.Data public class HttpPipeLineTests { // 테스트에 사용할 HttpPipeLine 인스턴스 - private HttpPipeLine pipeLine; + private HttpPipeLine? pipeLine; /// /// 각 테스트 실행 전에 호출되는 설정 메서드입니다. @@ -62,14 +62,20 @@ namespace UVC.Tests.Data //await RunTestAsync(nameof(Test_RepeatWithCount_StopsAutomatically), Test_RepeatWithCount_StopsAutomatically); // HttpResponseMask 테스트 추가 - Debug.Log("===== HttpResponseMask 테스트 시작 ====="); - RunTest(nameof(HttpResponseMask_Apply_SuccessfulResponse_ReturnsSuccessWithData), HttpResponseMask_Apply_SuccessfulResponse_ReturnsSuccessWithData); - RunTest(nameof(HttpResponseMask_Apply_FailedResponse_WrongSuccessValue_ReturnsFailWithMessage), HttpResponseMask_Apply_FailedResponse_WrongSuccessValue_ReturnsFailWithMessage); - RunTest(nameof(HttpResponseMask_Apply_FailedResponse_MissingSuccessKey_ReturnsFailWithMessage), HttpResponseMask_Apply_FailedResponse_MissingSuccessKey_ReturnsFailWithMessage); - RunTest(nameof(HttpResponseMask_Apply_FailedResponse_MissingDataKey_ReturnsFailWithMessage), HttpResponseMask_Apply_FailedResponse_MissingDataKey_ReturnsFailWithMessage); - RunTest(nameof(HttpResponseMask_Apply_SuccessfulResponse_CustomKeys_ReturnsSuccessWithData), HttpResponseMask_Apply_SuccessfulResponse_CustomKeys_ReturnsSuccessWithData); - RunTest(nameof(HttpResponseMask_Apply_InvalidJson_ThrowsException), HttpResponseMask_Apply_InvalidJson_ThrowsException); - Debug.Log("===== HttpResponseMask 테스트 완료 ====="); + //Debug.Log("===== HttpResponseMask 테스트 시작 ====="); + //RunTest(nameof(HttpResponseMask_Apply_SuccessfulResponse_ReturnsSuccessWithData), HttpResponseMask_Apply_SuccessfulResponse_ReturnsSuccessWithData); + //RunTest(nameof(HttpResponseMask_Apply_FailedResponse_WrongSuccessValue_ReturnsFailWithMessage), HttpResponseMask_Apply_FailedResponse_WrongSuccessValue_ReturnsFailWithMessage); + //RunTest(nameof(HttpResponseMask_Apply_FailedResponse_MissingSuccessKey_ReturnsFailWithMessage), HttpResponseMask_Apply_FailedResponse_MissingSuccessKey_ReturnsFailWithMessage); + //RunTest(nameof(HttpResponseMask_Apply_FailedResponse_MissingDataKey_ReturnsFailWithMessage), HttpResponseMask_Apply_FailedResponse_MissingDataKey_ReturnsFailWithMessage); + //RunTest(nameof(HttpResponseMask_Apply_SuccessfulResponse_CustomKeys_ReturnsSuccessWithData), HttpResponseMask_Apply_SuccessfulResponse_CustomKeys_ReturnsSuccessWithData); + //RunTest(nameof(HttpResponseMask_Apply_InvalidJson_ThrowsException), HttpResponseMask_Apply_InvalidJson_ThrowsException); + //Debug.Log("===== HttpResponseMask 테스트 완료 ====="); + + Debug.Log("===== DataValidator 테스트 시작 ====="); + await RunTestAsync(nameof(Test_Excute_WithValidData_ValidatorPasses), Test_Excute_WithValidData_ValidatorPasses); + await RunTestAsync(nameof(Test_Excute_WithInvalidData_ValidatorFails), Test_Excute_WithInvalidData_ValidatorFails); + await RunTestAsync(nameof(Test_Excute_WithArrayAndValidator_FiltersInvalidData), Test_Excute_WithArrayAndValidator_FiltersInvalidData); + Debug.Log("===== DataValidator 테스트 완료 ====="); Debug.Log("===== HttpPipeLine 테스트 완료 ====="); } @@ -245,6 +251,7 @@ namespace UVC.Tests.Data .setDataMapper(dataMapper) .setSuccessHandler((data) => { + Debug.Log("핸들러 호출됨"); handlerCalled = true; if (data is DataObject dataObject) { @@ -1127,5 +1134,166 @@ namespace UVC.Tests.Data } #endregion + + #region DataValidator Tests + + /// + /// DataValidator를 사용하여 유효한 데이터를 성공적으로 처리하는지 테스트합니다. + /// + [Test] + public async UniTask Test_Excute_WithValidData_ValidatorPasses() + { + // Arrange + bool handlerCalled = false; + IDataObject? receivedData = null; + string testUrl = "http://test.com/validator-pass"; + var mockResponse = @"{""message"": ""Success"", ""data"": {""id"": 1, ""status"": ""active""}}"; + MockHttpRequester.SetResponse(testUrl, mockResponse); + + var dataMapper = new DataMapper(new DataMask { ["id"] = 0, ["status"] = "" }); + + // "status" 필드가 "active"인 경우에만 유효하도록 설정 + var validator = new DataValidator(); + validator.AddValidator("status", value => { + return value is string s && s == "active"; + }); + + var info = new HttpPipeLineInfo(testUrl) + .setDataMapper(dataMapper) + .setValidator(validator) + .setSuccessHandler(data => + { + handlerCalled = true; + receivedData = data; + }) + .setFailHandler((message) => + { + Debug.LogError("Fail message: " + message); + }); + + pipeLine.Add("validatorPassTest", info); + + try + { + // Act + await pipeLine.Excute("validatorPassTest"); + + // Assert + Assert.IsTrue(handlerCalled, "유효성 검사를 통과했으므로 핸들러가 호출되어야 합니다."); + Assert.IsNotNull(receivedData, "데이터가 핸들러로 전달되어야 합니다."); + Assert.AreEqual("active", (receivedData as DataObject)?.GetString("status")); + } + finally + { + await pipeLine.RemoveAsync("validatorPassTest"); + MockHttpRequester.ClearResponses(testUrl); + } + } + + /// + /// DataValidator를 사용하여 유효하지 않은 데이터를 필터링하는지 테스트합니다. + /// + [Test] + public async UniTask Test_Excute_WithInvalidData_ValidatorFails() + { + // Arrange + bool handlerCalled = false; + string testUrl = "http://test.com/validator-fail"; + var mockResponse = @"{""message"": ""Success"", ""data"": {""id"": 2, ""status"": ""inactive""}}"; + MockHttpRequester.SetResponse(testUrl, mockResponse); + + var dataMapper = new DataMapper(new DataMask { ["id"] = 0, ["status"] = "" }); + + // "status" 필드가 "active"인 경우에만 유효하도록 설정 + var validator = new DataValidator(); + validator.AddValidator("status", value => value is string s && s == "active"); + + var info = new HttpPipeLineInfo(testUrl) + .setDataMapper(dataMapper) + .setValidator(validator) + .setSuccessHandler(data => + { + handlerCalled = true; // 이 핸들러는 호출되지 않아야 함 + }); + + pipeLine.Add("validatorFailTest", info); + + try + { + // Act + await pipeLine.Excute("validatorFailTest"); + + // Assert + Assert.IsFalse(handlerCalled, "유효성 검사에 실패했으므로 핸들러가 호출되지 않아야 합니다."); + } + finally + { + await pipeLine.RemoveAsync("validatorFailTest"); + MockHttpRequester.ClearResponses(testUrl); + } + } + + /// + /// DataValidator가 배열 데이터에서 유효한 항목만 필터링하는지 테스트합니다. + /// + [Test] + public async UniTask Test_Excute_WithArrayAndValidator_FiltersInvalidData() + { + // Arrange + bool handlerCalled = false; + IDataObject? receivedData = null; + string testUrl = "http://test.com/validator-array"; + var mockResponse = @"{ + ""message"": ""Success"", + ""data"": [ + {""id"": 1, ""value"": 10}, + {""id"": 2, ""value"": 20}, + {""id"": 3, ""value"": 5} + ] + }"; + MockHttpRequester.SetResponse(testUrl, mockResponse); + + var dataMapper = new DataMapper(new DataMask { ["id"] = 0, ["value"] = 0 }); + + // "value"가 15보다 큰 항목만 유효하도록 설정 + var validator = new DataValidator(); + validator.AddValidator("value", value => { + return value is int v && v > 15; + }); + + var info = new HttpPipeLineInfo(testUrl) + .setDataMapper(dataMapper) + .setValidator(validator) + .setSuccessHandler(data => + { + handlerCalled = true; + receivedData = data; + }); + + pipeLine.Add("validatorArrayTest", info); + + try + { + // Act + await pipeLine.Excute("validatorArrayTest"); + + // Assert + Assert.IsTrue(handlerCalled, "핸들러가 호출되어야 합니다."); + Assert.IsNotNull(receivedData, "데이터가 핸들러로 전달되어야 합니다."); + Assert.IsTrue(receivedData is DataArray, "결과는 DataArray여야 합니다."); + + var dataArray = receivedData as DataArray; + Assert.AreEqual(1, dataArray.Count, "유효한 항목은 1개여야 합니다."); + Assert.AreEqual(20, dataArray[0].GetInt("value"), "필터링된 데이터의 값이 올바르지 않습니다."); + } + finally + { + await pipeLine.RemoveAsync("validatorArrayTest"); + MockHttpRequester.ClearResponses(testUrl); + } + } + + #endregion + } } \ No newline at end of file diff --git a/Assets/Scripts/UVC/Tests/Data/MQTTPipeLineTests.cs b/Assets/Scripts/UVC/Tests/Data/MQTTPipeLineTests.cs index 4d8d3c87..40f946a8 100644 --- a/Assets/Scripts/UVC/Tests/Data/MQTTPipeLineTests.cs +++ b/Assets/Scripts/UVC/Tests/Data/MQTTPipeLineTests.cs @@ -5,6 +5,7 @@ using Newtonsoft.Json.Linq; using NUnit.Framework; using System; using System.Collections.Generic; +using System.Threading.Tasks; using UnityEngine; using UVC.Data; using UVC.Log; @@ -51,7 +52,11 @@ namespace UVC.Tests.Data //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); + //RunTest(nameof(OnTopicMessage_InvalidJson_DoesNotCallHandler), OnTopicMessage_InvalidJson_DoesNotCallHandler); + + //await RunTestAsync(nameof(OnTopicMessage_WithValidData_ValidatorPassesAsync), OnTopicMessage_WithValidData_ValidatorPassesAsync); + //await RunTestAsync(nameof(OnTopicMessage_WithInvalidData_ValidatorFailsAsync), OnTopicMessage_WithInvalidData_ValidatorFailsAsync); + await RunTestAsync(nameof(OnTopicMessage_WithArrayAndValidator_FiltersInvalidDataAsync), OnTopicMessage_WithArrayAndValidator_FiltersInvalidDataAsync); Debug.Log("===== MQTTPipeLine 테스트 완료 ====="); } @@ -568,6 +573,133 @@ namespace UVC.Tests.Data } } } + + #region DataValidator Tests + + /// + /// DataValidator를 사용하여 유효한 데이터를 성공적으로 처리하는지 테스트합니다. + /// + [Test] + public async UniTask OnTopicMessage_WithValidData_ValidatorPassesAsync() + { + // Arrange + var testPipeLine = new TestMQTTPipeLine(); + var handler = new TestDataHandler(); + var dataMapper = new DataMapper(new DataMask { ["id"] = 0, ["status"] = "" }); + + // "status" 필드가 "active"인 경우에만 유효하도록 설정 + var validator = new DataValidator(); + validator.AddValidator("status", value => { + Debug.Log($"Validator called with value: {value}, {value is string s2 && s2 == "active"}"); + return value is string s && s == "active"; + }); + + var pipelineInfo = new MQTTPipeLineInfo("test_topic") + .setDataMapper(dataMapper) + .setValidator(validator) + .setHandler(handler.HandleData); + + testPipeLine.Add(pipelineInfo); + + var validMessage = @"{""id"": 1, ""status"": ""active""}"; + + // Act + testPipeLine.TestOnTopicMessage("test_topic", validMessage); + + await UniTask.Delay(2000); // 핸들러가 호출되기 전에 잠시 대기 + + // Assert + Assert.AreEqual(1, handler.CallCount, "유효성 검사를 통과했으므로 핸들러가 호출되어야 합니다."); + Assert.IsNotNull(handler.LastDataObject, "데이터가 핸들러로 전달되어야 합니다."); + Assert.AreEqual("active", (handler.LastDataObject as DataObject)?.GetString("status")); + } + + /// + /// DataValidator를 사용하여 유효하지 않은 데이터를 필터링하는지 테스트합니다. + /// + [Test] + public async UniTask OnTopicMessage_WithInvalidData_ValidatorFailsAsync() + { + // Arrange + var testPipeLine = new TestMQTTPipeLine(); + var handler = new TestDataHandler(); + var dataMapper = new DataMapper(new DataMask { ["id"] = 0, ["status"] = "" }); + + // "status" 필드가 "active"인 경우에만 유효하도록 설정 + var validator = new DataValidator(); + validator.AddValidator("status", value => { + Debug.Log($"Validator called with value2: {value}, {value is string s2 && s2 == "active"}"); + return value is string s && s == "active"; + }); + + var pipelineInfo = new MQTTPipeLineInfo("test_topic") + .setDataMapper(dataMapper) + .setValidator(validator) + .setHandler(handler.HandleData); + + testPipeLine.Add(pipelineInfo); + + var invalidMessage = @"{""id"": 2, ""status"": ""inactive""}"; + + // Act + testPipeLine.TestOnTopicMessage("test_topic", invalidMessage); + + await UniTask.Delay(2000); // 핸들러가 호출되기 전에 잠시 대기 + + // Assert + Assert.AreEqual(0, handler.CallCount, "유효성 검사에 실패했으므로 핸들러가 호출되지 않아야 합니다."); + } + + /// + /// DataValidator가 배열 데이터에서 유효한 항목만 필터링하는지 테스트합니다. + /// + [Test] + public async UniTask OnTopicMessage_WithArrayAndValidator_FiltersInvalidDataAsync() + { + // Arrange + var testPipeLine = new TestMQTTPipeLine(); + var handler = new TestDataHandler(); + var dataMapper = new DataMapper(new DataMask { ["id"] = 0, ["value"] = 0 }); + + // "value"가 15보다 큰 항목만 유효하도록 설정 + var validator = new DataValidator(); + validator.AddValidator("value", value => + { + Debug.Log($"Validator called with value3: {value}, {value is int v2 && v2 > 15}"); + return value is int v && v > 15; + }); + + var pipelineInfo = new MQTTPipeLineInfo("test_topic") + .setDataMapper(dataMapper) + .setValidator(validator) + .setHandler(handler.HandleData); + + testPipeLine.Add(pipelineInfo); + + var arrayMessage = @"[ + {""id"": 1, ""value"": 10}, + {""id"": 2, ""value"": 20}, + {""id"": 3, ""value"": 5} + ]"; + + // Act + testPipeLine.TestOnTopicMessage("test_topic", arrayMessage); + + await UniTask.Delay(2000); // 핸들러가 호출되기 전에 잠시 대기 + + Debug.Log($"LastDataObject: {handler.LastDataObject.GetType()}"); + + // Assert + Assert.AreEqual(1, handler.CallCount, "핸들러가 한 번 호출되어야 합니다."); + Assert.IsNotNull(handler.LastDataObject, "데이터가 핸들러로 전달되어야 합니다."); + Assert.IsTrue(handler.LastDataObject is DataArray, "결과는 DataArray여야 합니다."); + + var dataArray = handler.LastDataObject as DataArray; + Assert.AreEqual(1, dataArray.Count, "유효한 항목은 1개여야 합니다."); + Assert.AreEqual(20, dataArray[0].GetInt("value"), "필터링된 데이터의 값이 올바르지 않습니다."); + } + + #endregion } // MQTTPipeLine의 OnTopicMessage 메서드를 테스트하기 위한 확장 클래스 diff --git a/Assets/Scripts/UVC/Tests/Tester.cs b/Assets/Scripts/UVC/Tests/Tester.cs index 5a6f3fa2..ae8fe5e1 100644 --- a/Assets/Scripts/UVC/Tests/Tester.cs +++ b/Assets/Scripts/UVC/Tests/Tester.cs @@ -7,8 +7,8 @@ namespace UVC.Tests public static void RunAllTests() { //new DataMapperTests().TestAll(); - new HttpPipeLineTests().TestAll(); - //new MQTTPipeLineTests().TestAll(); + //new HttpPipeLineTests().TestAll(); + new MQTTPipeLineTests().TestAll(); } } } diff --git a/Assets/Scripts/UVC/Threading/MainThreadDispatcher.cs b/Assets/Scripts/UVC/Threading/MainThreadDispatcher.cs index ec920a92..4b5acc69 100644 --- a/Assets/Scripts/UVC/Threading/MainThreadDispatcher.cs +++ b/Assets/Scripts/UVC/Threading/MainThreadDispatcher.cs @@ -1,5 +1,6 @@ #nullable enable +using Cysharp.Threading.Tasks; using System; using System.Collections.Concurrent; using System.Threading; @@ -22,8 +23,8 @@ namespace UVC.Threading /// // 백그라운드 작업 수행 /// var result = ComputeIntensiveTask(); /// - /// // UI 업데이트는 메인 스레드에서 수행 - /// MainThreadDispatcher.Instance.Enqueue(() => { + /// // UI 업데이트는 메인 스레드에서 수행 (Fire-and-forget) + /// MainThreadDispatcher.Instance.SendToMainThread(() => { /// UpdateUIWithResult(result); /// }); /// }); @@ -40,8 +41,8 @@ namespace UVC.Threading /// var data = await FetchDataFromServerAsync(); /// var processedData = ProcessLargeData(data); /// - /// // 결과를 메인 스레드로 전달하여 UI 업데이트 - /// MainThreadDispatcher.Instance.SendToMainThread(() => { + /// // 결과를 메인 스레드로 전달하여 UI 업데이트하고 완료까지 대기 + /// await MainThreadDispatcher.Instance.SendToMainThreadAsync(() => { /// // 여기에서 UI 컴포넌트를 안전하게 업데이트 /// UpdateUI(processedData); /// ShowSuccessMessage("데이터 처리 완료"); @@ -50,7 +51,7 @@ namespace UVC.Threading /// catch (Exception ex) /// { /// // 오류 발생 시에도 메인 스레드에서 UI 관련 작업 처리 - /// MainThreadDispatcher.Instance.SendToMainThread(() => { + /// await MainThreadDispatcher.Instance.SendToMainThreadAsync(() => { /// ShowErrorDialog($"오류 발생: {ex.Message}"); /// Debug.LogException(ex); /// }); @@ -78,8 +79,8 @@ namespace UVC.Threading /// // 시간이 걸리는 작업 수행 /// var result = await PerformHeavyComputationAsync(); /// - /// // 결과를 UI에 반영 (메인 스레드에서) - /// MainThreadDispatcher.Instance.SendToMainThread(() => { + /// // 결과를 UI에 반영하고 완료까지 대기 (메인 스레드에서) + /// await MainThreadDispatcher.Instance.SendToMainThreadAsync(() => { /// resultText.text = result.ToString(); /// loadingIndicator.SetActive(false); /// }); @@ -87,7 +88,7 @@ namespace UVC.Threading /// catch (Exception ex) /// { /// // 예외 처리도 메인 스레드에서 - /// MainThreadDispatcher.Instance.SendToMainThread(() => { + /// await MainThreadDispatcher.Instance.SendToMainThreadAsync(() => { /// errorText.text = ex.Message; /// loadingIndicator.SetActive(false); /// }); @@ -165,7 +166,7 @@ namespace UVC.Threading } /// - /// SynchronizationContext를 통해 메인 스레드에서 액션을 실행합니다. + /// 메인 스레드에서 액션을 실행합니다. (Fire-and-forget) /// /// 메인 스레드에서 실행할 액션 public void SendToMainThread(Action action) @@ -184,6 +185,56 @@ namespace UVC.Threading } } + /// + /// 메인 스레드에서 액션을 비동기적으로 실행하고, 해당 액션의 완료를 나타내는 UniTask를 반환합니다. + /// + /// 메인 스레드에서 실행할 액션입니다. + /// 액션의 실행이 완료되면 완료되는 UniTask입니다. + public UniTask SendToMainThreadAsync(Action action) + { + if (action == null) return UniTask.CompletedTask; + + // 현재 스레드가 메인 스레드인 경우 즉시 실행 + if (_mainThreadContext != null && SynchronizationContext.Current == _mainThreadContext) + { + try + { + action(); + return UniTask.CompletedTask; + } + catch (Exception ex) + { + return UniTask.FromException(ex); + } + } + + var utcs = new UniTaskCompletionSource(); + Action wrappedAction = () => + { + try + { + action(); + utcs.TrySetResult(); + } + catch (Exception e) + { + utcs.TrySetException(e); + } + }; + + if (_mainThreadContext != null) + { + _mainThreadContext.Post(_ => wrappedAction(), null); + } + else + { + // SynchronizationContext가 없는 경우 큐 사용 + Enqueue(wrappedAction); + } + + return utcs.Task; + } + private void Update() { // 큐에 있는 모든 액션 처리 diff --git a/Assets/Scripts/UVC/UI/Info/InfoWindow.cs b/Assets/Scripts/UVC/UI/Info/InfoWindow.cs index 0bb7028f..1bcda0bf 100644 --- a/Assets/Scripts/UVC/UI/Info/InfoWindow.cs +++ b/Assets/Scripts/UVC/UI/Info/InfoWindow.cs @@ -17,11 +17,7 @@ namespace UVC.UI.Info [Tooltip("Label 정보 텍스트를 표시할 UI 요소")] [SerializeField] - private TextMeshProUGUI labelText; - - [Tooltip("Value 정보 텍스트를 표시할 UI 요소")] - [SerializeField] - private TextMeshProUGUI valueText; + private TextMeshProUGUI text; [Tooltip("정보 창을 닫을 버튼")] [SerializeField] @@ -29,7 +25,11 @@ namespace UVC.UI.Info [Tooltip("UI가 객체를 가리지 않도록 할 월드 좌표계 오프셋")] [SerializeField] - private Vector2 screenOffset = new Vector2(0f, 0f); + private Vector2 screenOffset = new Vector2(10f, 10f); + + [Tooltip("UI가 객체를 가리지 않도록 할 메뉴바 높이")] + [SerializeField] + private float menuBarHeight = 70f; // 정보 창이 따라다닐 3D 객체의 Transform private Transform? target; @@ -40,20 +40,20 @@ namespace UVC.UI.Info /// public bool IsVisible => gameObject.activeSelf; + private RectTransform? rectTransform; protected override void Awake() { base.Awake(); + rectTransform = GetComponent(); + // 닫기 버튼이 할당되었으면 클릭 이벤트를 연결합니다. if (closeButton != null) { closeButton.onClick.AddListener(Hide); } - RectTransform? rectTransform = transform as RectTransform; - if (rectTransform != null && screenOffset == Vector2.zero) screenOffset = new Vector2(rectTransform.rect.width / 2 + 10f, - rectTransform.rect.height / 2); - // 처음에는 정보 창을 숨깁니다. if (gameObject.activeSelf) { @@ -90,39 +90,33 @@ namespace UVC.UI.Info // 추가 오프셋 적용 screenPosRight.x += screenOffset.x; screenPosRight.y += screenOffset.y; - - // 메뉴바 영역(상단 70픽셀) 고려 및 화면 밖으로 나가지 않도록 제한 - float menuBarHeight = 70f; - screenPosRight.x = Mathf.Clamp(screenPosRight.x, 100f, Screen.width - 100f); - screenPosRight.y = Mathf.Clamp(screenPosRight.y, 100f, Screen.height - menuBarHeight); + // 메뉴바 영역 고려 및 화면 밖으로 나가지 않도록 제한 + screenPosRight.x = Mathf.Clamp(screenPosRight.x, 0, Screen.width - rectTransform!.rect.width); + screenPosRight.y = Mathf.Clamp(screenPosRight.y, rectTransform!.rect.height, Screen.height - menuBarHeight); // RectTransform을 사용하여 UI 위치 설정 - RectTransform? rectTransform = transform as RectTransform; - if (rectTransform != null) + // 캔버스의 렌더링 모드에 따라 다르게 처리 + Canvas canvas = GetComponentInParent(); + if (canvas != null) { - // 캔버스의 렌더링 모드에 따라 다르게 처리 - Canvas canvas = GetComponentInParent(); - if (canvas != null) + if (canvas.renderMode == RenderMode.ScreenSpaceOverlay) { - if (canvas.renderMode == RenderMode.ScreenSpaceOverlay) - { - rectTransform.position = screenPosRight; - } - else if (canvas.renderMode == RenderMode.ScreenSpaceCamera || - canvas.renderMode == RenderMode.WorldSpace) - { - // 스크린 좌표를 캔버스 상의 로컬 좌표로 변환 - Vector2 localPoint; - RectTransformUtility.ScreenPointToLocalPointInRectangle( - canvas.GetComponent(), - screenPosRight, - canvas.renderMode == RenderMode.ScreenSpaceCamera ? canvas.worldCamera : Camera.main, - out localPoint); - - rectTransform.localPosition = new Vector3(localPoint.x, localPoint.y, rectTransform.localPosition.z); - } + rectTransform.position = screenPosRight; + } + else if (canvas.renderMode == RenderMode.ScreenSpaceCamera || + canvas.renderMode == RenderMode.WorldSpace) + { + // 스크린 좌표를 캔버스 상의 로컬 좌표로 변환 + Vector2 localPoint; + RectTransformUtility.ScreenPointToLocalPointInRectangle( + canvas.GetComponent(), + screenPosRight, + canvas.renderMode == RenderMode.ScreenSpaceCamera ? canvas.worldCamera : Camera.main, + out localPoint); + rectTransform.localPosition = new Vector3(localPoint.x, localPoint.y, rectTransform.localPosition.z); } } + // UI가 항상 보이도록 카메라를 향하게 설정 (World Space Canvas인 경우에만 필요) Canvas parentCanvas = GetComponentInParent(); @@ -142,28 +136,8 @@ namespace UVC.UI.Info public void Show(Transform targetObject, Dictionary information) { target = targetObject; - if (labelText != null) - { - string labelString = string.Empty; - string valueString = string.Empty; - foreach (var kvp in information) - { - labelString += $"{kvp.Key}\n"; - valueString += $"{kvp.Value ?? "null"}\n"; - } - labelString = labelString.TrimEnd('\n'); // 마지막 줄바꿈 제거 - valueString = valueString.TrimEnd('\n'); // 마지막 줄바꿈 제거 - Debug.Log($"InfoWindow: {labelString}, {valueString}"); - labelText.text = labelString; - valueText.text = valueString; - } - //size를 text에 맞게 조정합니다. - RectTransform? rect = GetComponent(); - if (rect != null) - { - rect.sizeDelta = new Vector2(rect.rect.width, valueText.preferredHeight + 25f); - } + UpdateInformation(information); gameObject.SetActive(true); @@ -171,6 +145,30 @@ namespace UVC.UI.Info LateUpdate(); } + public void UpdateInformation(Dictionary information) + { + if (target == null) return; + if (text != null) + { + string combinedString = string.Empty; + foreach (var kvp in information) + { + // 태그를 사용하여 줄바꿈 시에도 정렬이 유지되도록 합니다. + combinedString += $"{kvp.Key}:{kvp.Value ?? "null"}\n"; + } + combinedString = combinedString.TrimEnd('\n'); // 마지막 줄바꿈 제거 + text.text = combinedString; + } + // size를 text에 맞게 조정합니다. + RectTransform? rect = GetComponent(); + if (rect != null && text != null) + { + RectTransform textRect = text.GetComponent(); + float marginHeight = rect.rect.height - textRect.rect.height; // 상하 여백 + rect.sizeDelta = new Vector2(rect.rect.width, text.preferredHeight + marginHeight); + } + } + /// /// 정보 창을 숨깁니다. /// diff --git a/Assets/Scripts/UVC/UI/Menu/TopMenuController.cs b/Assets/Scripts/UVC/UI/Menu/TopMenuController.cs index f841ac7a..03b0f908 100644 --- a/Assets/Scripts/UVC/UI/Menu/TopMenuController.cs +++ b/Assets/Scripts/UVC/UI/Menu/TopMenuController.cs @@ -27,7 +27,7 @@ namespace UVC.UI.Menu /// /// // 기존 모델에 새로운 메뉴 아이템 추가 또는 수정 /// // 예: '도움말' 메뉴 추가 - /// model.MenuItems.Add(new MenuItemData("help", "menu_help", subMenuItems: new List<MenuItemData> + /// model.MenuItems.Add(new MenuItemData("help", "menu_help", subMenuItems: new List; /// { /// new MenuItemData("help_about", "menu_help_about", new DebugLogCommand("도움말 > 정보 선택됨")) /// }));