pipeline 스레드 적용

This commit is contained in:
logonkhi
2025-06-25 18:50:19 +09:00
parent b3bf7e6eff
commit 784238efbf
19 changed files with 1034 additions and 369 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -3,6 +3,7 @@ using System;
using UnityEngine;
using UVC.Core;
using UVC.Data;
using UVC.Tests;
namespace SampleProject
{

View File

@@ -314,6 +314,17 @@ namespace UVC.Data
return defaultValue;
}
/// <summary>
/// 지정된 문자열을 배정밀도 부동 소수점 숫자로 변환합니다.
/// </summary>
/// <param name="v">변환할 숫자의 문자열 표현입니다.</param>
/// <returns>입력 문자열에서 구문 분석된 배정밀도 부동 소수점 숫자 또는 변환이 실패하면 <c>0.0</c>을 반환합니다.
///</returns>
public double GetLong(string v)
{
return GetDouble(v, 0.0);
}
/// <summary>
/// 지정된 속성의 값을 DateTime으로 변환하여 반환합니다.
/// </summary>
@@ -511,7 +522,6 @@ namespace UVC.Data
{
return string.Join(", ", this.Select(kvp => $"{kvp.Key}:{kvp.Value}"));
}
}

View File

@@ -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;
}
}

View File

@@ -41,9 +41,9 @@ namespace UVC.Data
{
/// <summary>
/// 유효성 검사 규칙의 목록입니다.
/// 각 사전은 속성 이름(string)과 해당 속성의 검증 함수(Func&lt;object, bool&gt;)를 포함합니다.
/// 각 사전은 속성 이름(string)과 해당 속성의 검증 함수(Func<object, bool>)를 포함합니다.
/// </summary>
private List<Dictionary<string, Func<object, bool>>> validaters = new List<Dictionary<string, Func<object, bool>>>();
private List<Dictionary<string, Func<object?, bool>>> validaters = new List<Dictionary<string, Func<object?, bool>>>();
/// <summary>
/// 대용량 JSON 데이터를 스트리밍 방식으로 처리할지 여부를 나타내는 속성입니다.
@@ -91,10 +91,10 @@ namespace UVC.Data
/// });
/// </code>
/// </example>
public void AddValidator(string propertyName, Func<object, bool> validator)
public void AddValidator(string propertyName, Func<object?, bool> validator)
{
// 유효성 검사기를 추가합니다.
validaters.Add(new Dictionary<string, Func<object, bool>> { { propertyName, validator } });
validaters.Add(new Dictionary<string, Func<object?, bool>> { { propertyName, validator } });
}
/// <summary>
@@ -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<int>();
case JTokenType.Float:
return token.ToObject<float>();
case JTokenType.Boolean:
return token.ToObject<bool>();
//case JTokenType.Object:
// return new DataObject((JObject)token);
//case JTokenType.Array:
// JArray array = (JArray)token;
// return new DataArray(array);
default:
return token.ToString();
}
}
}
}

View File

@@ -158,7 +158,7 @@ namespace UVC.Data
/// 모든 HTTP 요청 처리는 백그라운드 스레드에서 수행되며, 핸들러만 메인 스레드에서 호출됩니다.
/// </remarks>
/// <exception cref="KeyNotFoundException">지정된 키가 등록되어 있지 않은 경우</exception>
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<string>(info.Url, info.Method, info.Body, info.Headers);
result = await MockHttpRequester.Request<string>(info.Url, info.Method, info.Body, info.Headers);
}
else
{
result = await MockHttpRequester.Request<string>(info.Url, info.Method, info.Body, info.Headers);
result = await HttpRequester.Request<string>(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
/// </remarks>
public async UniTask StopRepeat(string key)
{
CancellationTokenSource cts = null;
CancellationTokenSource? cts = null;
lock (repeatTokenSources) // 스레드 안전성 확보
{
if (repeatTokenSources.TryGetValue(key, out cts) && !cts.IsCancellationRequested)

View File

@@ -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' 설정에 따라 데이터가 변경된 경우에만 핸들러를 호출할 수도 있습니다.
/// 메시지 처리는 백그라운드 스레드에서 수행되며, 핸들러는 메인 스레드에서 호출됩니다.
/// </remarks>
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();
}
/// <summary>

View File

@@ -1,21 +1,31 @@
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UVC.Data;
namespace UVC.Factory.Component
{
/// <summary>
/// 씬에 표시되는 개별 AGV(무인 운반차)를 제어하는 클래스입니다.
/// FactoryObject를 상속받아, AGV의 데이터 처리, 3D 모델의 이동 및 회전, 정보 표시 기능을 구현합니다.
/// </summary>
/// <remarks>
/// 이 클래스는 AGVManager에 의해 동적으로 생성되고 관리됩니다.
/// AGVManager로부터 실시간 데이터를 받아 ProcessData 메서드에서 처리하고,
/// Unity의 Update 메서드에서 매 프레임마다 부드러운 시각적 이동을 구현합니다.
/// </remarks>
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미터 이상 차이나면 순간이동
/// <summary>
/// AGV 객체가 생성될 때 처음 한 번 호출되는 초기화 메서드입니다.
/// </summary>
private void Start()
{
// 시작 시에는 현재 위치를 목표 위치로 설정하여 의도치 않은 움직임을 방지합니다.
targetPosition = transform.position;
targetRotation = transform.rotation;
// 사용자가 AGV를 클릭했을 때 정보창에 표시될 데이터 항목과 순서를 정의합니다.
DataOrderedMask = new List<string>
{
"VHL_NAME",
@@ -43,26 +58,32 @@ namespace UVC.Factory.Component
}
/// <summary>
/// 내부 상태를 업데이트하고 정렬된 마스크에 정의된 특정 키에 대한 작업을 수행하여 제공된 데이터 객체를 처리합니다.
// </summary>
/// <remarks>이 메서드는 초기화되지 않은 경우 내부 데이터 상태를 업데이트하고, 제공된
/// <see cref="DataOrderedMask"/>에 정의된 키를 반복하여 제공된
/// <paramref name="newData"/> 객체의 일치하는 항목에 대한 작업을 수행합니다. <paramref name="newData"/>에 처리에 필요한 키가 포함되어 있는지
/// 확인합니다.</remarks>
/// <param name="newData">처리할 데이터 객체입니다. null이 아니어야 하며 정렬된 마스크와 관련된 키-값 쌍을 포함해야 합니다.
/// AGVManager로부터 새로운 데이터를 받았을 때 호출되는 핵심 메서드입니다.
/// 받은 데이터를 기반으로 AGV의 내부 상태와 목표 위치를 갱신합니다.
/// </summary>
/// <remarks>
/// 이 메서드는 FactoryObject의 추상 메서드를 재정의한 것입니다.
/// AGV의 위치, 회전, 상태 등 모든 동적인 정보는 이 메서드를 통해 업데이트됩니다.
/// </remarks>
/// <param name="newData">AGV의 최신 정보가 담긴 데이터 객체입니다.</param>
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
}
}
/// <summary>
/// 데이터 객체로부터 위치(X, Y) 및 각도(DEGREE) 값을 읽어와 AGV의 목표 위치와 회전을 설정합니다.
/// </summary>
/// <param name="newData">위치와 각도 정보가 포함된 데이터 객체입니다.</param>
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
}
}
/// <summary>
/// Unity에 의해 매 프레임마다 호출되는 메서드입니다.
/// AGV의 현재 위치/회전을 목표 위치/회전으로 부드럽게 이동시키는 시각적 처리를 담당합니다.
/// </summary>
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);
}
}

View File

@@ -10,18 +10,77 @@ using UVC.Data;
namespace UVC.Factory.Component
{
/// <summary>
/// 씬에 존재하는 모든 AGV(Automated Guided Vehicle, 무인 운반차) 객체를 총괄 관리하는 싱글톤 클래스입니다.
/// 외부 데이터 소스(예: MQTT)로부터 AGV의 실시간 데이터를 수신하여, 씬에 AGV를 동적으로 생성, 업데이트, 제거하는 역할을 담당합니다.
/// </summary>
/// <remarks>
/// 이 매니저는 `SingletonScene`을 상속받아 구현되었으므로, `AGVManager.Instance`를 통해 씬의 어디에서든 쉽게 접근할 수 있습니다.
/// 씬이 초기화될 때(`OnSceneInitialized`) MQTT 파이프라인에 연결하여 'AGV' 토픽의 데이터를 구독하기 시작합니다.
/// 수신된 데이터(`DataArray`)를 분석하여 새로 추가된 AGV, 제거된 AGV, 정보가 변경된 AGV를 감지하고 각각에 맞는 처리를 비동기적으로 수행합니다.
/// </remarks>
/// <example>
/// 이 매니저는 자동으로 작동하므로 다른 스크립트에서 직접 호출할 일은 거의 없습니다.
/// 대신 이 매니저가 어떻게 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 파일에 포함될 수 있는 예시 코드)
/// <code>
/// 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");
/// // ... (상태에 따른 시각적 처리)
/// }
/// }
/// }
/// </code>
/// </example>
public class AGVManager : SingletonScene<AGVManager>
{
private readonly string prefabPath = "Prefabs/SampleProject/Factory/AGV";
private GameObject? prefab;
private List<AGV> agvList;
/// <summary>
/// AGVManager의 초기화 메서드입니다.
/// Awake 메서드에서 호출되며, MonoBehaviour가 생성될 때 한 번만 실행됩니다.
@@ -32,6 +91,10 @@ namespace UVC.Factory.Component
SceneMain.Instance.Initialized += OnSceneInitialized;
}
/// <summary>
/// 씬이 완전히 초기화된 후 호출됩니다.
/// AGV 데이터를 수신하기 위한 MQTT 파이프라인을 설정합니다.
/// </summary>
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);
}
/// <summary>
/// 데이터 수신 시 호출되는 공개 핸들러입니다.
/// 비동기 처리 메서드인 OnUpdateDataAsync를 호출합니다.
/// </summary>
/// <param name="data">수신된 데이터 객체 (일반적으로 DataArray 형태)</param>
public void OnUpdateData(IDataObject? data)
{
OnUpdateDataAsync(data).Forget();
}
/// <summary>
/// 기존 데이터와 비교하여 변경된 부분만 전달 됩니다.
/// 수신된 AGV 데이터 배열을 비동기적으로 처리하여 씬에 반영합니다.
/// 추가, 제거, 수정된 AGV 데이터를 각각 구분하여 처리합니다.
/// </summary>
/// <param name="data"></param>
/// <param name="data">수신된 데이터 (DataArray)</param>
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
}
/// <summary>
/// AGV 프리팹을 사용하여 새로운 AGV 게임 오브젝트를 생성하고 초기화합니다.
/// </summary>
/// <param name="data">신규 AGV의 정보가 담긴 DataObject</param>
/// <returns>생성 및 초기화된 AGV 컴포넌트, 실패 시 null</returns>
private async UniTask<AGV?> CreateAGV(DataObject data)
{
if (prefab == null)
@@ -129,6 +216,7 @@ namespace UVC.Factory.Component
return null;
}
var agv = prefabInstance.GetComponent<AGV>();
// 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;
}
/// <summary>
/// AGVManager가 파괴될 때 호출됩니다.
/// MQTT 파이프라인에서 'AGV' 핸들러를 제거하여 메모리 누수를 방지합니다.
/// </summary>
protected override void OnDestroy()
{
base.OnDestroy();

View File

@@ -9,11 +9,115 @@ using UVC.UI.Info;
namespace UVC.Factory.Component
{
/// <summary>
///
/// 팩토리 내의 상호작용 가능한 3D 객체를 위한 추상 기본 클래스입니다.
/// 이 클래스는 Unity GameObject를 데이터(DataObject)와 연결하고, 사용자 상호작용(예: 클릭)을 처리하며,
/// 데이터 변경에 따라 객체의 상태(예: 색상, 애니메이션)를 업데이트하는 기능을 제공합니다.
/// </summary>
/// <remarks>
/// 팩토리 객체는 고유한 정보(Info)와 동적 데이터(data)를 가집니다.
/// 사용자가 객체를 클릭하면 OnPointerClick 이벤트가 발생하여 InfoWindow에 관련 데이터를 표시할 수 있습니다.
/// ProcessData 메서드를 재정의하여 데이터 변경 시 특정 로직을 수행하도록 구현해야 합니다.
/// </remarks>
/// <example>
/// 다음은 FactoryObject를 상속받아 'MachineObject'라는 구체적인 클래스를 만드는 예제입니다.
/// 이 예제에서는 데이터로 받은 'status' 값에 따라 머신의 색상을 변경합니다.
/// <code>
/// using UnityEngine;
/// using UVC.Data;
/// using UVC.Factory.Component;
///
/// // FactoryObject를 상속받는 MachineObject 클래스 정의
/// public class MachineObject : FactoryObject
/// {
/// private Renderer objectRenderer;
///
/// private void Awake()
/// {
/// // 색상을 변경할 렌더러 컴포넌트를 미리 찾아둡니다.
/// objectRenderer = GetComponent<Renderer>();
/// }
///
/// // 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<MeshFilter>(); // 렌더링을 위한 기본 컴포넌트
/// machineGo.AddComponent<MeshRenderer>();
/// machineGo.AddComponent<BoxCollider>(); // 클릭 이벤트를 위한 콜라이더
/// MachineObject machine = machineGo.AddComponent<MachineObject>();
///
/// // 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);
/// }
/// }
/// </code>
/// </example>
public abstract class FactoryObject : InteractiveObject
{
protected FactoryObjectInfo? info;
/// <summary>
/// 팩토리 객체의 고유한 식별 정보(ID, 이름 등)를 가져오거나 설정합니다.
/// 이 정보는 객체를 관리하고 UI에 표시하는 데 사용됩니다.
/// </summary>
public FactoryObjectInfo? Info
{
get => info;
@@ -33,7 +137,8 @@ namespace UVC.Factory.Component
protected List<string>? dataOrderedMask;
/// <summary>
/// InfoWindow에 표시 데이터의 순서와 항목을 지정하는 마스크입니다.
/// 객체 클릭 시 정보창(InfoWindow)에 표시 데이터의 순서와 항목을 지정하는 마스크입니다.
/// 이 리스트에 포함된 키의 데이터만 순서대로 표시됩니다. null이거나 비어있으면 모든 데이터를 표시합니다.
/// </summary>
public List<string>? DataOrderedMask
{
@@ -54,10 +159,11 @@ namespace UVC.Factory.Component
/// <summary>
/// 포인터 클릭 이벤트를 처리하고 관련 데이터가 포함된 정보 창을 표시합니다.
/// </summary>
/// <remarks>이 메서드는 정보 창이 현재 표시되어 있는지, 그리고
/// 유효한 데이터가 있는지 확인합니다. 데이터가 마스크를 사용하여 정렬된 경우 마스크된 데이터만 표시되고, 그렇지 않은 경우
/// 사용 가능한 모든 데이터가 표시됩니다. 정보 창은 현재
/// 변환을 기준으로 배치됩니다.</remarks>
/// <remarks>
/// 이 메서드는 `InteractiveObject`로부터 상속받아 재정의되었습니다.
/// 객체에 유효한 데이터가 있을 경우, `InfoWindow`를 통해 사용자에게 데이터를 보여줍니다.
/// `DataOrderedMask`가 설정되어 있으면 해당 순서대로, 그렇지 않으면 모든 데이터를 표시합니다.
/// </remarks>
/// <param name="eventData">포인터 클릭과 관련된 이벤트 데이터입니다.</param>
public override void OnPointerClick(PointerEventData eventData)
{
@@ -85,9 +191,11 @@ namespace UVC.Factory.Component
}
/// <summary>
/// 변경된 데이터만 업데이트합니다.
/// 외부로부터 받은 새로운 데이터로 객체의 상태를 업데이트합니다.
/// 이 메서드는 내부적으로 `ProcessData`를 호출하여 실제 데이터 처리 로직을 수행합니다.
/// MQTTPipeLineInfo.updatedDataOnly가 true인 경우, 데이터가 변경된 경우에만 호출됩니다.
/// </summary>
/// <param name="newData"></param>
/// <param name="newData">업데이트할 새로운 데이터가 포함된 IDataObject 객체입니다.</param>
public void UpdateData(IDataObject? newData)
{
if(newData == null) return;
@@ -100,19 +208,29 @@ namespace UVC.Factory.Component
/// 지정된 데이터 객체를 처리합니다. 이 메서드는 파생 클래스에서 재정의되어
/// 사용자 지정 데이터 처리 로직을 구현하도록 설계되었습니다.
/// </summary>
/// <remarks>파생 클래스는 <paramref name="newData"/> 매개변수에 대한 특정 처리 동작을 제공하기 위해
/// 이 메서드를 재정의해야 합니다.
/// 사용하기 전에 매개변수의 유효성을 검사해야 합니다.</remarks>
/// <remarks>
/// `UpdateData`가 호출될 때 실행됩니다. 파생 클래스에서는 이 메서드를 재정의하여
/// 데이터 값에 따라 객체의 색상, 애니메이션, 동작 등을 변경하는 코드를 작성해야 합니다.
/// </remarks>
/// <param name="newData">처리할 데이터 객체입니다. null일 수 없습니다.</param>
protected virtual void ProcessData(DataObject newData) {}
/// <summary>
/// 객체의 위치를 가져옵니다. 월드 좌표 또는 로컬 좌표로 반환할 수 있습니다.
/// </summary>
/// <param name="world">true이면 월드 좌표, false이면 부모 기준의 로컬 좌표를 반환합니다.</param>
/// <returns>객체의 Vector3 위치 값입니다.</returns>
public Vector3 GetPosition(bool world = false)
{
if (!world) return transform.position;
return transform.TransformPoint(transform.position);
}
/// <summary>
/// 객체의 진입점 위치를 가져옵니다. 주로 다른 객체(예: AGV)가 이 객체로 접근할 때 목표 지점으로 사용됩니다.
/// </summary>
/// <param name="world">true이면 월드 좌표, false이면 부모 기준의 로컬 좌표를 반환합니다.</param>
/// <returns>객체의 진입점 Vector3 위치 값입니다.</returns>
public Vector3 GetEntrancePosition(bool world = false)
{
if (!world) return transform.position;

View File

@@ -1,32 +1,84 @@
namespace UVC.Factory.Component
{
/// <summary>
/// 팩토리 내의 각 객체(FactoryObject)에 대한 고정적인 식별 정보를 저장하는 데이터 클래스입니다.
/// 이 클래스는 객체의 이름, ID, 위치, 구역, 층과 같이 한 번 설정된 후에는 거의 변경되지 않는 정적 데이터를 담는 데 사용됩니다.
/// </summary>
/// <remarks>
/// 이 정보는 FactoryObject가 생성될 때 할당되며, FactoryObjectManager를 통해 시스템 전체에서 객체를 식별하고 관리하는 데 사용됩니다.
/// 예를 들어, 특정 ID를 가진 기계를 찾거나 특정 구역에 있는 모든 센서를 필터링하는 등의 작업에 활용될 수 있습니다.
/// </remarks>
/// <example>
/// 다음은 `FactoryObjectInfo`를 생성하고 `FactoryObject`에 할당하는 간단한 예제입니다.
/// <code>
/// 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>(); // 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 { /* ... */ }
/// </code>
/// </example>
public class FactoryObjectInfo
{
/// <summary>
/// 이름
/// 객체의 이름입니다. UI에 표시되거나 사람이 식별할 수 있는 이름으로 사용됩니다. (예: "Main Conveyor Belt")
/// </summary>
public string Name { get; set; }
/// <summary>
/// 아이디
/// 객체의 고유 식별자(ID)입니다. 시스템에서 객체를 유일하게 구분하는 데 사용됩니다. (예: "CVB-001")
/// </summary>
public string Id { get; set; }
/// <summary>
/// 위치
/// 팩토리 내에서 객체의 물리적인 위치나 좌표를 설명합니다. (예: "A-12")
/// </summary>
public string Position { get; set; }
/// <summary>
/// 구역
/// 객체가 속한 구역이나 공정을 나타냅니다. (예: "조립 라인 1")
/// </summary>
public string Area { get; set; }
/// <summary>
///
/// 객체가 위치한 건물의 층을 나타냅니다. (예: "1F", "B2")
/// </summary>
public string Floor { get; set; }
/// <summary>
/// 모든 속성을 초기화하는 생성자입니다.
/// </summary>
/// <param name="name">객체 이름</param>
/// <param name="id">고유 ID</param>
/// <param name="position">위치 정보</param>
/// <param name="area">구역 정보</param>
/// <param name="floor">층 정보</param>
public FactoryObjectInfo(string name, string id, string position, string area, string floor)
{
Name = name;
@@ -36,12 +88,21 @@
Floor = floor;
}
/// <summary>
/// 다른 FactoryObjectInfo 객체와 모든 속성 값이 동일한지 비교합니다.
/// </summary>
/// <param name="other">비교할 다른 FactoryObjectInfo 객체입니다.</param>
/// <returns>모든 속성이 같으면 true, 그렇지 않으면 false를 반환합니다.</returns>
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;
}
/// <summary>
/// 객체의 정보를 요약된 문자열 형태로 반환합니다. 디버깅이나 로깅에 유용합니다.
/// </summary>
/// <returns>객체의 모든 속성 정보를 포함하는 문자열입니다.</returns>
public override string ToString()
{
return $"Name:{Name},Id:{Id},Position:{Position},Area:{Area},Floor:{Floor}";

View File

@@ -5,10 +5,83 @@ using UVC.Core;
namespace UVC.Factory.Component
{
/// <summary>
/// 씬에 존재하는 모든 FactoryObject 인스턴스를 관리하는 싱글톤 클래스입니다.
/// 이 매니저를 통해 팩토리 객체를 등록, 등록 해제 및 검색할 수 있습니다.
/// </summary>
/// <remarks>
/// SingletonScene을 상속받아 구현되었으므로, `FactoryObjectManager.Instance`를 통해 씬의 어디에서든 쉽게 접근할 수 있습니다.
/// FactoryObject는 Info 속성이 설정될 때 자동으로 이 매니저에 등록되며, 파괴될 때 자동으로 등록 해제됩니다.
/// 따라서 개발자가 직접 Register/Unregister 메서드를 호출할 일은 거의 없습니다.
/// </remarks>
/// <example>
/// 다음은 FactoryObjectManager를 사용하여 씬에 있는 특정 기계를 찾고 제어하는 예제입니다.
/// <code>
/// 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<FactoryObject> 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}");
/// }
/// }
/// }
/// </code>
/// </example>
public class FactoryObjectManager : SingletonScene<FactoryObjectManager>
{
/// <summary>
/// 등록된 모든 FactoryObject를 저장하는 딕셔너리입니다.
/// Key는 객체의 식별 정보인 FactoryObjectInfo이고, Value는 FactoryObject 인스턴스입니다.
/// </summary>
public Dictionary<FactoryObjectInfo, FactoryObject> FactoryObjects { get; private set; } = new Dictionary<FactoryObjectInfo, FactoryObject>();
/// <summary>
/// 새로운 FactoryObject를 매니저에 등록합니다.
/// 일반적으로 FactoryObject의 Info 속성이 설정될 때 자동으로 호출됩니다.
/// </summary>
/// <param name="factoryObject">등록할 FactoryObject 인스턴스입니다.</param>
/// <exception cref="ArgumentNullException">factoryObject 또는 factoryObject.Info가 null일 때 발생합니다.</exception>
public void RegisterFactoryObject(FactoryObject factoryObject)
{
if (factoryObject == null || factoryObject.Info == null)
@@ -21,6 +94,11 @@ namespace UVC.Factory.Component
}
}
/// <summary>
/// 매니저에서 FactoryObject의 등록을 해제합니다.
/// 일반적으로 FactoryObject가 파괴될 때(OnDestroy) 자동으로 호출됩니다.
/// </summary>
/// <param name="factoryObjectInfo">등록 해제할 객체의 FactoryObjectInfo입니다.</param>
public void UnregisterFactoryObject(FactoryObjectInfo factoryObjectInfo)
{
if (factoryObjectInfo == null)
@@ -31,6 +109,12 @@ namespace UVC.Factory.Component
FactoryObjects.Remove(factoryObjectInfo);
}
/// <summary>
/// FactoryObjectInfo를 사용하여 등록된 FactoryObject 인스턴스를 가져옵니다.
/// </summary>
/// <param name="factoryObjectInfo">찾고자 하는 객체의 FactoryObjectInfo입니다.</param>
/// <returns>발견된 FactoryObject 인스턴스. 없으면 null을 반환합니다.</returns>
/// <exception cref="ArgumentNullException">factoryObjectInfo가 null일 때 발생합니다.</exception>
public FactoryObject? GetFactoryObject(FactoryObjectInfo factoryObjectInfo)
{
if (factoryObjectInfo == null)
@@ -41,6 +125,11 @@ namespace UVC.Factory.Component
return factoryObject;
}
/// <summary>
/// 객체의 이름(Name)으로 FactoryObject를 검색합니다. 대소문자를 구분하지 않습니다.
/// </summary>
/// <param name="name">찾고자 하는 객체의 이름입니다.</param>
/// <returns>처음으로 발견된 일치하는 FactoryObject. 없으면 null을 반환합니다.</returns>
public FactoryObject? FindByName(string name)
{
foreach (var kvp in FactoryObjects)
@@ -53,6 +142,11 @@ namespace UVC.Factory.Component
return null;
}
/// <summary>
/// 객체의 고유 ID로 FactoryObject를 검색합니다. 대소문자를 구분하지 않습니다.
/// </summary>
/// <param name="id">찾고자 하는 객체의 ID입니다.</param>
/// <returns>처음으로 발견된 일치하는 FactoryObject. 없으면 null을 반환합니다.</returns>
public FactoryObject? FindById(string id)
{
foreach (var kvp in FactoryObjects)
@@ -65,6 +159,11 @@ namespace UVC.Factory.Component
return null;
}
/// <summary>
/// 객체의 위치(Position) 정보로 FactoryObject를 검색합니다. 대소문자를 구분하지 않습니다.
/// </summary>
/// <param name="position">찾고자 하는 객체의 위치 정보입니다.</param>
/// <returns>처음으로 발견된 일치하는 FactoryObject. 없으면 null을 반환합니다.</returns>
public FactoryObject? FindByPosition(string position)
{
foreach (var kvp in FactoryObjects)
@@ -77,6 +176,11 @@ namespace UVC.Factory.Component
return null;
}
/// <summary>
/// 특정 구역(Area)에 속한 모든 FactoryObject를 검색합니다. 대소문자를 구분하지 않습니다.
/// </summary>
/// <param name="area">찾고자 하는 구역의 이름입니다.</param>
/// <returns>발견된 모든 FactoryObject의 리스트. 없으면 빈 리스트를 반환합니다.</returns>
public List<FactoryObject> FindByArea(string area)
{
List<FactoryObject> foundObjects = new List<FactoryObject>();
@@ -90,6 +194,11 @@ namespace UVC.Factory.Component
return foundObjects;
}
/// <summary>
/// 특정 층(Floor)에 속한 모든 FactoryObject를 검색합니다. 대소문자를 구분하지 않습니다.
/// </summary>
/// <param name="floor">찾고자 하는 층의 이름입니다.</param>
/// <returns>발견된 모든 FactoryObject의 리스트. 없으면 빈 리스트를 반환합니다.</returns>
public List<FactoryObject> FindByFloor(string floor)
{
List<FactoryObject> foundObjects = new List<FactoryObject>();

View File

@@ -18,7 +18,7 @@ namespace UVC.Tests.Data
public class HttpPipeLineTests
{
// 테스트에 사용할 HttpPipeLine 인스턴스
private HttpPipeLine pipeLine;
private HttpPipeLine? pipeLine;
/// <summary>
/// 각 테스트 실행 전에 호출되는 설정 메서드입니다.
@@ -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
/// <summary>
/// DataValidator를 사용하여 유효한 데이터를 성공적으로 처리하는지 테스트합니다.
/// </summary>
[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);
}
}
/// <summary>
/// DataValidator를 사용하여 유효하지 않은 데이터를 필터링하는지 테스트합니다.
/// </summary>
[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);
}
}
/// <summary>
/// DataValidator가 배열 데이터에서 유효한 항목만 필터링하는지 테스트합니다.
/// </summary>
[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
}
}

View File

@@ -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
/// <summary>
/// DataValidator를 사용하여 유효한 데이터를 성공적으로 처리하는지 테스트합니다.
/// </summary>
[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"));
}
/// <summary>
/// DataValidator를 사용하여 유효하지 않은 데이터를 필터링하는지 테스트합니다.
/// </summary>
[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, "유효성 검사에 실패했으므로 핸들러가 호출되지 않아야 합니다.");
}
/// <summary>
/// DataValidator가 배열 데이터에서 유효한 항목만 필터링하는지 테스트합니다.
/// </summary>
[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 메서드를 테스트하기 위한 확장 클래스

View File

@@ -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();
}
}
}

View File

@@ -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
}
/// <summary>
/// SynchronizationContext를 통해 메인 스레드에서 액션을 실행합니다.
/// 메인 스레드에서 액션을 실행합니다. (Fire-and-forget)
/// </summary>
/// <param name="action">메인 스레드에서 실행할 액션</param>
public void SendToMainThread(Action action)
@@ -184,6 +185,56 @@ namespace UVC.Threading
}
}
/// <summary>
/// 메인 스레드에서 액션을 비동기적으로 실행하고, 해당 액션의 완료를 나타내는 UniTask를 반환합니다.
/// </summary>
/// <param name="action">메인 스레드에서 실행할 액션입니다.</param>
/// <returns>액션의 실행이 완료되면 완료되는 UniTask입니다.</returns>
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()
{
// 큐에 있는 모든 액션 처리

View File

@@ -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
/// </summary>
public bool IsVisible => gameObject.activeSelf;
private RectTransform? rectTransform;
protected override void Awake()
{
base.Awake();
rectTransform = GetComponent<RectTransform>();
// 닫기 버튼이 할당되었으면 클릭 이벤트를 연결합니다.
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<Canvas>();
if (canvas != null)
{
// 캔버스의 렌더링 모드에 따라 다르게 처리
Canvas canvas = GetComponentInParent<Canvas>();
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<RectTransform>(),
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<RectTransform>(),
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<Canvas>();
@@ -142,28 +136,8 @@ namespace UVC.UI.Info
public void Show(Transform targetObject, Dictionary<string, object> 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<RectTransform>();
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<string, object> information)
{
if (target == null) return;
if (text != null)
{
string combinedString = string.Empty;
foreach (var kvp in information)
{
// <indent> 태그를 사용하여 줄바꿈 시에도 정렬이 유지되도록 합니다.
combinedString += $"{kvp.Key}:<pos=40%><indent=40%>{kvp.Value ?? "null"}</indent>\n";
}
combinedString = combinedString.TrimEnd('\n'); // 마지막 줄바꿈 제거
text.text = combinedString;
}
// size를 text에 맞게 조정합니다.
RectTransform? rect = GetComponent<RectTransform>();
if (rect != null && text != null)
{
RectTransform textRect = text.GetComponent<RectTransform>();
float marginHeight = rect.rect.height - textRect.rect.height; // 상하 여백
rect.sizeDelta = new Vector2(rect.rect.width, text.preferredHeight + marginHeight);
}
}
/// <summary>
/// 정보 창을 숨깁니다.
/// </summary>

View File

@@ -27,7 +27,7 @@ namespace UVC.UI.Menu
///
/// // 기존 모델에 새로운 메뉴 아이템 추가 또는 수정
/// // 예: '도움말' 메뉴 추가
/// model.MenuItems.Add(new MenuItemData("help", "menu_help", subMenuItems: new List&lt;MenuItemData&gt;
/// model.MenuItems.Add(new MenuItemData("help", "menu_help", subMenuItems: new List<MenuItemData>;
/// {
/// new MenuItemData("help_about", "menu_help_about", new DebugLogCommand("도움말 > 정보 선택됨"))
/// }));