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:
깃 관리자 2026-04-17 20:31:14 +09:00
parent 95e47d8288
commit fb51e94d88
16 changed files with 1533 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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&lt;TRow&gt; 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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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