feat(core): Tier 1 잔여 3종 — Event · Container · Data (PD님 #36 즉시 수행 지시)
- Event: EventBus (타입 안전 정적 버스) + Raw.RawEventBus (문자열 키 특수 용도) - Container: ObservableList / ObservableDictionary / ObservableQueue (UniList/UniEventList/UniObserverList 3종 통합 대체) - Data: IDataRow / DataTable<TKey,TRow> / 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) <noreply@anthropic.com>
This commit is contained in:
parent
95e47d8288
commit
fb51e94d88
|
|
@ -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<T>` — Added/Removed/Reset 이벤트 내장
|
||||
- `NerdNavis.Core.Container.ObservableDictionary<TKey,TValue>` — Added/Removed/Updated/Reset 이벤트 내장
|
||||
- `NerdNavis.Core.Container.ObservableQueue<T>` — Enqueued/Dequeued/Reset 이벤트 내장
|
||||
- 기존 `UniList`·`UniEventList`·`UniObserverList` 3종 통합 대체 (설계 §4-3)
|
||||
- Tier 1 Data 모듈 (2026-04-17)
|
||||
- `NerdNavis.Core.Data.IDataRow<TKey>` — 행 키 추출 계약
|
||||
- `NerdNavis.Core.Data.DataTable<TKey,TRow>` — 불변 마스터 테이블 (키·행 조회)
|
||||
- `NerdNavis.Core.Data.DataTableSO<TKey,TRow>` — 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
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
// ---------------------------------------------------------------------------
|
||||
// NerdNavis.Framework
|
||||
// ObservableDictionary.cs — 변경 이벤트를 내장한 Dictionary<TKey, TValue>
|
||||
// ---------------------------------------------------------------------------
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace NerdNavis.Core.Container
|
||||
{
|
||||
/// <summary>
|
||||
/// Add/Remove/Update/Reset 이벤트를 인스턴스 단위로 발행하는 <see cref="IDictionary{TKey, TValue}"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 설계 문서 §3-2. 기존 <c>UniDictionary</c> 상응 통합 대체.
|
||||
/// Updated 는 동일 키에 새 값이 대입될 때만 발행, 기존 키가 없던 경우에는 Added 로 처리.
|
||||
/// </remarks>
|
||||
public class ObservableDictionary<TKey, TValue> : IDictionary<TKey, TValue>
|
||||
{
|
||||
private readonly Dictionary<TKey, TValue> _map;
|
||||
|
||||
public event Action<TKey, TValue> Added;
|
||||
public event Action<TKey, TValue> Removed;
|
||||
/// <summary>동일 키에 새 값 대입 시 발행. 인자는 (key, oldValue, newValue).</summary>
|
||||
public event Action<TKey, TValue, TValue> Updated;
|
||||
public event Action Reset;
|
||||
|
||||
public ObservableDictionary() { _map = new Dictionary<TKey, TValue>(); }
|
||||
public ObservableDictionary(int capacity) { _map = new Dictionary<TKey, TValue>(capacity); }
|
||||
public ObservableDictionary(IEqualityComparer<TKey> comparer) { _map = new Dictionary<TKey, TValue>(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<TKey> Keys => _map.Keys;
|
||||
public ICollection<TValue> 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<TKey, TValue> 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<TKey, TValue> item)
|
||||
{
|
||||
if (!_map.TryGetValue(item.Key, out var existing) ||
|
||||
!EqualityComparer<TValue>.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<TKey, TValue> item)
|
||||
=> _map.TryGetValue(item.Key, out var v) && EqualityComparer<TValue>.Default.Equals(v, item.Value);
|
||||
|
||||
public bool ContainsKey(TKey key) => _map.ContainsKey(key);
|
||||
|
||||
public void CopyTo(KeyValuePair<TKey, TValue>[] array, int arrayIndex)
|
||||
=> ((ICollection<KeyValuePair<TKey, TValue>>)_map).CopyTo(array, arrayIndex);
|
||||
|
||||
public bool TryGetValue(TKey key, out TValue value) => _map.TryGetValue(key, out value);
|
||||
|
||||
public Dictionary<TKey, TValue>.Enumerator GetEnumerator() => _map.GetEnumerator();
|
||||
IEnumerator<KeyValuePair<TKey, TValue>> IEnumerable<KeyValuePair<TKey, TValue>>.GetEnumerator() => _map.GetEnumerator();
|
||||
IEnumerator IEnumerable.GetEnumerator() => _map.GetEnumerator();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
// ---------------------------------------------------------------------------
|
||||
// NerdNavis.Framework
|
||||
// ObservableList.cs — 변경 이벤트를 내장한 List<T>
|
||||
// ---------------------------------------------------------------------------
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace NerdNavis.Core.Container
|
||||
{
|
||||
/// <summary>
|
||||
/// 인스턴스 단위로 Add/Remove/Reset 이벤트를 발행하는 <see cref="IList{T}"/> 구현.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>설계 문서 §3-2 (04_Tier1_3종_상호작용_설계_v1.md). 기존 <c>UniList</c>·<c>UniEventList</c>·<c>UniObserverList</c>
|
||||
/// 3종을 본 1종으로 통합했다(§01 §4-3).</para>
|
||||
/// <para>계약:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description>이벤트는 인스턴스 멤버. 전역 전파가 필요하면 호출자가 수신 후 <c>EventBus.Publish</c> 로 다시 발행.</description></item>
|
||||
/// <item><description>이벤트 핸들러에서 재변경이 일어나도 현재 호출은 깨지지 않는다 (내부 snapshot).</description></item>
|
||||
/// <item><description>null 핸들러 안전 (<c>?.Invoke</c>).</description></item>
|
||||
/// <item><description>Reset 은 <see cref="Clear"/> 와 대량 교체( <see cref="ReplaceAll"/> ) 때 발행.</description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public class ObservableList<T> : IList<T>, IReadOnlyList<T>
|
||||
{
|
||||
private readonly List<T> _items;
|
||||
|
||||
/// <summary>Add·Insert 발생 시 발행. 인자는 (index, item).</summary>
|
||||
public event Action<int, T> Added;
|
||||
/// <summary>Remove·RemoveAt 발생 시 발행. 인자는 (index, item).</summary>
|
||||
public event Action<int, T> Removed;
|
||||
/// <summary><see cref="Clear"/>·<see cref="ReplaceAll"/> 발생 시 발행.</summary>
|
||||
public event Action Reset;
|
||||
|
||||
public ObservableList() { _items = new List<T>(); }
|
||||
public ObservableList(int capacity) { _items = new List<T>(capacity); }
|
||||
public ObservableList(IEnumerable<T> source) { _items = new List<T>(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();
|
||||
}
|
||||
|
||||
/// <summary>전체 원소를 <paramref name="source"/> 로 교체. 단일 Reset 이벤트로 통지.</summary>
|
||||
public void ReplaceAll(IEnumerable<T> 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<T>.Enumerator GetEnumerator() => _items.GetEnumerator();
|
||||
IEnumerator<T> IEnumerable<T>.GetEnumerator() => _items.GetEnumerator();
|
||||
IEnumerator IEnumerable.GetEnumerator() => _items.GetEnumerator();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
// ---------------------------------------------------------------------------
|
||||
// NerdNavis.Framework
|
||||
// ObservableQueue.cs — 변경 이벤트를 내장한 Queue<T>
|
||||
// ---------------------------------------------------------------------------
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace NerdNavis.Core.Container
|
||||
{
|
||||
/// <summary>
|
||||
/// Enqueue/Dequeue/Reset 이벤트를 인스턴스 단위로 발행하는 FIFO 큐.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 설계 문서 §3-2. 기존 <c>UniQueue</c>·<c>UniEventQueue</c>·<c>UniObserverQueue</c> 3종 통합 대체.
|
||||
/// </remarks>
|
||||
public class ObservableQueue<T> : IReadOnlyCollection<T>
|
||||
{
|
||||
private readonly Queue<T> _items;
|
||||
|
||||
public event Action<T> Enqueued;
|
||||
public event Action<T> Dequeued;
|
||||
public event Action Reset;
|
||||
|
||||
public ObservableQueue() { _items = new Queue<T>(); }
|
||||
public ObservableQueue(int capacity) { _items = new Queue<T>(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<T>.Enumerator GetEnumerator() => _items.GetEnumerator();
|
||||
IEnumerator<T> IEnumerable<T>.GetEnumerator() => _items.GetEnumerator();
|
||||
IEnumerator IEnumerable.GetEnumerator() => _items.GetEnumerator();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
// ---------------------------------------------------------------------------
|
||||
// NerdNavis.Framework
|
||||
// DataTable.cs — 불변 마스터 테이블 (키·행)
|
||||
// ---------------------------------------------------------------------------
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace NerdNavis.Core.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// 로드 완료 이후 내용이 변경되지 않는 키·행 매핑 스냅샷.
|
||||
/// </summary>
|
||||
/// <typeparam name="TKey">행 키 타입 (보통 int·string·enum).</typeparam>
|
||||
/// <typeparam name="TRow">행 타입. <see cref="IDataRow{TKey}"/> 를 구현해야 함.</typeparam>
|
||||
/// <remarks>
|
||||
/// <para>설계 문서 §3-3 (04_Tier1_3종_상호작용_설계_v1.md). 기존 <c>MasterTableBase</c> 를 본 클래스로 재설계.</para>
|
||||
/// <para>계약:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description>생성 후 <see cref="Rows"/> 내용·길이는 변경되지 않는다 (불변 스냅샷).</description></item>
|
||||
/// <item><description>동일 키가 중복 등장하면 <see cref="ArgumentException"/>.</description></item>
|
||||
/// <item><description>런타임 쓰기가 필요하면 <see cref="NerdNavis.Core.Container.ObservableList{T}"/> 사용 (Container 모듈과 역할 분리).</description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public class DataTable<TKey, TRow> where TRow : IDataRow<TKey>
|
||||
{
|
||||
private readonly TRow[] _rows;
|
||||
private readonly Dictionary<TKey, TRow> _byKey;
|
||||
|
||||
/// <summary>행의 읽기 전용 뷰 (원본 순서 보존).</summary>
|
||||
public IReadOnlyList<TRow> Rows => _rows;
|
||||
|
||||
/// <summary>행 개수.</summary>
|
||||
public int Count => _rows.Length;
|
||||
|
||||
/// <summary>
|
||||
/// 주어진 행 집합으로 테이블을 생성한다. 행 순서는 입력 순서를 보존한다.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="rows"/> 가 null.</exception>
|
||||
/// <exception cref="ArgumentException">중복 키가 감지됨.</exception>
|
||||
public DataTable(IEnumerable<TRow> rows)
|
||||
{
|
||||
if (rows == null) throw new ArgumentNullException(nameof(rows));
|
||||
var list = new List<TRow>();
|
||||
var map = new Dictionary<TKey, TRow>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>키에 해당하는 행을 반환한다.</summary>
|
||||
/// <exception cref="KeyNotFoundException">키 미등록.</exception>
|
||||
public TRow Get(TKey key)
|
||||
{
|
||||
if (!_byKey.TryGetValue(key, out var row))
|
||||
throw new KeyNotFoundException($"키 미등록: {key}");
|
||||
return row;
|
||||
}
|
||||
|
||||
/// <summary>키에 해당하는 행을 안전하게 조회한다.</summary>
|
||||
public bool TryGet(TKey key, out TRow row) => _byKey.TryGetValue(key, out row);
|
||||
|
||||
/// <summary>키 등록 여부.</summary>
|
||||
public bool Contains(TKey key) => _byKey.ContainsKey(key);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
// ---------------------------------------------------------------------------
|
||||
// NerdNavis.Framework
|
||||
// DataTableLoadedEvent.cs — 마스터 테이블 로드 완료 이벤트 payload
|
||||
// ---------------------------------------------------------------------------
|
||||
using System;
|
||||
|
||||
namespace NerdNavis.Core.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// 마스터 테이블 로드 완료를 전역 통지할 때 <see cref="NerdNavis.Core.Event.EventBus"/> 로 Publish 하는 이벤트.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>설계 문서 §3-3. Data 모듈은 본 struct 를 정의만 하며, 실제 Publish 는 호출자 선택 사항.
|
||||
/// Data 가 Event 에 강제 의존하지 않도록 하기 위함(설계 §2 DAG).</para>
|
||||
/// </remarks>
|
||||
public readonly struct DataTableLoadedEvent
|
||||
{
|
||||
/// <summary>로드된 테이블의 행 타입 (식별자).</summary>
|
||||
public readonly Type TableType;
|
||||
|
||||
/// <summary>로드된 행 개수.</summary>
|
||||
public readonly int RowCount;
|
||||
|
||||
public DataTableLoadedEvent(Type tableType, int rowCount)
|
||||
{
|
||||
TableType = tableType;
|
||||
RowCount = rowCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 텍스트 원본(CSV·JSON)으로부터 <see cref="DataTable{TKey, TRow}"/> 를 구축하는 정적 헬퍼.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>설계 문서 §3-3·§6-5·§6-6. Tier 1 외부 의존성 최소 원칙에 따라:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description>CSV: 쉼표·따옴표 이스케이프·줄바꿈을 처리하는 최소 자체 파서.</description></item>
|
||||
/// <item><description>JSON: Unity <see cref="JsonUtility"/> 기반. Dictionary·polymorphism 미지원 — 고급 케이스는 호출자가 자체 파싱.</description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public static class DataTableLoader
|
||||
{
|
||||
// -----------------------------------------------------------------
|
||||
// CSV
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// CSV 텍스트를 파싱하여 <see cref="DataTable{TKey, TRow}"/> 를 생성한다.
|
||||
/// </summary>
|
||||
/// <typeparam name="TKey">행 키 타입.</typeparam>
|
||||
/// <typeparam name="TRow">행 타입 (<see cref="IDataRow{TKey}"/> 구현).</typeparam>
|
||||
/// <param name="csvText">원본 텍스트.</param>
|
||||
/// <param name="rowFactory">컬럼 배열 → 행 인스턴스 변환기.</param>
|
||||
/// <param name="skipHeader">첫 줄을 헤더로 간주하여 건너뛸지 여부. 기본 true.</param>
|
||||
public static DataTable<TKey, TRow> FromCsv<TKey, TRow>(
|
||||
string csvText,
|
||||
Func<string[], TRow> rowFactory,
|
||||
bool skipHeader = true)
|
||||
where TRow : IDataRow<TKey>
|
||||
{
|
||||
if (csvText == null) throw new ArgumentNullException(nameof(csvText));
|
||||
if (rowFactory == null) throw new ArgumentNullException(nameof(rowFactory));
|
||||
|
||||
var rows = new List<TRow>();
|
||||
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<TKey, TRow>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CSV 텍스트를 행·컬럼 2차원 리스트로 파싱 (RFC 4180 준수 최소 구현).
|
||||
/// </summary>
|
||||
/// <remarks>쉼표·따옴표·따옴표 내 줄바꿈·연속 따옴표(<c>""</c> → <c>"</c>) 처리.</remarks>
|
||||
public static List<string[]> ParseCsv(string csvText)
|
||||
{
|
||||
var result = new List<string[]>();
|
||||
if (string.IsNullOrEmpty(csvText)) return result;
|
||||
|
||||
var currentRow = new List<string>();
|
||||
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)
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// JSON 배열 래퍼 텍스트(<c>{"rows":[...]}</c>) 를 파싱하여 <see cref="DataTable{TKey, TRow}"/> 를 생성한다.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Unity <see cref="JsonUtility"/> 는 최상위 배열을 직접 역직렬화하지 못하므로,
|
||||
/// <typeparamref name="TRowWrapper"/> 안에 공개 <c>List<TRow> rows</c> 필드 또는
|
||||
/// <c>TRow[] rows</c> 필드를 두고 감싸는 방식을 사용한다. 래퍼는 <see cref="RowListWrapper{TRow}"/> 를
|
||||
/// 직접 쓰거나, 호출자가 custom class 로 만들어 <paramref name="extractRows"/> 를 제공한다.</para>
|
||||
/// <para>고급 케이스(Dictionary 키·polymorphism)는 호출자가 자체 파싱 후 <see cref="DataTable{TKey, TRow}"/>
|
||||
/// 생성자에 직접 전달.</para>
|
||||
/// </remarks>
|
||||
/// <typeparam name="TKey">행 키 타입.</typeparam>
|
||||
/// <typeparam name="TRow">행 타입.</typeparam>
|
||||
/// <typeparam name="TRowWrapper">JSON 루트 래퍼 클래스 (반드시 <see cref="Serializable"/>).</typeparam>
|
||||
/// <param name="jsonText">원본 JSON.</param>
|
||||
/// <param name="extractRows">래퍼에서 행 목록을 꺼내는 함수.</param>
|
||||
public static DataTable<TKey, TRow> FromJson<TKey, TRow, TRowWrapper>(
|
||||
string jsonText,
|
||||
Func<TRowWrapper, IEnumerable<TRow>> extractRows)
|
||||
where TRow : IDataRow<TKey>
|
||||
where TRowWrapper : class
|
||||
{
|
||||
if (jsonText == null) throw new ArgumentNullException(nameof(jsonText));
|
||||
if (extractRows == null) throw new ArgumentNullException(nameof(extractRows));
|
||||
TRowWrapper wrapper = JsonUtility.FromJson<TRowWrapper>(jsonText);
|
||||
if (wrapper == null) return new DataTable<TKey, TRow>(new List<TRow>());
|
||||
var rows = extractRows(wrapper);
|
||||
return new DataTable<TKey, TRow>(rows ?? new List<TRow>());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="DataTableLoader.FromJson{TKey, TRow, TRowWrapper}"/> 사용 시 기본 래퍼.
|
||||
/// </summary>
|
||||
/// <remarks>JSON 예: <c>{"rows":[{...},{...}]}</c>.</remarks>
|
||||
[Serializable]
|
||||
public class RowListWrapper<TRow>
|
||||
{
|
||||
public List<TRow> rows;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
// ---------------------------------------------------------------------------
|
||||
// NerdNavis.Framework
|
||||
// DataTableSO.cs — DataTable의 Unity ScriptableObject 래퍼
|
||||
// ---------------------------------------------------------------------------
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NerdNavis.Core.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="DataTable{TKey, TRow}"/> 를 담는 <see cref="ScriptableObject"/> 기반 자산.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>설계 문서 §3-3. 기존 <c>MasterTableSO</c> 를 본 클래스로 재설계.</para>
|
||||
/// <para>하위 타입은 <see cref="BuildRows"/> 를 구현하여 인스펙터 상에서 입력한 원본을 <typeparamref name="TRow"/>
|
||||
/// 리스트로 가공해 반환한다. <see cref="Load"/> 가 호출되면 <see cref="Table"/> 이 초기화된다.</para>
|
||||
/// <para>이벤트 통지가 필요하면 호출자가 <see cref="NerdNavis.Core.Event.EventBus.Publish{TEvent}"/> 로
|
||||
/// <see cref="DataTableLoadedEvent"/> 를 발행한다 (Data 는 Event 에 런타임 의존하지 않음).</para>
|
||||
/// </remarks>
|
||||
public abstract class DataTableSO<TKey, TRow> : ScriptableObject where TRow : IDataRow<TKey>
|
||||
{
|
||||
private DataTable<TKey, TRow> _table;
|
||||
|
||||
/// <summary>로드된 테이블. <see cref="Load"/> 호출 이전에는 null.</summary>
|
||||
public DataTable<TKey, TRow> Table => _table;
|
||||
|
||||
/// <summary>로드 완료 여부.</summary>
|
||||
public bool IsLoaded => _table != null;
|
||||
|
||||
/// <summary>테이블을 구축한다. 이미 로드된 상태면 스킵 (명시적 재로드는 <see cref="Reload"/> 사용).</summary>
|
||||
public void Load()
|
||||
{
|
||||
if (_table != null) return;
|
||||
_table = new DataTable<TKey, TRow>(BuildRows());
|
||||
}
|
||||
|
||||
/// <summary>테이블을 강제로 재구축한다.</summary>
|
||||
public void Reload()
|
||||
{
|
||||
_table = new DataTable<TKey, TRow>(BuildRows());
|
||||
}
|
||||
|
||||
/// <summary>하위 타입이 행 원본을 공급한다 (인스펙터 직렬화 필드·CSV·JSON 등에서 유래).</summary>
|
||||
protected abstract IEnumerable<TRow> BuildRows();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
// ---------------------------------------------------------------------------
|
||||
// NerdNavis.Framework
|
||||
// IDataRow.cs — 마스터 테이블 행의 키 추출 인터페이스
|
||||
// ---------------------------------------------------------------------------
|
||||
namespace NerdNavis.Core.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// 마스터 테이블 행이 자신의 키를 노출하기 위한 인터페이스.
|
||||
/// </summary>
|
||||
/// <typeparam name="TKey">키 타입 (int·string·enum 등).</typeparam>
|
||||
public interface IDataRow<TKey>
|
||||
{
|
||||
/// <summary>테이블 내 고유 키.</summary>
|
||||
TKey Key { get; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
// ---------------------------------------------------------------------------
|
||||
// NerdNavis.Framework
|
||||
// EventBus.cs — 타입 안전 프로세스 내 이벤트 버스 (정적)
|
||||
// ---------------------------------------------------------------------------
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using NerdNavis.Core.Util.Log;
|
||||
|
||||
namespace NerdNavis.Core.Event
|
||||
{
|
||||
/// <summary>
|
||||
/// 타입을 키로 사용하는 프로세스 내 이벤트 버스.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>설계 문서 §3-1 (04_Tier1_3종_상호작용_설계_v1.md). 기존 <c>ObserverManager</c> 의
|
||||
/// 문자열 키·박싱 비용·프로젝트 특화 키 생성기 오염 문제를 해결하기 위해 제네릭 타입 버스로 재설계.</para>
|
||||
/// <para>계약:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description>메인 스레드 전용 (Unity 표준). 멀티스레드 지원은 Tier 3 네트워크 도입 시 재검토.</description></item>
|
||||
/// <item><description><see cref="Publish{TEvent}"/> 중 핸들러가 예외를 던져도 나머지 구독자 호출 보장 (catch-and-log).</description></item>
|
||||
/// <item><description><see cref="Publish{TEvent}"/> 진행 중 발생한 Subscribe/Unsubscribe 는 다음 Publish 에 반영(iteration snapshot).</description></item>
|
||||
/// <item><description>이벤트 타입은 struct 권장 (박싱 회피). class 도 허용.</description></item>
|
||||
/// <item><description>약한 참조 방식 미채택 — 명시적 <see cref="Unsubscribe{TEvent}"/> 의무 (설계 문서 §6-2 기각안 참조).</description></item>
|
||||
/// </list>
|
||||
/// <para>문자열 키 기반 특수 용도는 <see cref="NerdNavis.Core.Event.Raw.RawEventBus"/> 분리.</para>
|
||||
/// </remarks>
|
||||
public static class EventBus
|
||||
{
|
||||
/// <summary>
|
||||
/// 타입별 구독자 목록 저장소. 동일 타입에 대한 List 는 핸들러 집합.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 제네릭 정적 클래스 <see cref="Holder{TEvent}"/> 를 통해 타입별 List 를 캐싱, Dictionary lookup 비용 제거 (C11 자원 효율).
|
||||
/// </remarks>
|
||||
private static class Holder<TEvent>
|
||||
{
|
||||
public static readonly List<Action<TEvent>> Handlers = new List<Action<TEvent>>(4);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 전체 타입 핸들러 목록을 역으로 추적하는 메타 테이블 (<see cref="ClearAll"/> 용).
|
||||
/// </summary>
|
||||
private static readonly List<Action> ClearCallbacks = new List<Action>();
|
||||
|
||||
/// <summary>타입별 초기화 1회 flag.</summary>
|
||||
private static readonly HashSet<Type> RegisteredTypes = new HashSet<Type>();
|
||||
|
||||
/// <summary>
|
||||
/// 해당 이벤트 타입에 대한 핸들러를 등록한다.
|
||||
/// </summary>
|
||||
/// <typeparam name="TEvent">이벤트 타입.</typeparam>
|
||||
/// <param name="handler">호출 콜백.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="handler"/> 가 null 일 때.</exception>
|
||||
public static void Subscribe<TEvent>(Action<TEvent> handler)
|
||||
{
|
||||
if (handler == null) throw new ArgumentNullException(nameof(handler));
|
||||
EnsureRegistered<TEvent>();
|
||||
Holder<TEvent>.Handlers.Add(handler);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 등록된 핸들러를 제거한다. 동일 핸들러가 여러 번 등록됐다면 가장 먼저 발견된 1개만 제거한다.
|
||||
/// </summary>
|
||||
/// <typeparam name="TEvent">이벤트 타입.</typeparam>
|
||||
/// <param name="handler">해제할 콜백.</param>
|
||||
/// <returns>실제로 제거되었으면 true, 미등록 상태면 false.</returns>
|
||||
public static bool Unsubscribe<TEvent>(Action<TEvent> handler)
|
||||
{
|
||||
if (handler == null) return false;
|
||||
return Holder<TEvent>.Handlers.Remove(handler);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 해당 이벤트를 모든 구독자에게 전달한다.
|
||||
/// </summary>
|
||||
/// <typeparam name="TEvent">이벤트 타입.</typeparam>
|
||||
/// <param name="e">이벤트 인스턴스 (struct 권장).</param>
|
||||
/// <remarks>
|
||||
/// 핸들러 중 하나가 예외를 던져도 나머지 핸들러는 호출된다. 예외는 <see cref="Log"/> 로 기록.
|
||||
/// 호출 중 발생한 <see cref="Subscribe{TEvent}"/>·<see cref="Unsubscribe{TEvent}"/> 는 현재 iteration 에 영향 주지 않는다.
|
||||
/// </remarks>
|
||||
public static void Publish<TEvent>(TEvent e)
|
||||
{
|
||||
var handlers = Holder<TEvent>.Handlers;
|
||||
int count = handlers.Count;
|
||||
if (count == 0) return;
|
||||
|
||||
// iteration snapshot: 현재 호출 중 추가·제거가 일어나도 본 배치는 원본 기준.
|
||||
// 배열 할당 비용은 구독자 수가 많은 이벤트에서만 의미가 있으나, 안전성 우선.
|
||||
Action<TEvent>[] snapshot = new Action<TEvent>[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 특정 이벤트 타입의 모든 구독자를 제거한다. 테스트·씬 전환 시 유용.
|
||||
/// </summary>
|
||||
public static void Clear<TEvent>()
|
||||
{
|
||||
Holder<TEvent>.Handlers.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 지금까지 한 번이라도 Subscribe 된 모든 타입의 구독자를 일괄 제거한다.
|
||||
/// </summary>
|
||||
/// <remarks>주로 테스트 셋업·애플리케이션 종료 시 사용.</remarks>
|
||||
public static void ClearAll()
|
||||
{
|
||||
for (int i = 0; i < ClearCallbacks.Count; i++) ClearCallbacks[i].Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 특정 이벤트 타입의 현재 구독자 수 조회 (테스트·디버그용).
|
||||
/// </summary>
|
||||
public static int SubscriberCount<TEvent>() => Holder<TEvent>.Handlers.Count;
|
||||
|
||||
private static void EnsureRegistered<TEvent>()
|
||||
{
|
||||
Type t = typeof(TEvent);
|
||||
if (RegisteredTypes.Contains(t)) return;
|
||||
RegisteredTypes.Add(t);
|
||||
ClearCallbacks.Add(() => Holder<TEvent>.Handlers.Clear());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
// ---------------------------------------------------------------------------
|
||||
// NerdNavis.Framework
|
||||
// RawEventBus.cs — 문자열 키 기반 이벤트 버스 (특수 용도)
|
||||
// ---------------------------------------------------------------------------
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using NerdNavis.Core.Util.Log;
|
||||
|
||||
namespace NerdNavis.Core.Event.Raw
|
||||
{
|
||||
/// <summary>
|
||||
/// 문자열 키 기반 이벤트 버스. 외부 스크립팅·에디터 훅 등 타입 안전 경로가 불가능한 특수 용도에만 사용한다.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>설계 문서 §3-1 단서. 일반 용도는 <see cref="NerdNavis.Core.Event.EventBus"/> 를 쓴다.</para>
|
||||
/// <para>박싱 비용이 발생하므로 hot-path 에서는 피한다.</para>
|
||||
/// </remarks>
|
||||
public static class RawEventBus
|
||||
{
|
||||
private static readonly Dictionary<string, List<Action<object>>> HandlersByKey
|
||||
= new Dictionary<string, List<Action<object>>>(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>키에 대한 핸들러 등록.</summary>
|
||||
public static void Subscribe(string key, Action<object> 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<Action<object>>(2);
|
||||
HandlersByKey.Add(key, list);
|
||||
}
|
||||
list.Add(handler);
|
||||
}
|
||||
|
||||
/// <summary>키에 대한 핸들러 해제.</summary>
|
||||
public static bool Unsubscribe(string key, Action<object> handler)
|
||||
{
|
||||
if (handler == null) return false;
|
||||
return HandlersByKey.TryGetValue(key, out var list) && list.Remove(handler);
|
||||
}
|
||||
|
||||
/// <summary>키에 대한 이벤트 발행.</summary>
|
||||
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<object>[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); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>특정 키 구독자 일괄 제거.</summary>
|
||||
public static void Clear(string key)
|
||||
{
|
||||
if (HandlersByKey.TryGetValue(key, out var list)) list.Clear();
|
||||
}
|
||||
|
||||
/// <summary>모든 키 구독자 일괄 제거.</summary>
|
||||
public static void ClearAll()
|
||||
{
|
||||
foreach (var kv in HandlersByKey) kv.Value.Clear();
|
||||
HandlersByKey.Clear();
|
||||
}
|
||||
|
||||
/// <summary>특정 키의 현재 구독자 수.</summary>
|
||||
public static int SubscriberCount(string key)
|
||||
=> HandlersByKey.TryGetValue(key, out var list) ? list.Count : 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<int, string>();
|
||||
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, string>();
|
||||
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<int, string> { { 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<int, string> { { 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, string>();
|
||||
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<int, string> { { 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, string>();
|
||||
int resetCount = 0;
|
||||
map.Reset += () => resetCount++;
|
||||
|
||||
map.Clear();
|
||||
|
||||
Assert.That(resetCount, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryGetValue_Works()
|
||||
{
|
||||
var map = new ObservableDictionary<string, int> { { "a", 1 } };
|
||||
Assert.IsTrue(map.TryGetValue("a", out var v));
|
||||
Assert.That(v, Is.EqualTo(1));
|
||||
Assert.IsFalse(map.TryGetValue("missing", out _));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string>();
|
||||
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<int> { 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<int> { 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<int> { 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<int> { 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>();
|
||||
int resetCount = 0;
|
||||
list.Reset += () => resetCount++;
|
||||
|
||||
list.Clear();
|
||||
|
||||
Assert.That(resetCount, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Indexer_EmitsRemovedThenAdded()
|
||||
{
|
||||
var list = new ObservableList<int> { 10, 20, 30 };
|
||||
var sequence = new List<string>();
|
||||
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<int> { 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>();
|
||||
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<int>();
|
||||
Assert.DoesNotThrow(() => list.Add(1));
|
||||
Assert.DoesNotThrow(() => list.Clear());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<int>();
|
||||
var items = new List<int>();
|
||||
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<int>();
|
||||
q.Enqueue(1);
|
||||
q.Enqueue(2);
|
||||
var items = new List<int>();
|
||||
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>();
|
||||
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<int>();
|
||||
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<int>();
|
||||
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>();
|
||||
int resetCount = 0;
|
||||
q.Reset += () => resetCount++;
|
||||
|
||||
q.Clear();
|
||||
|
||||
Assert.That(resetCount, Is.EqualTo(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<int>
|
||||
{
|
||||
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<int, MonsterRow>(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<int, MonsterRow>(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<int, MonsterRow>(Array.Empty<MonsterRow>());
|
||||
Assert.Throws<KeyNotFoundException>(() => table.Get(999));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryGet_ReturnsFalseOnMissingKey()
|
||||
{
|
||||
var table = new DataTable<int, MonsterRow>(Array.Empty<MonsterRow>());
|
||||
Assert.IsFalse(table.TryGet(999, out _));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Contains_Works()
|
||||
{
|
||||
var table = new DataTable<int, MonsterRow>(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<ArgumentException>(() => new DataTable<int, MonsterRow>(rows));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Constructor_Null_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new DataTable<int, MonsterRow>(null));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// CSV loader
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
[Test]
|
||||
public void FromCsv_ParsesBasicRows()
|
||||
{
|
||||
string csv = "Id,Name,Hp\n10,Slime,100\n20,Goblin,200\n";
|
||||
|
||||
var table = DataTableLoader.FromCsv<int, MonsterRow>(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<int, MonsterRow>(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<int, MonsterRow>(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<int, MonsterRow>(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<int, MonsterRow>(csv, MonsterRow.FromCsv);
|
||||
|
||||
Assert.That(table.Count, Is.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FromCsv_NullText_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
DataTableLoader.FromCsv<int, MonsterRow>(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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<FooEvent> 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<int>();
|
||||
EventBus.Subscribe<FooEvent>(_ => order.Add(1));
|
||||
EventBus.Subscribe<FooEvent>(_ => order.Add(2));
|
||||
EventBus.Subscribe<FooEvent>(_ => 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<FooEvent> handler = _ => count++;
|
||||
EventBus.Subscribe(handler);
|
||||
EventBus.Unsubscribe(handler);
|
||||
|
||||
EventBus.Publish(new FooEvent());
|
||||
|
||||
Assert.That(count, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Unsubscribe_ReturnsFalseForUnknownHandler()
|
||||
{
|
||||
Action<FooEvent> handler = _ => { };
|
||||
Assert.IsFalse(EventBus.Unsubscribe(handler));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Publish_ContinuesAfterHandlerException()
|
||||
{
|
||||
int afterException = 0;
|
||||
EventBus.Subscribe<FooEvent>(_ => throw new InvalidOperationException("test"));
|
||||
EventBus.Subscribe<FooEvent>(_ => 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<FooEvent>(_ =>
|
||||
{
|
||||
EventBus.Subscribe<FooEvent>(__ => 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<FooEvent>(_ => fooCount++);
|
||||
EventBus.Subscribe<BarEvent>(_ => barCount++);
|
||||
|
||||
EventBus.Publish(new FooEvent());
|
||||
|
||||
Assert.That(fooCount, Is.EqualTo(1));
|
||||
Assert.That(barCount, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Clear_RemovesAllHandlersOfType()
|
||||
{
|
||||
EventBus.Subscribe<FooEvent>(_ => { });
|
||||
EventBus.Subscribe<FooEvent>(_ => { });
|
||||
Assert.That(EventBus.SubscriberCount<FooEvent>(), Is.EqualTo(2));
|
||||
|
||||
EventBus.Clear<FooEvent>();
|
||||
|
||||
Assert.That(EventBus.SubscriberCount<FooEvent>(), Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClearAll_RemovesAllTypes()
|
||||
{
|
||||
EventBus.Subscribe<FooEvent>(_ => { });
|
||||
EventBus.Subscribe<BarEvent>(_ => { });
|
||||
|
||||
EventBus.ClearAll();
|
||||
|
||||
Assert.That(EventBus.SubscriberCount<FooEvent>(), Is.EqualTo(0));
|
||||
Assert.That(EventBus.SubscriberCount<BarEvent>(), Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Subscribe_NullHandler_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => EventBus.Subscribe<FooEvent>(null));
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue