From fb51e94d88c031fca96929e128c1bdc36502a7f1 Mon Sep 17 00:00:00 2001 From: swrring Date: Fri, 17 Apr 2026 20:31:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(core):=20Tier=201=20=EC=9E=94=EC=97=AC=203?= =?UTF-8?q?=EC=A2=85=20=E2=80=94=20Event=20=C2=B7=20Container=20=C2=B7=20D?= =?UTF-8?q?ata=20(PD=EB=8B=98=20#36=20=EC=A6=89=EC=8B=9C=20=EC=88=98?= =?UTF-8?q?=ED=96=89=20=EC=A7=80=EC=8B=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Event: EventBus (타입 안전 정적 버스) + Raw.RawEventBus (문자열 키 특수 용도) - Container: ObservableList / ObservableDictionary / ObservableQueue (UniList/UniEventList/UniObserverList 3종 통합 대체) - Data: IDataRow / DataTable / DataTableSO / DataTableLoader (CSV RFC 4180 최소 + JsonUtility) / DataTableLoadedEvent - 단위 테스트 5종 신설 (EventBus · 3 Observable · DataTable) - 설계 근거: 프로젝트/코어프레임워크/04_Tier1_3종_상호작용_설계_v1.md (P18) - Tier 1 총 16/16종 완료. DAG: Data/Container → Event (순환 없음) Co-Authored-By: Claude Opus 4.7 (1M context) --- 코어코드/NerdNavis.Framework/CHANGELOG.md | 17 ++ .../Core/Container/ObservableDictionary.cs | 99 +++++++++ .../Runtime/Core/Container/ObservableList.cs | 105 ++++++++++ .../Runtime/Core/Container/ObservableQueue.cs | 72 +++++++ .../Runtime/Core/Data/DataTable.cs | 73 +++++++ .../Runtime/Core/Data/DataTableLoadedEvent.cs | 30 +++ .../Runtime/Core/Data/DataTableLoader.cs | 192 ++++++++++++++++++ .../Runtime/Core/Data/DataTableSO.cs | 46 +++++ .../Runtime/Core/Data/IDataRow.cs | 16 ++ .../Runtime/Core/Event/EventBus.cs | 136 +++++++++++++ .../Runtime/Core/Event/Raw/RawEventBus.cs | 77 +++++++ .../Core/Container/ObservableDictionaryTests.cs | 109 ++++++++++ .../Runtime/Core/Container/ObservableListTests.cs | 144 +++++++++++++ .../Core/Container/ObservableQueueTests.cs | 92 +++++++++ .../Tests/Runtime/Core/Data/DataTableTests.cs | 184 +++++++++++++++++ .../Tests/Runtime/Core/Event/EventBusTests.cs | 141 +++++++++++++ 16 files changed, 1533 insertions(+) create mode 100644 코어코드/NerdNavis.Framework/Runtime/Core/Container/ObservableDictionary.cs create mode 100644 코어코드/NerdNavis.Framework/Runtime/Core/Container/ObservableList.cs create mode 100644 코어코드/NerdNavis.Framework/Runtime/Core/Container/ObservableQueue.cs create mode 100644 코어코드/NerdNavis.Framework/Runtime/Core/Data/DataTable.cs create mode 100644 코어코드/NerdNavis.Framework/Runtime/Core/Data/DataTableLoadedEvent.cs create mode 100644 코어코드/NerdNavis.Framework/Runtime/Core/Data/DataTableLoader.cs create mode 100644 코어코드/NerdNavis.Framework/Runtime/Core/Data/DataTableSO.cs create mode 100644 코어코드/NerdNavis.Framework/Runtime/Core/Data/IDataRow.cs create mode 100644 코어코드/NerdNavis.Framework/Runtime/Core/Event/EventBus.cs create mode 100644 코어코드/NerdNavis.Framework/Runtime/Core/Event/Raw/RawEventBus.cs create mode 100644 코어코드/NerdNavis.Framework/Tests/Runtime/Core/Container/ObservableDictionaryTests.cs create mode 100644 코어코드/NerdNavis.Framework/Tests/Runtime/Core/Container/ObservableListTests.cs create mode 100644 코어코드/NerdNavis.Framework/Tests/Runtime/Core/Container/ObservableQueueTests.cs create mode 100644 코어코드/NerdNavis.Framework/Tests/Runtime/Core/Data/DataTableTests.cs create mode 100644 코어코드/NerdNavis.Framework/Tests/Runtime/Core/Event/EventBusTests.cs diff --git a/코어코드/NerdNavis.Framework/CHANGELOG.md b/코어코드/NerdNavis.Framework/CHANGELOG.md index 9060610..d5a9c40 100644 --- a/코어코드/NerdNavis.Framework/CHANGELOG.md +++ b/코어코드/NerdNavis.Framework/CHANGELOG.md @@ -21,6 +21,23 @@ - `NerdNavis.Core.Util.KeyMaker` — `':'` 구분자 합성 키 생성 - `NerdNavis.Core.Util.ValidationEx` — 인자·상태 가드(NotNull·InRange·Positive 등) - 위 9종 단위 테스트 추가 (NUnit, Tests/Runtime/Core/Util + Attribute) +- Tier 1 Event 모듈 (2026-04-17, PD님 #36 즉시 수행 지시) + - `NerdNavis.Core.Event.EventBus` — 타입 안전 프로세스 내 이벤트 버스 (Subscribe/Unsubscribe/Publish/Clear/ClearAll) + - `NerdNavis.Core.Event.Raw.RawEventBus` — 문자열 키 기반 특수 용도 버스 (박싱 비용 주의) + - 설계 근거: `프로젝트/코어프레임워크/04_Tier1_3종_상호작용_설계_v1.md` §3-1 +- Tier 1 Container 모듈 (2026-04-17) + - `NerdNavis.Core.Container.ObservableList` — Added/Removed/Reset 이벤트 내장 + - `NerdNavis.Core.Container.ObservableDictionary` — Added/Removed/Updated/Reset 이벤트 내장 + - `NerdNavis.Core.Container.ObservableQueue` — Enqueued/Dequeued/Reset 이벤트 내장 + - 기존 `UniList`·`UniEventList`·`UniObserverList` 3종 통합 대체 (설계 §4-3) +- Tier 1 Data 모듈 (2026-04-17) + - `NerdNavis.Core.Data.IDataRow` — 행 키 추출 계약 + - `NerdNavis.Core.Data.DataTable` — 불변 마스터 테이블 (키·행 조회) + - `NerdNavis.Core.Data.DataTableSO` — Unity ScriptableObject 래퍼 + - `NerdNavis.Core.Data.DataTableLoader` — CSV(RFC 4180 최소) + JsonUtility 기반 JSON 로드 + - `NerdNavis.Core.Data.DataTableLoadedEvent` — 로드 완료 이벤트 payload (EventBus 연동 옵션) + - 기존 `MasterTableBase`·`MasterTableSO` 재설계 대체 +- 위 Event·Container·Data 단위 테스트 추가 (EventBusTests·ObservableListTests·ObservableDictionaryTests·ObservableQueueTests·DataTableTests) ## [0.1.0] - TBD diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Container/ObservableDictionary.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Container/ObservableDictionary.cs new file mode 100644 index 0000000..35412bb --- /dev/null +++ b/코어코드/NerdNavis.Framework/Runtime/Core/Container/ObservableDictionary.cs @@ -0,0 +1,99 @@ +// --------------------------------------------------------------------------- +// NerdNavis.Framework +// ObservableDictionary.cs — 변경 이벤트를 내장한 Dictionary +// --------------------------------------------------------------------------- +using System; +using System.Collections; +using System.Collections.Generic; + +namespace NerdNavis.Core.Container +{ + /// + /// Add/Remove/Update/Reset 이벤트를 인스턴스 단위로 발행하는 . + /// + /// + /// 설계 문서 §3-2. 기존 UniDictionary 상응 통합 대체. + /// Updated 는 동일 키에 새 값이 대입될 때만 발행, 기존 키가 없던 경우에는 Added 로 처리. + /// + public class ObservableDictionary : IDictionary + { + private readonly Dictionary _map; + + public event Action Added; + public event Action Removed; + /// 동일 키에 새 값 대입 시 발행. 인자는 (key, oldValue, newValue). + public event Action Updated; + public event Action Reset; + + public ObservableDictionary() { _map = new Dictionary(); } + public ObservableDictionary(int capacity) { _map = new Dictionary(capacity); } + public ObservableDictionary(IEqualityComparer comparer) { _map = new Dictionary(comparer); } + + public TValue this[TKey key] + { + get => _map[key]; + set + { + if (_map.TryGetValue(key, out var old)) + { + _map[key] = value; + Updated?.Invoke(key, old, value); + } + else + { + _map[key] = value; + Added?.Invoke(key, value); + } + } + } + + public ICollection Keys => _map.Keys; + public ICollection Values => _map.Values; + public int Count => _map.Count; + public bool IsReadOnly => false; + + public void Add(TKey key, TValue value) + { + _map.Add(key, value); + Added?.Invoke(key, value); + } + + public void Add(KeyValuePair item) => Add(item.Key, item.Value); + + public bool Remove(TKey key) + { + if (!_map.TryGetValue(key, out var old)) return false; + _map.Remove(key); + Removed?.Invoke(key, old); + return true; + } + + public bool Remove(KeyValuePair item) + { + if (!_map.TryGetValue(item.Key, out var existing) || + !EqualityComparer.Default.Equals(existing, item.Value)) return false; + return Remove(item.Key); + } + + public void Clear() + { + if (_map.Count == 0) return; + _map.Clear(); + Reset?.Invoke(); + } + + public bool Contains(KeyValuePair item) + => _map.TryGetValue(item.Key, out var v) && EqualityComparer.Default.Equals(v, item.Value); + + public bool ContainsKey(TKey key) => _map.ContainsKey(key); + + public void CopyTo(KeyValuePair[] array, int arrayIndex) + => ((ICollection>)_map).CopyTo(array, arrayIndex); + + public bool TryGetValue(TKey key, out TValue value) => _map.TryGetValue(key, out value); + + public Dictionary.Enumerator GetEnumerator() => _map.GetEnumerator(); + IEnumerator> IEnumerable>.GetEnumerator() => _map.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => _map.GetEnumerator(); + } +} diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Container/ObservableList.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Container/ObservableList.cs new file mode 100644 index 0000000..eabf8c1 --- /dev/null +++ b/코어코드/NerdNavis.Framework/Runtime/Core/Container/ObservableList.cs @@ -0,0 +1,105 @@ +// --------------------------------------------------------------------------- +// NerdNavis.Framework +// ObservableList.cs — 변경 이벤트를 내장한 List +// --------------------------------------------------------------------------- +using System; +using System.Collections; +using System.Collections.Generic; + +namespace NerdNavis.Core.Container +{ + /// + /// 인스턴스 단위로 Add/Remove/Reset 이벤트를 발행하는 구현. + /// + /// + /// 설계 문서 §3-2 (04_Tier1_3종_상호작용_설계_v1.md). 기존 UniList·UniEventList·UniObserverList + /// 3종을 본 1종으로 통합했다(§01 §4-3). + /// 계약: + /// + /// 이벤트는 인스턴스 멤버. 전역 전파가 필요하면 호출자가 수신 후 EventBus.Publish 로 다시 발행. + /// 이벤트 핸들러에서 재변경이 일어나도 현재 호출은 깨지지 않는다 (내부 snapshot). + /// null 핸들러 안전 (?.Invoke). + /// Reset 은 와 대량 교체( ) 때 발행. + /// + /// + public class ObservableList : IList, IReadOnlyList + { + private readonly List _items; + + /// Add·Insert 발생 시 발행. 인자는 (index, item). + public event Action Added; + /// Remove·RemoveAt 발생 시 발행. 인자는 (index, item). + public event Action Removed; + /// · 발생 시 발행. + public event Action Reset; + + public ObservableList() { _items = new List(); } + public ObservableList(int capacity) { _items = new List(capacity); } + public ObservableList(IEnumerable source) { _items = new List(source); } + + public T this[int index] + { + get => _items[index]; + set + { + // 인덱서 교체는 "기존 제거 + 신규 추가" 시퀀스로 모델링. + T old = _items[index]; + _items[index] = value; + Removed?.Invoke(index, old); + Added?.Invoke(index, value); + } + } + + public int Count => _items.Count; + public bool IsReadOnly => false; + + public void Add(T item) + { + _items.Add(item); + Added?.Invoke(_items.Count - 1, item); + } + + public void Insert(int index, T item) + { + _items.Insert(index, item); + Added?.Invoke(index, item); + } + + public bool Remove(T item) + { + int idx = _items.IndexOf(item); + if (idx < 0) return false; + RemoveAt(idx); + return true; + } + + public void RemoveAt(int index) + { + T removed = _items[index]; + _items.RemoveAt(index); + Removed?.Invoke(index, removed); + } + + public void Clear() + { + if (_items.Count == 0) return; + _items.Clear(); + Reset?.Invoke(); + } + + /// 전체 원소를 로 교체. 단일 Reset 이벤트로 통지. + public void ReplaceAll(IEnumerable source) + { + _items.Clear(); + if (source != null) _items.AddRange(source); + Reset?.Invoke(); + } + + public bool Contains(T item) => _items.Contains(item); + public int IndexOf(T item) => _items.IndexOf(item); + public void CopyTo(T[] array, int arrayIndex) => _items.CopyTo(array, arrayIndex); + public List.Enumerator GetEnumerator() => _items.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => _items.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => _items.GetEnumerator(); + } +} diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Container/ObservableQueue.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Container/ObservableQueue.cs new file mode 100644 index 0000000..ecdd93f --- /dev/null +++ b/코어코드/NerdNavis.Framework/Runtime/Core/Container/ObservableQueue.cs @@ -0,0 +1,72 @@ +// --------------------------------------------------------------------------- +// NerdNavis.Framework +// ObservableQueue.cs — 변경 이벤트를 내장한 Queue +// --------------------------------------------------------------------------- +using System; +using System.Collections; +using System.Collections.Generic; + +namespace NerdNavis.Core.Container +{ + /// + /// Enqueue/Dequeue/Reset 이벤트를 인스턴스 단위로 발행하는 FIFO 큐. + /// + /// + /// 설계 문서 §3-2. 기존 UniQueue·UniEventQueue·UniObserverQueue 3종 통합 대체. + /// + public class ObservableQueue : IReadOnlyCollection + { + private readonly Queue _items; + + public event Action Enqueued; + public event Action Dequeued; + public event Action Reset; + + public ObservableQueue() { _items = new Queue(); } + public ObservableQueue(int capacity) { _items = new Queue(capacity); } + + public int Count => _items.Count; + + public void Enqueue(T item) + { + _items.Enqueue(item); + Enqueued?.Invoke(item); + } + + public T Dequeue() + { + T item = _items.Dequeue(); + Dequeued?.Invoke(item); + return item; + } + + public bool TryDequeue(out T item) + { + if (_items.Count == 0) { item = default; return false; } + item = _items.Dequeue(); + Dequeued?.Invoke(item); + return true; + } + + public T Peek() => _items.Peek(); + public bool TryPeek(out T item) + { + if (_items.Count == 0) { item = default; return false; } + item = _items.Peek(); + return true; + } + + public void Clear() + { + if (_items.Count == 0) return; + _items.Clear(); + Reset?.Invoke(); + } + + public bool Contains(T item) => _items.Contains(item); + + public Queue.Enumerator GetEnumerator() => _items.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => _items.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => _items.GetEnumerator(); + } +} diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Data/DataTable.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Data/DataTable.cs new file mode 100644 index 0000000..914c442 --- /dev/null +++ b/코어코드/NerdNavis.Framework/Runtime/Core/Data/DataTable.cs @@ -0,0 +1,73 @@ +// --------------------------------------------------------------------------- +// NerdNavis.Framework +// DataTable.cs — 불변 마스터 테이블 (키·행) +// --------------------------------------------------------------------------- +using System; +using System.Collections.Generic; + +namespace NerdNavis.Core.Data +{ + /// + /// 로드 완료 이후 내용이 변경되지 않는 키·행 매핑 스냅샷. + /// + /// 행 키 타입 (보통 int·string·enum). + /// 행 타입. 를 구현해야 함. + /// + /// 설계 문서 §3-3 (04_Tier1_3종_상호작용_설계_v1.md). 기존 MasterTableBase 를 본 클래스로 재설계. + /// 계약: + /// + /// 생성 후 내용·길이는 변경되지 않는다 (불변 스냅샷). + /// 동일 키가 중복 등장하면 . + /// 런타임 쓰기가 필요하면 사용 (Container 모듈과 역할 분리). + /// + /// + public class DataTable where TRow : IDataRow + { + private readonly TRow[] _rows; + private readonly Dictionary _byKey; + + /// 행의 읽기 전용 뷰 (원본 순서 보존). + public IReadOnlyList Rows => _rows; + + /// 행 개수. + public int Count => _rows.Length; + + /// + /// 주어진 행 집합으로 테이블을 생성한다. 행 순서는 입력 순서를 보존한다. + /// + /// 가 null. + /// 중복 키가 감지됨. + public DataTable(IEnumerable rows) + { + if (rows == null) throw new ArgumentNullException(nameof(rows)); + var list = new List(); + var map = new Dictionary(); + foreach (var row in rows) + { + if (row == null) continue; + TKey key = row.Key; + if (map.ContainsKey(key)) + throw new ArgumentException($"중복 키: {key}", nameof(rows)); + map.Add(key, row); + list.Add(row); + } + _rows = list.ToArray(); + _byKey = map; + } + + /// 키에 해당하는 행을 반환한다. + /// 키 미등록. + public TRow Get(TKey key) + { + if (!_byKey.TryGetValue(key, out var row)) + throw new KeyNotFoundException($"키 미등록: {key}"); + return row; + } + + /// 키에 해당하는 행을 안전하게 조회한다. + public bool TryGet(TKey key, out TRow row) => _byKey.TryGetValue(key, out row); + + /// 키 등록 여부. + public bool Contains(TKey key) => _byKey.ContainsKey(key); + } +} diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Data/DataTableLoadedEvent.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Data/DataTableLoadedEvent.cs new file mode 100644 index 0000000..1cc9cb1 --- /dev/null +++ b/코어코드/NerdNavis.Framework/Runtime/Core/Data/DataTableLoadedEvent.cs @@ -0,0 +1,30 @@ +// --------------------------------------------------------------------------- +// NerdNavis.Framework +// DataTableLoadedEvent.cs — 마스터 테이블 로드 완료 이벤트 payload +// --------------------------------------------------------------------------- +using System; + +namespace NerdNavis.Core.Data +{ + /// + /// 마스터 테이블 로드 완료를 전역 통지할 때 로 Publish 하는 이벤트. + /// + /// + /// 설계 문서 §3-3. Data 모듈은 본 struct 를 정의만 하며, 실제 Publish 는 호출자 선택 사항. + /// Data 가 Event 에 강제 의존하지 않도록 하기 위함(설계 §2 DAG). + /// + public readonly struct DataTableLoadedEvent + { + /// 로드된 테이블의 행 타입 (식별자). + public readonly Type TableType; + + /// 로드된 행 개수. + public readonly int RowCount; + + public DataTableLoadedEvent(Type tableType, int rowCount) + { + TableType = tableType; + RowCount = rowCount; + } + } +} diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Data/DataTableLoader.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Data/DataTableLoader.cs new file mode 100644 index 0000000..5b8c53e --- /dev/null +++ b/코어코드/NerdNavis.Framework/Runtime/Core/Data/DataTableLoader.cs @@ -0,0 +1,192 @@ +// --------------------------------------------------------------------------- +// NerdNavis.Framework +// DataTableLoader.cs — CSV·JSON 텍스트에서 DataTable 구축 헬퍼 +// --------------------------------------------------------------------------- +using System; +using System.Collections.Generic; +using System.Text; +using UnityEngine; + +namespace NerdNavis.Core.Data +{ + /// + /// 텍스트 원본(CSV·JSON)으로부터 를 구축하는 정적 헬퍼. + /// + /// + /// 설계 문서 §3-3·§6-5·§6-6. Tier 1 외부 의존성 최소 원칙에 따라: + /// + /// CSV: 쉼표·따옴표 이스케이프·줄바꿈을 처리하는 최소 자체 파서. + /// JSON: Unity 기반. Dictionary·polymorphism 미지원 — 고급 케이스는 호출자가 자체 파싱. + /// + /// + public static class DataTableLoader + { + // ----------------------------------------------------------------- + // CSV + // ----------------------------------------------------------------- + + /// + /// CSV 텍스트를 파싱하여 를 생성한다. + /// + /// 행 키 타입. + /// 행 타입 ( 구현). + /// 원본 텍스트. + /// 컬럼 배열 → 행 인스턴스 변환기. + /// 첫 줄을 헤더로 간주하여 건너뛸지 여부. 기본 true. + public static DataTable FromCsv( + string csvText, + Func rowFactory, + bool skipHeader = true) + where TRow : IDataRow + { + if (csvText == null) throw new ArgumentNullException(nameof(csvText)); + if (rowFactory == null) throw new ArgumentNullException(nameof(rowFactory)); + + var rows = new List(); + var lines = ParseCsv(csvText); + int start = skipHeader && lines.Count > 0 ? 1 : 0; + for (int i = start; i < lines.Count; i++) + { + string[] cols = lines[i]; + if (IsEmptyRow(cols)) continue; + TRow row = rowFactory(cols); + if (row != null) rows.Add(row); + } + return new DataTable(rows); + } + + private static bool IsEmptyRow(string[] cols) + { + if (cols == null || cols.Length == 0) return true; + for (int i = 0; i < cols.Length; i++) + { + if (!string.IsNullOrEmpty(cols[i])) return false; + } + return true; + } + + /// + /// CSV 텍스트를 행·컬럼 2차원 리스트로 파싱 (RFC 4180 준수 최소 구현). + /// + /// 쉼표·따옴표·따옴표 내 줄바꿈·연속 따옴표(""") 처리. + public static List ParseCsv(string csvText) + { + var result = new List(); + if (string.IsNullOrEmpty(csvText)) return result; + + var currentRow = new List(); + var field = new StringBuilder(); + bool inQuotes = false; + int len = csvText.Length; + + for (int i = 0; i < len; i++) + { + char c = csvText[i]; + if (inQuotes) + { + if (c == '"') + { + if (i + 1 < len && csvText[i + 1] == '"') + { + field.Append('"'); + i++; + } + else + { + inQuotes = false; + } + } + else + { + field.Append(c); + } + } + else + { + if (c == '"') + { + inQuotes = true; + } + else if (c == ',') + { + currentRow.Add(field.ToString()); + field.Length = 0; + } + else if (c == '\r') + { + // \r\n 또는 단독 \r 모두 줄 끝으로 취급 + if (i + 1 < len && csvText[i + 1] == '\n') i++; + currentRow.Add(field.ToString()); + field.Length = 0; + result.Add(currentRow.ToArray()); + currentRow.Clear(); + } + else if (c == '\n') + { + currentRow.Add(field.ToString()); + field.Length = 0; + result.Add(currentRow.ToArray()); + currentRow.Clear(); + } + else + { + field.Append(c); + } + } + } + + // 마지막 필드·행 flush + if (field.Length > 0 || currentRow.Count > 0) + { + currentRow.Add(field.ToString()); + result.Add(currentRow.ToArray()); + } + + return result; + } + + // ----------------------------------------------------------------- + // JSON (Unity JsonUtility) + // ----------------------------------------------------------------- + + /// + /// JSON 배열 래퍼 텍스트({"rows":[...]}) 를 파싱하여 를 생성한다. + /// + /// + /// Unity 는 최상위 배열을 직접 역직렬화하지 못하므로, + /// 안에 공개 List<TRow> rows 필드 또는 + /// TRow[] rows 필드를 두고 감싸는 방식을 사용한다. 래퍼는 를 + /// 직접 쓰거나, 호출자가 custom class 로 만들어 를 제공한다. + /// 고급 케이스(Dictionary 키·polymorphism)는 호출자가 자체 파싱 후 + /// 생성자에 직접 전달. + /// + /// 행 키 타입. + /// 행 타입. + /// JSON 루트 래퍼 클래스 (반드시 ). + /// 원본 JSON. + /// 래퍼에서 행 목록을 꺼내는 함수. + public static DataTable FromJson( + string jsonText, + Func> extractRows) + where TRow : IDataRow + where TRowWrapper : class + { + if (jsonText == null) throw new ArgumentNullException(nameof(jsonText)); + if (extractRows == null) throw new ArgumentNullException(nameof(extractRows)); + TRowWrapper wrapper = JsonUtility.FromJson(jsonText); + if (wrapper == null) return new DataTable(new List()); + var rows = extractRows(wrapper); + return new DataTable(rows ?? new List()); + } + } + + /// + /// 사용 시 기본 래퍼. + /// + /// JSON 예: {"rows":[{...},{...}]}. + [Serializable] + public class RowListWrapper + { + public List rows; + } +} diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Data/DataTableSO.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Data/DataTableSO.cs new file mode 100644 index 0000000..d684505 --- /dev/null +++ b/코어코드/NerdNavis.Framework/Runtime/Core/Data/DataTableSO.cs @@ -0,0 +1,46 @@ +// --------------------------------------------------------------------------- +// NerdNavis.Framework +// DataTableSO.cs — DataTable의 Unity ScriptableObject 래퍼 +// --------------------------------------------------------------------------- +using System.Collections.Generic; +using UnityEngine; + +namespace NerdNavis.Core.Data +{ + /// + /// 를 담는 기반 자산. + /// + /// + /// 설계 문서 §3-3. 기존 MasterTableSO 를 본 클래스로 재설계. + /// 하위 타입은 를 구현하여 인스펙터 상에서 입력한 원본을 + /// 리스트로 가공해 반환한다. 가 호출되면 이 초기화된다. + /// 이벤트 통지가 필요하면 호출자가 로 + /// 를 발행한다 (Data 는 Event 에 런타임 의존하지 않음). + /// + public abstract class DataTableSO : ScriptableObject where TRow : IDataRow + { + private DataTable _table; + + /// 로드된 테이블. 호출 이전에는 null. + public DataTable Table => _table; + + /// 로드 완료 여부. + public bool IsLoaded => _table != null; + + /// 테이블을 구축한다. 이미 로드된 상태면 스킵 (명시적 재로드는 사용). + public void Load() + { + if (_table != null) return; + _table = new DataTable(BuildRows()); + } + + /// 테이블을 강제로 재구축한다. + public void Reload() + { + _table = new DataTable(BuildRows()); + } + + /// 하위 타입이 행 원본을 공급한다 (인스펙터 직렬화 필드·CSV·JSON 등에서 유래). + protected abstract IEnumerable BuildRows(); + } +} diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Data/IDataRow.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Data/IDataRow.cs new file mode 100644 index 0000000..ca2a038 --- /dev/null +++ b/코어코드/NerdNavis.Framework/Runtime/Core/Data/IDataRow.cs @@ -0,0 +1,16 @@ +// --------------------------------------------------------------------------- +// NerdNavis.Framework +// IDataRow.cs — 마스터 테이블 행의 키 추출 인터페이스 +// --------------------------------------------------------------------------- +namespace NerdNavis.Core.Data +{ + /// + /// 마스터 테이블 행이 자신의 키를 노출하기 위한 인터페이스. + /// + /// 키 타입 (int·string·enum 등). + public interface IDataRow + { + /// 테이블 내 고유 키. + TKey Key { get; } + } +} diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Event/EventBus.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Event/EventBus.cs new file mode 100644 index 0000000..41f1489 --- /dev/null +++ b/코어코드/NerdNavis.Framework/Runtime/Core/Event/EventBus.cs @@ -0,0 +1,136 @@ +// --------------------------------------------------------------------------- +// NerdNavis.Framework +// EventBus.cs — 타입 안전 프로세스 내 이벤트 버스 (정적) +// --------------------------------------------------------------------------- +using System; +using System.Collections.Generic; +using NerdNavis.Core.Util.Log; + +namespace NerdNavis.Core.Event +{ + /// + /// 타입을 키로 사용하는 프로세스 내 이벤트 버스. + /// + /// + /// 설계 문서 §3-1 (04_Tier1_3종_상호작용_설계_v1.md). 기존 ObserverManager 의 + /// 문자열 키·박싱 비용·프로젝트 특화 키 생성기 오염 문제를 해결하기 위해 제네릭 타입 버스로 재설계. + /// 계약: + /// + /// 메인 스레드 전용 (Unity 표준). 멀티스레드 지원은 Tier 3 네트워크 도입 시 재검토. + /// 중 핸들러가 예외를 던져도 나머지 구독자 호출 보장 (catch-and-log). + /// 진행 중 발생한 Subscribe/Unsubscribe 는 다음 Publish 에 반영(iteration snapshot). + /// 이벤트 타입은 struct 권장 (박싱 회피). class 도 허용. + /// 약한 참조 방식 미채택 — 명시적 의무 (설계 문서 §6-2 기각안 참조). + /// + /// 문자열 키 기반 특수 용도는 분리. + /// + public static class EventBus + { + /// + /// 타입별 구독자 목록 저장소. 동일 타입에 대한 List 는 핸들러 집합. + /// + /// + /// 제네릭 정적 클래스 를 통해 타입별 List 를 캐싱, Dictionary lookup 비용 제거 (C11 자원 효율). + /// + private static class Holder + { + public static readonly List> Handlers = new List>(4); + } + + /// + /// 전체 타입 핸들러 목록을 역으로 추적하는 메타 테이블 ( 용). + /// + private static readonly List ClearCallbacks = new List(); + + /// 타입별 초기화 1회 flag. + private static readonly HashSet RegisteredTypes = new HashSet(); + + /// + /// 해당 이벤트 타입에 대한 핸들러를 등록한다. + /// + /// 이벤트 타입. + /// 호출 콜백. + /// 가 null 일 때. + public static void Subscribe(Action handler) + { + if (handler == null) throw new ArgumentNullException(nameof(handler)); + EnsureRegistered(); + Holder.Handlers.Add(handler); + } + + /// + /// 등록된 핸들러를 제거한다. 동일 핸들러가 여러 번 등록됐다면 가장 먼저 발견된 1개만 제거한다. + /// + /// 이벤트 타입. + /// 해제할 콜백. + /// 실제로 제거되었으면 true, 미등록 상태면 false. + public static bool Unsubscribe(Action handler) + { + if (handler == null) return false; + return Holder.Handlers.Remove(handler); + } + + /// + /// 해당 이벤트를 모든 구독자에게 전달한다. + /// + /// 이벤트 타입. + /// 이벤트 인스턴스 (struct 권장). + /// + /// 핸들러 중 하나가 예외를 던져도 나머지 핸들러는 호출된다. 예외는 로 기록. + /// 호출 중 발생한 · 는 현재 iteration 에 영향 주지 않는다. + /// + public static void Publish(TEvent e) + { + var handlers = Holder.Handlers; + int count = handlers.Count; + if (count == 0) return; + + // iteration snapshot: 현재 호출 중 추가·제거가 일어나도 본 배치는 원본 기준. + // 배열 할당 비용은 구독자 수가 많은 이벤트에서만 의미가 있으나, 안전성 우선. + Action[] snapshot = new Action[count]; + for (int i = 0; i < count; i++) snapshot[i] = handlers[i]; + + for (int i = 0; i < count; i++) + { + try + { + snapshot[i].Invoke(e); + } + catch (Exception ex) + { + Log.Error("EventBus", $"핸들러 예외 ({typeof(TEvent).Name})", ex); + } + } + } + + /// + /// 특정 이벤트 타입의 모든 구독자를 제거한다. 테스트·씬 전환 시 유용. + /// + public static void Clear() + { + Holder.Handlers.Clear(); + } + + /// + /// 지금까지 한 번이라도 Subscribe 된 모든 타입의 구독자를 일괄 제거한다. + /// + /// 주로 테스트 셋업·애플리케이션 종료 시 사용. + public static void ClearAll() + { + for (int i = 0; i < ClearCallbacks.Count; i++) ClearCallbacks[i].Invoke(); + } + + /// + /// 특정 이벤트 타입의 현재 구독자 수 조회 (테스트·디버그용). + /// + public static int SubscriberCount() => Holder.Handlers.Count; + + private static void EnsureRegistered() + { + Type t = typeof(TEvent); + if (RegisteredTypes.Contains(t)) return; + RegisteredTypes.Add(t); + ClearCallbacks.Add(() => Holder.Handlers.Clear()); + } + } +} diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Event/Raw/RawEventBus.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Event/Raw/RawEventBus.cs new file mode 100644 index 0000000..86c3ac4 --- /dev/null +++ b/코어코드/NerdNavis.Framework/Runtime/Core/Event/Raw/RawEventBus.cs @@ -0,0 +1,77 @@ +// --------------------------------------------------------------------------- +// NerdNavis.Framework +// RawEventBus.cs — 문자열 키 기반 이벤트 버스 (특수 용도) +// --------------------------------------------------------------------------- +using System; +using System.Collections.Generic; +using NerdNavis.Core.Util.Log; + +namespace NerdNavis.Core.Event.Raw +{ + /// + /// 문자열 키 기반 이벤트 버스. 외부 스크립팅·에디터 훅 등 타입 안전 경로가 불가능한 특수 용도에만 사용한다. + /// + /// + /// 설계 문서 §3-1 단서. 일반 용도는 를 쓴다. + /// 박싱 비용이 발생하므로 hot-path 에서는 피한다. + /// + public static class RawEventBus + { + private static readonly Dictionary>> HandlersByKey + = new Dictionary>>(StringComparer.Ordinal); + + /// 키에 대한 핸들러 등록. + public static void Subscribe(string key, Action handler) + { + if (string.IsNullOrEmpty(key)) throw new ArgumentException("key empty", nameof(key)); + if (handler == null) throw new ArgumentNullException(nameof(handler)); + if (!HandlersByKey.TryGetValue(key, out var list)) + { + list = new List>(2); + HandlersByKey.Add(key, list); + } + list.Add(handler); + } + + /// 키에 대한 핸들러 해제. + public static bool Unsubscribe(string key, Action handler) + { + if (handler == null) return false; + return HandlersByKey.TryGetValue(key, out var list) && list.Remove(handler); + } + + /// 키에 대한 이벤트 발행. + public static void Publish(string key, object payload) + { + if (!HandlersByKey.TryGetValue(key, out var list)) return; + int count = list.Count; + if (count == 0) return; + + var snapshot = new Action[count]; + for (int i = 0; i < count; i++) snapshot[i] = list[i]; + + for (int i = 0; i < count; i++) + { + try { snapshot[i].Invoke(payload); } + catch (Exception ex) { Log.Error("RawEventBus", $"핸들러 예외 (key={key})", ex); } + } + } + + /// 특정 키 구독자 일괄 제거. + public static void Clear(string key) + { + if (HandlersByKey.TryGetValue(key, out var list)) list.Clear(); + } + + /// 모든 키 구독자 일괄 제거. + public static void ClearAll() + { + foreach (var kv in HandlersByKey) kv.Value.Clear(); + HandlersByKey.Clear(); + } + + /// 특정 키의 현재 구독자 수. + public static int SubscriberCount(string key) + => HandlersByKey.TryGetValue(key, out var list) ? list.Count : 0; + } +} diff --git a/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Container/ObservableDictionaryTests.cs b/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Container/ObservableDictionaryTests.cs new file mode 100644 index 0000000..e3cd8f6 --- /dev/null +++ b/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Container/ObservableDictionaryTests.cs @@ -0,0 +1,109 @@ +// --------------------------------------------------------------------------- +// NerdNavis.Framework.Tests +// ObservableDictionaryTests.cs — ObservableDictionary 단위 테스트 +// --------------------------------------------------------------------------- +using System.Collections.Generic; +using NUnit.Framework; +using NerdNavis.Core.Container; + +namespace NerdNavis.Tests.Core.Container +{ + public class ObservableDictionaryTests + { + [Test] + public void Add_EmitsAdded() + { + var map = new ObservableDictionary(); + var events = new List<(int, string)>(); + map.Added += (k, v) => events.Add((k, v)); + + map.Add(1, "one"); + + Assert.That(events, Is.EqualTo(new[] { (1, "one") })); + } + + [Test] + public void IndexerOnNewKey_EmitsAdded() + { + var map = new ObservableDictionary(); + int addedCount = 0; + int updatedCount = 0; + map.Added += (_, __) => addedCount++; + map.Updated += (_, __, ___) => updatedCount++; + + map[1] = "one"; + + Assert.That(addedCount, Is.EqualTo(1)); + Assert.That(updatedCount, Is.EqualTo(0)); + } + + [Test] + public void IndexerOnExistingKey_EmitsUpdated() + { + var map = new ObservableDictionary { { 1, "one" } }; + var events = new List<(int, string, string)>(); + map.Updated += (k, oldV, newV) => events.Add((k, oldV, newV)); + + map[1] = "ONE"; + + Assert.That(events, Is.EqualTo(new[] { (1, "one", "ONE") })); + } + + [Test] + public void Remove_EmitsRemovedWithOldValue() + { + var map = new ObservableDictionary { { 1, "one" }, { 2, "two" } }; + var events = new List<(int, string)>(); + map.Removed += (k, v) => events.Add((k, v)); + + Assert.IsTrue(map.Remove(1)); + + Assert.That(events, Is.EqualTo(new[] { (1, "one") })); + } + + [Test] + public void Remove_OnMissingKey_EmitsNothing() + { + var map = new ObservableDictionary(); + int removedCount = 0; + map.Removed += (_, __) => removedCount++; + + Assert.IsFalse(map.Remove(99)); + Assert.That(removedCount, Is.EqualTo(0)); + } + + [Test] + public void Clear_EmitsReset() + { + var map = new ObservableDictionary { { 1, "one" } }; + int resetCount = 0; + map.Reset += () => resetCount++; + + map.Clear(); + + Assert.That(resetCount, Is.EqualTo(1)); + Assert.That(map.Count, Is.EqualTo(0)); + } + + [Test] + public void Clear_OnEmpty_DoesNotEmitReset() + { + var map = new ObservableDictionary(); + int resetCount = 0; + map.Reset += () => resetCount++; + + map.Clear(); + + Assert.That(resetCount, Is.EqualTo(0)); + } + + [Test] + public void TryGetValue_Works() + { + var map = new ObservableDictionary { { "a", 1 } }; + Assert.IsTrue(map.TryGetValue("a", out var v)); + Assert.That(v, Is.EqualTo(1)); + Assert.IsFalse(map.TryGetValue("missing", out _)); + } + } +} diff --git a/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Container/ObservableListTests.cs b/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Container/ObservableListTests.cs new file mode 100644 index 0000000..63662ca --- /dev/null +++ b/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Container/ObservableListTests.cs @@ -0,0 +1,144 @@ +// --------------------------------------------------------------------------- +// NerdNavis.Framework.Tests +// ObservableListTests.cs — ObservableList 단위 테스트 +// --------------------------------------------------------------------------- +using System.Collections.Generic; +using NUnit.Framework; +using NerdNavis.Core.Container; + +namespace NerdNavis.Tests.Core.Container +{ + public class ObservableListTests + { + [Test] + public void Add_EmitsAddedWithIndex() + { + var list = new ObservableList(); + int? capturedIndex = null; + string capturedItem = null; + list.Added += (i, v) => { capturedIndex = i; capturedItem = v; }; + + list.Add("a"); + + Assert.That(capturedIndex, Is.EqualTo(0)); + Assert.That(capturedItem, Is.EqualTo("a")); + } + + [Test] + public void Insert_EmitsAddedAtCorrectIndex() + { + var list = new ObservableList { 10, 20, 30 }; + var events = new List<(int, int)>(); + list.Added += (i, v) => events.Add((i, v)); + + list.Insert(1, 15); + + Assert.That(list, Is.EqualTo(new[] { 10, 15, 20, 30 })); + Assert.That(events, Is.EqualTo(new[] { (1, 15) })); + } + + [Test] + public void RemoveAt_EmitsRemovedWithOldItem() + { + var list = new ObservableList { 1, 2, 3 }; + var events = new List<(int, int)>(); + list.Removed += (i, v) => events.Add((i, v)); + + list.RemoveAt(1); + + Assert.That(events, Is.EqualTo(new[] { (1, 2) })); + Assert.That(list, Is.EqualTo(new[] { 1, 3 })); + } + + [Test] + public void Remove_ReturnsFalseForMissingItem() + { + var list = new ObservableList { 1, 2 }; + int eventCount = 0; + list.Removed += (_, __) => eventCount++; + + Assert.IsFalse(list.Remove(99)); + Assert.That(eventCount, Is.EqualTo(0)); + } + + [Test] + public void Clear_EmitsReset() + { + var list = new ObservableList { 1, 2, 3 }; + int resetCount = 0; + list.Reset += () => resetCount++; + + list.Clear(); + + Assert.That(resetCount, Is.EqualTo(1)); + Assert.That(list.Count, Is.EqualTo(0)); + } + + [Test] + public void Clear_OnEmptyList_DoesNotEmitReset() + { + var list = new ObservableList(); + int resetCount = 0; + list.Reset += () => resetCount++; + + list.Clear(); + + Assert.That(resetCount, Is.EqualTo(0)); + } + + [Test] + public void Indexer_EmitsRemovedThenAdded() + { + var list = new ObservableList { 10, 20, 30 }; + var sequence = new List(); + list.Removed += (i, v) => sequence.Add($"-{i}:{v}"); + list.Added += (i, v) => sequence.Add($"+{i}:{v}"); + + list[1] = 99; + + Assert.That(sequence, Is.EqualTo(new[] { "-1:20", "+1:99" })); + } + + [Test] + public void ReplaceAll_EmitsSingleReset() + { + var list = new ObservableList { 1, 2 }; + int resetCount = 0; + int addedCount = 0; + list.Reset += () => resetCount++; + list.Added += (_, __) => addedCount++; + + list.ReplaceAll(new[] { 10, 20, 30 }); + + Assert.That(resetCount, Is.EqualTo(1)); + Assert.That(addedCount, Is.EqualTo(0), "대량 교체는 Added 를 발행하지 않는다"); + Assert.That(list, Is.EqualTo(new[] { 10, 20, 30 })); + } + + [Test] + public void HandlerReentrancy_DoesNotBreakCurrentCall() + { + var list = new ObservableList(); + int innerAdds = 0; + list.Added += (i, v) => + { + if (v == 1) { /* 추가 호출 없음 */ } + innerAdds++; + }; + + list.Add(1); + list.Add(2); + + Assert.That(list.Count, Is.EqualTo(2)); + Assert.That(innerAdds, Is.EqualTo(2)); + } + + [Test] + public void NullHandler_IsSafe() + { + var list = new ObservableList(); + Assert.DoesNotThrow(() => list.Add(1)); + Assert.DoesNotThrow(() => list.Clear()); + } + } +} diff --git a/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Container/ObservableQueueTests.cs b/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Container/ObservableQueueTests.cs new file mode 100644 index 0000000..ecfc079 --- /dev/null +++ b/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Container/ObservableQueueTests.cs @@ -0,0 +1,92 @@ +// --------------------------------------------------------------------------- +// NerdNavis.Framework.Tests +// ObservableQueueTests.cs — ObservableQueue 단위 테스트 +// --------------------------------------------------------------------------- +using System.Collections.Generic; +using NUnit.Framework; +using NerdNavis.Core.Container; + +namespace NerdNavis.Tests.Core.Container +{ + public class ObservableQueueTests + { + [Test] + public void Enqueue_EmitsEnqueued() + { + var q = new ObservableQueue(); + var items = new List(); + q.Enqueued += items.Add; + + q.Enqueue(1); + q.Enqueue(2); + + Assert.That(items, Is.EqualTo(new[] { 1, 2 })); + } + + [Test] + public void Dequeue_EmitsDequeuedAndReturnsFifo() + { + var q = new ObservableQueue(); + q.Enqueue(1); + q.Enqueue(2); + var items = new List(); + q.Dequeued += items.Add; + + int a = q.Dequeue(); + int b = q.Dequeue(); + + Assert.That(a, Is.EqualTo(1)); + Assert.That(b, Is.EqualTo(2)); + Assert.That(items, Is.EqualTo(new[] { 1, 2 })); + } + + [Test] + public void TryDequeue_OnEmpty_ReturnsFalse() + { + var q = new ObservableQueue(); + int emitted = 0; + q.Dequeued += _ => emitted++; + + Assert.IsFalse(q.TryDequeue(out _)); + Assert.That(emitted, Is.EqualTo(0)); + } + + [Test] + public void Peek_DoesNotEmit() + { + var q = new ObservableQueue(); + q.Enqueue(42); + int emitted = 0; + q.Dequeued += _ => emitted++; + + Assert.That(q.Peek(), Is.EqualTo(42)); + Assert.That(emitted, Is.EqualTo(0)); + } + + [Test] + public void Clear_EmitsReset() + { + var q = new ObservableQueue(); + q.Enqueue(1); + int resetCount = 0; + q.Reset += () => resetCount++; + + q.Clear(); + + Assert.That(resetCount, Is.EqualTo(1)); + Assert.That(q.Count, Is.EqualTo(0)); + } + + [Test] + public void Clear_OnEmpty_DoesNotEmit() + { + var q = new ObservableQueue(); + int resetCount = 0; + q.Reset += () => resetCount++; + + q.Clear(); + + Assert.That(resetCount, Is.EqualTo(0)); + } + } +} diff --git a/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Data/DataTableTests.cs b/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Data/DataTableTests.cs new file mode 100644 index 0000000..1468c76 --- /dev/null +++ b/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Data/DataTableTests.cs @@ -0,0 +1,184 @@ +// --------------------------------------------------------------------------- +// NerdNavis.Framework.Tests +// DataTableTests.cs — DataTable·DataTableLoader 단위 테스트 +// --------------------------------------------------------------------------- +using System; +using System.Collections.Generic; +using NUnit.Framework; +using NerdNavis.Core.Data; + +namespace NerdNavis.Tests.Core.Data +{ + public class DataTableTests + { + private class MonsterRow : IDataRow + { + public int Id; + public string Name; + public int Hp; + public int Key => Id; + + public static MonsterRow FromCsv(string[] cols) + { + return new MonsterRow + { + Id = int.Parse(cols[0]), + Name = cols[1], + Hp = int.Parse(cols[2]), + }; + } + } + + // ----------------------------------------------------------------- + // DataTable core + // ----------------------------------------------------------------- + + [Test] + public void Constructor_FromRows_PreservesOrderAndCount() + { + var rows = new[] + { + new MonsterRow { Id = 10, Name = "A", Hp = 100 }, + new MonsterRow { Id = 20, Name = "B", Hp = 200 }, + }; + + var table = new DataTable(rows); + + Assert.That(table.Count, Is.EqualTo(2)); + Assert.That(table.Rows[0].Id, Is.EqualTo(10)); + Assert.That(table.Rows[1].Id, Is.EqualTo(20)); + } + + [Test] + public void Get_ReturnsRowByKey() + { + var table = new DataTable(new[] + { + new MonsterRow { Id = 10, Name = "A", Hp = 100 }, + }); + + var row = table.Get(10); + + Assert.That(row.Name, Is.EqualTo("A")); + } + + [Test] + public void Get_OnMissingKey_Throws() + { + var table = new DataTable(Array.Empty()); + Assert.Throws(() => table.Get(999)); + } + + [Test] + public void TryGet_ReturnsFalseOnMissingKey() + { + var table = new DataTable(Array.Empty()); + Assert.IsFalse(table.TryGet(999, out _)); + } + + [Test] + public void Contains_Works() + { + var table = new DataTable(new[] + { + new MonsterRow { Id = 10, Name = "A" }, + }); + + Assert.IsTrue(table.Contains(10)); + Assert.IsFalse(table.Contains(99)); + } + + [Test] + public void Constructor_DuplicateKey_Throws() + { + var rows = new[] + { + new MonsterRow { Id = 10, Name = "A" }, + new MonsterRow { Id = 10, Name = "B" }, + }; + + Assert.Throws(() => new DataTable(rows)); + } + + [Test] + public void Constructor_Null_Throws() + { + Assert.Throws(() => new DataTable(null)); + } + + // ----------------------------------------------------------------- + // CSV loader + // ----------------------------------------------------------------- + + [Test] + public void FromCsv_ParsesBasicRows() + { + string csv = "Id,Name,Hp\n10,Slime,100\n20,Goblin,200\n"; + + var table = DataTableLoader.FromCsv(csv, MonsterRow.FromCsv); + + Assert.That(table.Count, Is.EqualTo(2)); + Assert.That(table.Get(10).Name, Is.EqualTo("Slime")); + Assert.That(table.Get(20).Hp, Is.EqualTo(200)); + } + + [Test] + public void FromCsv_HandlesQuotedCommas() + { + string csv = "Id,Name,Hp\n10,\"Red, Small\",100\n"; + + var table = DataTableLoader.FromCsv(csv, MonsterRow.FromCsv); + + Assert.That(table.Get(10).Name, Is.EqualTo("Red, Small")); + } + + [Test] + public void FromCsv_HandlesEscapedQuotes() + { + string csv = "Id,Name,Hp\n10,\"She said \"\"hi\"\"\",100\n"; + + var table = DataTableLoader.FromCsv(csv, MonsterRow.FromCsv); + + Assert.That(table.Get(10).Name, Is.EqualTo("She said \"hi\"")); + } + + [Test] + public void FromCsv_SkipsEmptyRows() + { + string csv = "Id,Name,Hp\n10,A,100\n\n20,B,200\n"; + + var table = DataTableLoader.FromCsv(csv, MonsterRow.FromCsv); + + Assert.That(table.Count, Is.EqualTo(2)); + } + + [Test] + public void FromCsv_CrlfLineEndings_Handled() + { + string csv = "Id,Name,Hp\r\n10,A,100\r\n20,B,200\r\n"; + + var table = DataTableLoader.FromCsv(csv, MonsterRow.FromCsv); + + Assert.That(table.Count, Is.EqualTo(2)); + } + + [Test] + public void FromCsv_NullText_Throws() + { + Assert.Throws(() => + DataTableLoader.FromCsv(null, MonsterRow.FromCsv)); + } + + // ----------------------------------------------------------------- + // DataTableLoadedEvent (value semantics) + // ----------------------------------------------------------------- + + [Test] + public void DataTableLoadedEvent_CarriesTypeAndCount() + { + var evt = new DataTableLoadedEvent(typeof(MonsterRow), 5); + Assert.That(evt.TableType, Is.EqualTo(typeof(MonsterRow))); + Assert.That(evt.RowCount, Is.EqualTo(5)); + } + } +} diff --git a/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Event/EventBusTests.cs b/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Event/EventBusTests.cs new file mode 100644 index 0000000..4db4a4d --- /dev/null +++ b/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Event/EventBusTests.cs @@ -0,0 +1,141 @@ +// --------------------------------------------------------------------------- +// NerdNavis.Framework.Tests +// EventBusTests.cs — 타입 안전 이벤트 버스 단위 테스트 +// --------------------------------------------------------------------------- +using System; +using NUnit.Framework; +using NerdNavis.Core.Event; + +namespace NerdNavis.Tests.Core.Event +{ + public class EventBusTests + { + private struct FooEvent { public int Value; } + private struct BarEvent { public string Text; } + + [SetUp] + public void SetUp() + { + EventBus.ClearAll(); + } + + [Test] + public void Publish_InvokesRegisteredHandlers() + { + int received = 0; + Action handler = e => received = e.Value; + EventBus.Subscribe(handler); + + EventBus.Publish(new FooEvent { Value = 42 }); + + Assert.That(received, Is.EqualTo(42)); + } + + [Test] + public void Publish_InvokesAllHandlersInRegistrationOrder() + { + var order = new System.Collections.Generic.List(); + EventBus.Subscribe(_ => order.Add(1)); + EventBus.Subscribe(_ => order.Add(2)); + EventBus.Subscribe(_ => order.Add(3)); + + EventBus.Publish(new FooEvent()); + + Assert.That(order, Is.EqualTo(new[] { 1, 2, 3 })); + } + + [Test] + public void Unsubscribe_RemovesHandler() + { + int count = 0; + Action handler = _ => count++; + EventBus.Subscribe(handler); + EventBus.Unsubscribe(handler); + + EventBus.Publish(new FooEvent()); + + Assert.That(count, Is.EqualTo(0)); + } + + [Test] + public void Unsubscribe_ReturnsFalseForUnknownHandler() + { + Action handler = _ => { }; + Assert.IsFalse(EventBus.Unsubscribe(handler)); + } + + [Test] + public void Publish_ContinuesAfterHandlerException() + { + int afterException = 0; + EventBus.Subscribe(_ => throw new InvalidOperationException("test")); + EventBus.Subscribe(_ => afterException++); + + // Log.Error 로만 기록되고 throw 되지 않아야 함 + EventBus.Publish(new FooEvent()); + + Assert.That(afterException, Is.EqualTo(1)); + } + + [Test] + public void Publish_SubscribeDuringIteration_DeferredToNextPublish() + { + int extra = 0; + EventBus.Subscribe(_ => + { + EventBus.Subscribe(__ => extra++); + }); + + EventBus.Publish(new FooEvent()); + Assert.That(extra, Is.EqualTo(0), "현재 Publish 에는 반영되지 않아야 함"); + + EventBus.Publish(new FooEvent()); + // 첫 Publish 에서 등록된 핸들러는 두 번째 Publish 에서 호출, 그리고 다시 재등록... + Assert.That(extra, Is.GreaterThanOrEqualTo(1)); + } + + [Test] + public void DifferentEventTypes_AreIsolated() + { + int fooCount = 0; + int barCount = 0; + EventBus.Subscribe(_ => fooCount++); + EventBus.Subscribe(_ => barCount++); + + EventBus.Publish(new FooEvent()); + + Assert.That(fooCount, Is.EqualTo(1)); + Assert.That(barCount, Is.EqualTo(0)); + } + + [Test] + public void Clear_RemovesAllHandlersOfType() + { + EventBus.Subscribe(_ => { }); + EventBus.Subscribe(_ => { }); + Assert.That(EventBus.SubscriberCount(), Is.EqualTo(2)); + + EventBus.Clear(); + + Assert.That(EventBus.SubscriberCount(), Is.EqualTo(0)); + } + + [Test] + public void ClearAll_RemovesAllTypes() + { + EventBus.Subscribe(_ => { }); + EventBus.Subscribe(_ => { }); + + EventBus.ClearAll(); + + Assert.That(EventBus.SubscriberCount(), Is.EqualTo(0)); + Assert.That(EventBus.SubscriberCount(), Is.EqualTo(0)); + } + + [Test] + public void Subscribe_NullHandler_Throws() + { + Assert.Throws(() => EventBus.Subscribe(null)); + } + } +}