feat(core): NerdNavis.Framework 코어코드를 조직 레포에 통합 (PD님 직접 지시)

단일 clone으로 전체 조직 자산 접근 가능하도록 코어코드를 NerdNavisAi 레포 내 배치.
- 코어코드/NerdNavis.Framework/ 에 전체 소스 복사 (30파일, .git 제외)
- 원본 별도 레포는 유지 (C+H1 배포 방식의 원격 SOT)
- 배포 방식: C+H1 (UPM Git URL + 로컬 오버라이드) PD님 승인 완료

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-04-16 16:44:27 +09:00
parent db47786c82
commit 7187ac68b2
31 changed files with 1715 additions and 0 deletions

View File

@ -31,6 +31,7 @@ C3·C13 위반에 해당. **즉시 자진 보고 후 소급 등록**.
| # | 일시 | 지시 요지 | 처리 상태 | 산출물 경로 | 중단 사유 | 사후 조치 | | # | 일시 | 지시 요지 | 처리 상태 | 산출물 경로 | 중단 사유 | 사후 조치 |
|---|------|----------|----------|-----------|----------|----------| |---|------|----------|----------|-----------|----------|----------|
| 27 | 2026-04-16 | NerdNavis.Framework 코어코드를 NerdNavisAi 조직 레포에 통합 — `코어코드/NerdNavis.Framework/`에 복사, git 커밋·푸시, 대화로그·지시로그 기록 | 진행중 | - | - | - |
| 26 | 2026-04-16 | NerdNavis.Framework git 통합 관리 조치 — 저장소 상태 점검(git remote·원격 연결·코드 구조 정합성), 구현 완료/미완료 모듈 목록 정리, 설계 문서와 실제 코드 정합성 확인 후 보고서 작성 | 완료 | `공유/소통/개발팀→PM/2026-04-16_코어코드_git통합_점검_개발팀.md`, `공유/대화로그/코어프레임워크/2026-04-16.md` | - | - | | 26 | 2026-04-16 | NerdNavis.Framework git 통합 관리 조치 — 저장소 상태 점검(git remote·원격 연결·코드 구조 정합성), 구현 완료/미완료 모듈 목록 정리, 설계 문서와 실제 코드 정합성 확인 후 보고서 작성 | 완료 | `공유/소통/개발팀→PM/2026-04-16_코어코드_git통합_점검_개발팀.md`, `공유/대화로그/코어프레임워크/2026-04-16.md` | - | - |
| 1 | 2026-04-14 | NerdNavisCore 타 회사 소유 전환·담당자 퇴사 사실 통보, 자체 범용 코어 신규 제작 결정 | 진행중 | `개발팀/프로젝트_숙지/수상한잡화점/06_신규코어_설계안_v1.md` (초안), `개발팀/코어_설계/01_아키텍처_개요_v1.md` (v1.2→§4-9 ServiceLocator 신설 추가), `개발팀/코어_설계/02_수상한잡화점_추출대상_v1.md` (13+ 파일 분류표), `개발팀/코어_설계/_skeleton/` (UPM 패키지 스켈레톤), **`D:/NerdNavis/NerdNavis.Framework/` 구현체 — Tier 1 기반 Core 4종 완료 (Log·CoroutineRunner·MonoSingleton·ServiceLocator + 테스트 28건) Gitea push 완료** | - | OI-1(네임스페이스 NerdNavis.*) PD님 확정 반영 완료. **OI-2·3·4·5는 여전히 PD님 판단 대기**. Tier 1+2 MVP 범위 PD님 확정 반영. **Tier 1 잔여 9종(EnumToInt/EnumEx/FormatEx/SafeAreaBorder 등) 대기** | | 1 | 2026-04-14 | NerdNavisCore 타 회사 소유 전환·담당자 퇴사 사실 통보, 자체 범용 코어 신규 제작 결정 | 진행중 | `개발팀/프로젝트_숙지/수상한잡화점/06_신규코어_설계안_v1.md` (초안), `개발팀/코어_설계/01_아키텍처_개요_v1.md` (v1.2→§4-9 ServiceLocator 신설 추가), `개발팀/코어_설계/02_수상한잡화점_추출대상_v1.md` (13+ 파일 분류표), `개발팀/코어_설계/_skeleton/` (UPM 패키지 스켈레톤), **`D:/NerdNavis/NerdNavis.Framework/` 구현체 — Tier 1 기반 Core 4종 완료 (Log·CoroutineRunner·MonoSingleton·ServiceLocator + 테스트 28건) Gitea push 완료** | - | OI-1(네임스페이스 NerdNavis.*) PD님 확정 반영 완료. **OI-2·3·4·5는 여전히 PD님 판단 대기**. Tier 1+2 MVP 범위 PD님 확정 반영. **Tier 1 잔여 9종(EnumToInt/EnumEx/FormatEx/SafeAreaBorder 등) 대기** |
| 2 | 2026-04-14 | 서버 Critical 보안 3건 보류 | 보류 | `개발팀/프로젝트_숙지/수상한잡화점/05_서버연동_현황_v1.md` | 서버 파트 정비 미완료 (PD님 지시) | 서버팀 가동 시점에 블로커급 재개. 담당: 서버팀장. 재개 트리거: 서버 파트 정비 완료 통보 | | 2 | 2026-04-14 | 서버 Critical 보안 3건 보류 | 보류 | `개발팀/프로젝트_숙지/수상한잡화점/05_서버연동_현황_v1.md` | 서버 파트 정비 미완료 (PD님 지시) | 서버팀 가동 시점에 블로커급 재개. 담당: 서버팀장. 재개 트리거: 서버 파트 정비 완료 통보 |

View File

@ -0,0 +1,52 @@
# 기본 텍스트 처리: 자동 개행 정규화
* text=auto eol=lf
# Windows 배치 파일만 CRLF 유지
*.bat text eol=crlf
*.cmd text eol=crlf
# C# / Unity 텍스트
*.cs text diff=csharp
*.asmdef text
*.json text
*.md text
*.txt text
*.xml text
*.yml text
*.yaml text
# Unity 에셋 텍스트 포맷 (Force Text 모드 가정)
*.unity text merge=unityyamlmerge
*.prefab text merge=unityyamlmerge
*.asset text merge=unityyamlmerge
*.mat text merge=unityyamlmerge
*.anim text merge=unityyamlmerge
*.controller text merge=unityyamlmerge
*.meta text merge=unityyamlmerge
# 바이너리
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.psd binary
*.tga binary
*.tif binary
*.tiff binary
*.mp3 binary
*.wav binary
*.ogg binary
*.mp4 binary
*.mov binary
*.fbx binary
*.obj binary
*.blend binary
*.ttf binary
*.otf binary
*.dll binary
*.so binary
*.dylib binary
# Git LFS 후보 (초기엔 비활성, 대용량 에셋 추가 시 활성화)
# *.png filter=lfs diff=lfs merge=lfs -text
# *.fbx filter=lfs diff=lfs merge=lfs -text

View File

@ -0,0 +1,44 @@
# Unity 생성물 (패키지에는 포함되지 않음)
[Ll]ibrary/
[Tt]emp/
[Oo]bj/
[Bb]uild/
[Bb]uilds/
[Ll]ogs/
[Mm]emoryCaptures/
# Unity 메타 캐시
*.pidb.meta
*.pdb.meta
*.mdb.meta
# Visual Studio / Rider
.vs/
.idea/
*.csproj
*.sln
*.suo
*.user
*.userprefs
*.pidb
*.booproj
*.svd
*.pdb
*.mdb
*.opendb
*.VC.db
# OS
.DS_Store
Thumbs.db
desktop.ini
# 빌드 산출물
*.apk
*.aab
*.ipa
*.unitypackage
# 임시 / 로그
*.log
*.tmp

View File

@ -0,0 +1,14 @@
# Changelog
이 프로젝트의 모든 주요 변경 사항은 이 파일에 기록한다.
포맷은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르고, 버저닝은 [Semantic Versioning](https://semver.org/lang/ko/)을 따른다.
## [Unreleased]
### Added
- 패키지 스켈레톤 (폴더 구조, asmdef, package.json)
## [0.1.0] - TBD
최초 릴리즈 예정.

View File

@ -0,0 +1,18 @@
{
"name": "NerdNavis.Framework.Editor",
"rootNamespace": "NerdNavis.Editor",
"references": [
"NerdNavis.Framework"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

@ -0,0 +1,52 @@
# NerdNavis.Framework
너드나비스 자체 범용 Unity 프레임워크.
## 개요
기존 외부 의존 코어(`NerdNavisCore`)가 이전·퇴사로 사용 불가해짐에 따라, 너드나비스가 자체적으로 보유·유지하는 범용 코어를 새로 구축한다. 수상한 잡화점 등 사내 프로젝트에서 반복되는 패턴을 Tier 단위로 흡수하여 차기 프로젝트부터 바로 활용 가능한 형태로 제공한다.
## 설치 (Unity Package Manager)
```
https://burning.i234.me/NerdNavis/NerdNavis.Framework.git
```
Unity 에디터 → Package Manager → `+`**Add package from git URL...** → 위 URL 입력.
특정 버전 고정:
```
https://burning.i234.me/NerdNavis/NerdNavis.Framework.git#v0.1.0
```
## 폴더 구조
```
Runtime/
├── Core/
│ ├── Patterns/ # MonoSingleton 등
│ ├── Coroutine/ # CoroutineRunner
│ └── Util/ # ValidationEx, ObjectEx, FormatEx, EnumEx, EnumToInt, Log
├── UI/
│ ├── UGUI/ # InfiniteScrollView, SpriteAtlasRegistry, BackKeyHandler
│ └── Components/ # SafeAreaBorder
├── Addressable/ # AddressableHandle, AutoReleaseComponent (Tier 2)
└── Security/ # CryptoUtil, ICryptoProvider (Tier 3)
Editor/ # 에디터 전용 유틸
Tests/ # Runtime/Editor 테스트
Documentation~/ # Unity 임포트 제외 (~ 접두)
```
## 개발 원칙
- **네이밍**: `My*`·`u*` 접두 금지, PascalCase 준수, `FilGoodBandits``NerdNavis.*`
- **의존성 단절**: 프로젝트 특수 enum/테이블 참조 제거
- **제네릭 우선**: 하드코딩 메서드는 제네릭 팩토리로 재설계
- **싱글톤 최소화**: 필요 최소 외 DI/이벤트 기반으로 전환
자세한 내용은 `개발실/코어_설계/01_아키텍처_개요_v1.md`, `02_수상한잡화점_추출대상_v1.md` 참조.
## 라이선스
사내 사용. 외부 배포 금지.

View File

@ -0,0 +1,9 @@
// ---------------------------------------------------------------------------
// NerdNavis.Framework
// AssemblyInfo.cs — 테스트 어셈블리에 internal 멤버 노출
// ---------------------------------------------------------------------------
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("NerdNavis.Framework.Tests")]
[assembly: InternalsVisibleTo("NerdNavis.Framework.Editor")]
[assembly: InternalsVisibleTo("NerdNavis.Framework.Editor.Tests")]

View File

@ -0,0 +1,36 @@
// ---------------------------------------------------------------------------
// NerdNavis.Framework
// CoroutineHandle.cs — CoroutineRunner 반환 핸들
// ---------------------------------------------------------------------------
using System;
namespace NerdNavis.Core.Coroutine
{
/// <summary>
/// <see cref="CoroutineRunner"/>에서 발급하는 코루틴 핸들.
/// </summary>
/// <remarks>
/// ID 기반 참조값이며 값 타입이다. <see cref="None"/>은 유효하지 않은 핸들을 의미한다.
/// 핸들을 보관한 쪽에서 중단·일시정지·재개 등을 요청할 때 사용한다.
/// </remarks>
public readonly struct CoroutineHandle : IEquatable<CoroutineHandle>
{
/// <summary>유효하지 않은 핸들.</summary>
public static readonly CoroutineHandle None = new CoroutineHandle(0);
/// <summary>내부 식별 ID. 0은 유효하지 않음.</summary>
public readonly ulong Id;
internal CoroutineHandle(ulong id) { Id = id; }
/// <summary>핸들이 유효한지 여부(ID가 0이 아닌지).</summary>
public bool IsValid => Id != 0;
public bool Equals(CoroutineHandle other) => Id == other.Id;
public override bool Equals(object obj) => obj is CoroutineHandle h && Equals(h);
public override int GetHashCode() => Id.GetHashCode();
public static bool operator ==(CoroutineHandle a, CoroutineHandle b) => a.Id == b.Id;
public static bool operator !=(CoroutineHandle a, CoroutineHandle b) => a.Id != b.Id;
public override string ToString() => IsValid ? $"Coroutine#{Id}" : "Coroutine#None";
}
}

View File

@ -0,0 +1,213 @@
// ---------------------------------------------------------------------------
// NerdNavis.Framework
// CoroutineRunner.cs — 일시정지·재개·키 중복방지를 지원하는 전역 코루틴 러너
// ---------------------------------------------------------------------------
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using NerdNavis.Core.Util.Log;
namespace NerdNavis.Core.Coroutine
{
/// <summary>
/// 전역에서 사용 가능한 코루틴 러너.
/// </summary>
/// <remarks>
/// <para>기존 NerdNavisCore의 <c>CoroutineHandler</c>와 <c>CoroutineRunner</c> 2종을 하나로 통합했다.
/// 수상한 잡화점의 <c>MyCoroutine</c>도 이 러너로 흡수된다.</para>
/// <para>호스트는 <see cref="CoroutineHost"/> 내부 <see cref="MonoBehaviour"/>로,
/// <c>DontDestroyOnLoad</c> 처리된다. 첫 호출 시점에 지연 생성되며, 씬 전환과 무관하게 유지된다.</para>
/// <para>주요 기능:</para>
/// <list type="bullet">
/// <item><description>핸들 기반 중단 / 일시정지 / 재개</description></item>
/// <item><description>문자열 키 기반 중복 정책 (Replace/Ignore/Allow)</description></item>
/// <item><description>전체 중단 <see cref="StopAll"/></description></item>
/// </list>
/// </remarks>
public static class CoroutineRunner
{
private const string LogCategory = "Coroutine";
private static CoroutineHost _host;
private static ulong _nextId = 1;
private static readonly Dictionary<ulong, Entry> _entries = new Dictionary<ulong, Entry>();
private static readonly Dictionary<string, ulong> _keyToId = new Dictionary<string, ulong>();
// -----------------------------------------------------------------
// 공개 API — 단순 시작
// -----------------------------------------------------------------
/// <summary>코루틴을 시작한다.</summary>
/// <param name="routine">실행할 <see cref="IEnumerator"/>.</param>
/// <returns>중단·제어에 사용하는 핸들. <paramref name="routine"/>이 null이면 <see cref="CoroutineHandle.None"/>.</returns>
public static CoroutineHandle Start(IEnumerator routine)
=> StartInternal(routine, key: null);
/// <summary>문자열 키로 코루틴을 시작한다. 동일 키 실행 중이면 <paramref name="policy"/>에 따라 처리.</summary>
public static CoroutineHandle Start(string key, IEnumerator routine,
DuplicatePolicy policy = DuplicatePolicy.Replace)
{
if (routine == null) return CoroutineHandle.None;
if (string.IsNullOrEmpty(key)) return StartInternal(routine, key: null);
if (_keyToId.TryGetValue(key, out var existingId))
{
switch (policy)
{
case DuplicatePolicy.Ignore:
return new CoroutineHandle(existingId);
case DuplicatePolicy.Replace:
StopInternal(existingId);
break;
case DuplicatePolicy.Allow:
// 기존 유지, 새 항목은 키 등록하지 않음.
return StartInternal(routine, key: null);
}
}
return StartInternal(routine, key);
}
// -----------------------------------------------------------------
// 공개 API — 제어
// -----------------------------------------------------------------
/// <summary>핸들로 실행 중인 코루틴을 중단한다.</summary>
public static void Stop(CoroutineHandle handle)
{
if (!handle.IsValid) return;
StopInternal(handle.Id);
}
/// <summary>키로 실행 중인 코루틴을 중단한다.</summary>
public static void StopByKey(string key)
{
if (string.IsNullOrEmpty(key)) return;
if (_keyToId.TryGetValue(key, out var id)) StopInternal(id);
}
/// <summary>실행 중인 모든 코루틴을 중단한다.</summary>
public static void StopAll()
{
if (_host == null) return;
_host.StopAllCoroutines();
_entries.Clear();
_keyToId.Clear();
}
/// <summary>핸들로 일시정지. 재개 전까지 <c>yield return</c> 이 소비되지 않는다.</summary>
public static void Pause(CoroutineHandle handle)
{
if (_entries.TryGetValue(handle.Id, out var e)) e.IsPaused = true;
}
/// <summary>핸들로 재개.</summary>
public static void Resume(CoroutineHandle handle)
{
if (_entries.TryGetValue(handle.Id, out var e)) e.IsPaused = false;
}
/// <summary>핸들이 현재 실행 중인지 조회.</summary>
public static bool IsRunning(CoroutineHandle handle)
=> handle.IsValid && _entries.ContainsKey(handle.Id);
/// <summary>키가 현재 실행 중인지 조회.</summary>
public static bool IsRunningByKey(string key)
=> !string.IsNullOrEmpty(key) && _keyToId.ContainsKey(key);
/// <summary>핸들에 해당하는 코루틴의 일시정지 여부.</summary>
public static bool IsPaused(CoroutineHandle handle)
=> _entries.TryGetValue(handle.Id, out var e) && e.IsPaused;
// -----------------------------------------------------------------
// 내부
// -----------------------------------------------------------------
private static CoroutineHandle StartInternal(IEnumerator routine, string key)
{
if (routine == null) return CoroutineHandle.None;
EnsureHost();
var id = _nextId++;
if (_nextId == 0) _nextId = 1; // 0은 None 예약
var entry = new Entry { Id = id, Source = routine, Key = key, IsPaused = false };
_entries[id] = entry;
if (!string.IsNullOrEmpty(key)) _keyToId[key] = id;
entry.UnityCoroutine = _host.StartCoroutine(Wrap(entry));
return new CoroutineHandle(id);
}
private static void StopInternal(ulong id)
{
if (!_entries.TryGetValue(id, out var entry)) return;
if (_host != null && entry.UnityCoroutine != null)
_host.StopCoroutine(entry.UnityCoroutine);
_entries.Remove(id);
if (entry.Key != null) _keyToId.Remove(entry.Key);
}
private static IEnumerator Wrap(Entry entry)
{
var source = entry.Source;
while (true)
{
// 일시정지면 프레임 단위로 대기
while (entry.IsPaused) yield return null;
bool moved;
try { moved = source.MoveNext(); }
catch (System.Exception ex)
{
Log.Error(LogCategory, $"coroutine threw (id={entry.Id}, key={entry.Key ?? "<none>"})", ex);
break;
}
if (!moved) break;
yield return source.Current;
}
// 정상 종료·예외 종료 공통 정리
_entries.Remove(entry.Id);
if (entry.Key != null && _keyToId.TryGetValue(entry.Key, out var kid) && kid == entry.Id)
_keyToId.Remove(entry.Key);
}
private static void EnsureHost()
{
if (_host != null) return;
var go = new GameObject("[NerdNavis.CoroutineHost]");
Object.DontDestroyOnLoad(go);
go.hideFlags = HideFlags.HideAndDontSave;
_host = go.AddComponent<CoroutineHost>();
}
/// <summary>테스트 전용: 내부 상태 초기화.</summary>
internal static void ResetForTests()
{
if (_host != null)
{
_host.StopAllCoroutines();
Object.Destroy(_host.gameObject);
_host = null;
}
_entries.Clear();
_keyToId.Clear();
_nextId = 1;
}
// 내부 엔트리 — 클래스로 선언하여 Wrap 람다와 dictionary가 같은 참조를 공유하도록 함
private sealed class Entry
{
public ulong Id;
public IEnumerator Source;
public string Key;
public bool IsPaused;
public UnityEngine.Coroutine UnityCoroutine;
}
/// <summary>전역 코루틴 호스트.</summary>
private sealed class CoroutineHost : MonoBehaviour { }
}
}

View File

@ -0,0 +1,22 @@
// ---------------------------------------------------------------------------
// NerdNavis.Framework
// DuplicatePolicy.cs — 키 기반 코루틴 중복 정책
// ---------------------------------------------------------------------------
namespace NerdNavis.Core.Coroutine
{
/// <summary>
/// 동일 키로 <see cref="CoroutineRunner.Start(string, System.Collections.IEnumerator, DuplicatePolicy)"/>
/// 가 호출됐을 때 기존 실행 중인 코루틴을 어떻게 처리할지 정의한다.
/// </summary>
public enum DuplicatePolicy
{
/// <summary>기존 코루틴을 중단하고 새로 시작한다(기본값).</summary>
Replace = 0,
/// <summary>기존 코루틴을 유지하고 새 요청은 무시한다.</summary>
Ignore = 1,
/// <summary>기존 코루틴을 유지하고 중복 시작을 허용한다(키 공유, 핸들만 다름).</summary>
Allow = 2,
}
}

View File

@ -0,0 +1,30 @@
// ---------------------------------------------------------------------------
// NerdNavis.Framework
// InitMode.cs — MonoSingleton 초기화 모드
// ---------------------------------------------------------------------------
namespace NerdNavis.Core.Patterns
{
/// <summary>
/// <see cref="MonoSingleton{T}"/>의 초기화 모드.
/// </summary>
public enum InitMode
{
/// <summary>
/// <see cref="MonoSingleton{T}.OnInitialized"/> 가 <c>Awake</c>에서 즉시 실행되고
/// <see cref="MonoSingleton{T}.IsReady"/>는 그 직후 <c>true</c>가 된다(기본값).
/// </summary>
Sync = 0,
/// <summary>
/// <see cref="MonoSingleton{T}.OnInitializeAsync"/> 가 코루틴으로 실행되며
/// 완료 시 <see cref="MonoSingleton{T}.IsReady"/>가 <c>true</c>로 전환된다.
/// </summary>
Async = 1,
/// <summary>
/// 구현체가 <see cref="MonoSingleton{T}.MarkReady"/> 를 직접 호출할 때까지
/// <see cref="MonoSingleton{T}.IsReady"/>는 <c>false</c>를 유지한다.
/// </summary>
ManualReady = 2,
}
}

View File

@ -0,0 +1,190 @@
// ---------------------------------------------------------------------------
// NerdNavis.Framework
// MonoSingleton.cs — 4종 통합(Sync/Async/Inner/Ready) MonoBehaviour 싱글톤
// ---------------------------------------------------------------------------
using System.Collections;
using UnityEngine;
using NerdNavis.Core.Coroutine;
using NerdNavis.Core.Util.Log;
namespace NerdNavis.Core.Patterns
{
/// <summary>
/// <see cref="MonoBehaviour"/> 단일 인스턴스 베이스 클래스.
/// </summary>
/// <remarks>
/// <para>기존 NerdNavisCore의 4종(<c>Singleton</c>, <c>AsyncSingleton</c>, <c>InnerSingleton</c>,
/// <c>ReadySingleton</c>)을 통합했다(설계 문서 §4-1). 옵션은 <see cref="Attribute"/> 방식이 아닌
/// 가상 프로퍼티로 노출하여 런타임 리플렉션 비용을 제거했다.</para>
/// <para>동작 규칙:</para>
/// <list type="bullet">
/// <item><description><see cref="Instance"/>는 씬에 있으면 찾고, 없으면 <see cref="AutoCreate"/> 판단 후 생성.</description></item>
/// <item><description><see cref="Persistent"/>이면 <c>DontDestroyOnLoad</c> 적용.</description></item>
/// <item><description><see cref="InitMode"/>에 따라 <see cref="OnInitialized"/> / <see cref="OnInitializeAsync"/>
/// / <see cref="MarkReady"/> 중 하나로 준비 상태가 결정된다.</description></item>
/// <item><description>중복 인스턴스가 발견되면 나중 것이 파괴된다(<see cref="OnDuplicateDestroyed"/> 훅).</description></item>
/// <item><description>애플리케이션 종료 후 <see cref="Instance"/> 접근은 <c>null</c>을 반환한다(Unity destroyed object 회피).</description></item>
/// </list>
/// <para>주의: Unity 메인 스레드에서만 접근한다. 다른 스레드 호출은 지원하지 않는다.</para>
/// </remarks>
public abstract class MonoSingleton<T> : MonoBehaviour where T : MonoSingleton<T>
{
private const string LogCategory = "Singleton";
private static T _instance;
private static bool _applicationIsQuitting;
private static readonly object _gate = new object();
/// <summary>
/// 싱글톤 인스턴스. 없으면 <see cref="AutoCreate"/> 규칙으로 자동 생성 시도.
/// 애플리케이션 종료 중이면 <c>null</c>을 반환한다.
/// </summary>
public static T Instance
{
get
{
if (_applicationIsQuitting) return null;
if (_instance != null) return _instance;
lock (_gate)
{
if (_instance != null) return _instance;
// 씬 검색
var found = FindAnyObjectByType<T>(FindObjectsInactive.Include);
if (found != null)
{
_instance = found;
_instance.ApplyPersistence();
return _instance;
}
// 자동 생성 판정 — 프로토타입 인스턴스로 AutoCreate 값만 읽음
// AutoCreate=false 이면 외부가 GameObject에 붙여야 한다(InnerSingleton 대응)
var probe = new GameObject(typeof(T).Name + "_probe").AddComponent<T>();
bool autoCreate = probe.AutoCreate;
if (!autoCreate)
{
Destroy(probe.gameObject);
Log.Warn(LogCategory,
$"{typeof(T).Name}: AutoCreate=false, 씬에 인스턴스가 없어 null 반환");
return null;
}
// probe 가 이미 Awake를 통해 _instance 에 등록되었을 것
probe.gameObject.name = typeof(T).Name;
return _instance;
}
}
}
/// <summary>싱글톤이 이미 존재하거나 조회 가능한지 확인(자동 생성 트리거 없음).</summary>
public static bool Exists => !_applicationIsQuitting && _instance != null;
/// <summary>초기화 완료 여부. <see cref="InitMode"/>에 따라 결정된다.</summary>
public static bool IsReady { get; private set; }
// -----------------------------------------------------------------
// 옵션 (파생에서 override)
// -----------------------------------------------------------------
/// <summary>씬 전환에도 파괴되지 않을지 여부. 기본 <c>true</c>.</summary>
protected virtual bool Persistent => true;
/// <summary>인스턴스 부재 시 자동 생성 여부. 기본 <c>true</c>.</summary>
protected virtual bool AutoCreate => true;
/// <summary>초기화 모드. 기본 <see cref="Core.Patterns.InitMode.Sync"/>.</summary>
protected virtual InitMode InitMode => InitMode.Sync;
// -----------------------------------------------------------------
// 라이프사이클 훅 (파생에서 override)
// -----------------------------------------------------------------
/// <summary><see cref="InitMode.Sync"/>에서 <c>Awake</c> 시 호출된다.</summary>
protected virtual void OnInitialized() { }
/// <summary><see cref="InitMode.Async"/>에서 코루틴으로 실행된다. 종료 시점에 <see cref="IsReady"/>가 <c>true</c>.</summary>
protected virtual IEnumerator OnInitializeAsync() { yield break; }
/// <summary>중복 인스턴스가 파괴될 때 마지막 호출. 리소스 정리 훅.</summary>
protected virtual void OnDuplicateDestroyed() { }
/// <summary><see cref="InitMode.ManualReady"/>에서 구현체가 호출하여 준비 상태로 전환.</summary>
protected void MarkReady() { IsReady = true; }
// -----------------------------------------------------------------
// Unity 라이프사이클
// -----------------------------------------------------------------
protected virtual void Awake()
{
if (_instance != null && _instance != this)
{
Log.Warn(LogCategory,
$"{typeof(T).Name}: 중복 인스턴스 감지, 이번 GameObject 파괴");
OnDuplicateDestroyed();
Destroy(gameObject);
return;
}
_instance = (T)this;
ApplyPersistence();
switch (InitMode)
{
case InitMode.Sync:
OnInitialized();
IsReady = true;
break;
case InitMode.Async:
CoroutineRunner.Start(InitializeAsyncWrapper());
break;
case InitMode.ManualReady:
// 구현체가 MarkReady 호출할 때까지 대기
break;
}
}
private IEnumerator InitializeAsyncWrapper()
{
yield return OnInitializeAsync();
IsReady = true;
}
protected virtual void OnDestroy()
{
if (_instance == this)
{
_instance = null;
IsReady = false;
}
}
protected virtual void OnApplicationQuit()
{
_applicationIsQuitting = true;
}
// -----------------------------------------------------------------
// 공용 유틸
// -----------------------------------------------------------------
private void ApplyPersistence()
{
if (!Persistent) return;
if (transform.parent != null) transform.SetParent(null, true); // DontDestroyOnLoad는 루트 객체에만 적용
DontDestroyOnLoad(gameObject);
}
/// <summary>테스트 전용: 정적 상태 초기화.</summary>
internal static void ResetForTests()
{
if (_instance != null && _instance.gameObject != null)
Object.DestroyImmediate(_instance.gameObject);
_instance = null;
IsReady = false;
_applicationIsQuitting = false;
}
}
}

View File

@ -0,0 +1,161 @@
// ---------------------------------------------------------------------------
// NerdNavis.Framework
// ServiceLocator.cs — 경량 서비스 레지스트리 (순수 C#, MonoBehaviour 비의존)
// ---------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using NerdNavis.Core.Util.Log;
namespace NerdNavis.Core.Patterns
{
/// <summary>
/// 타입 키 기반 경량 서비스 레지스트리.
/// </summary>
/// <remarks>
/// <para>설계 문서 §4-9 (v1.2 신설)에 따른 구현. MonoBehaviour와 무관한 순수 C# 서비스
/// 의 중앙 레지스트리로, MonoSingleton(씬 생명주기 동반)과 EventBus(이벤트 분기)와 역할이
/// 분리된 3축의 한 축을 담당한다.</para>
/// <para>용도:</para>
/// <list type="bullet">
/// <item><description>인터페이스 기반 느슨한 결합 — <c>ISaveProvider</c> 등으로 등록 후 구현 교체 용이</description></item>
/// <item><description>테스트 시 Mock 주입 — <see cref="Clear"/>로 전역 초기화, Mock <see cref="Register{T}(T)"/></description></item>
/// <item><description>Lazy 생성 — <see cref="Register{T}(Func{T})"/> 로 첫 <see cref="Resolve{T}"/> 시 생성</description></item>
/// </list>
/// <para>주의 규칙 (§4-9):</para>
/// <list type="number">
/// <item><description>인터페이스 타입 등록 권장, 구체 타입 강결합 회피</description></item>
/// <item><description>글로벌 서비스만 등록. 씬 전환 시 스코프가 필요하면 MonoSingleton 사용</description></item>
/// <item><description><see cref="Resolve{T}"/> 실패 시 <see cref="ServiceNotRegisteredException"/> — silent null 금지</description></item>
/// <item><description>코어 자체는 ServiceLocator에 의존하지 않음(순환 방지)</description></item>
/// </list>
/// </remarks>
public static class ServiceLocator
{
private const string LogCategory = "ServiceLocator";
private static readonly object _gate = new object();
private static readonly Dictionary<Type, object> _instances = new Dictionary<Type, object>();
private static readonly Dictionary<Type, Func<object>> _factories = new Dictionary<Type, Func<object>>();
// -----------------------------------------------------------------
// 등록
// -----------------------------------------------------------------
/// <summary>
/// 서비스 인스턴스를 등록한다. 동일 타입 재등록은 덮어쓴다(경고 로그).
/// </summary>
/// <exception cref="ArgumentNullException"><paramref name="service"/>가 <c>null</c>.</exception>
public static void Register<T>(T service) where T : class
{
if (service == null) throw new ArgumentNullException(nameof(service));
var type = typeof(T);
lock (_gate)
{
if (_instances.ContainsKey(type) || _factories.ContainsKey(type))
Log.Warn(LogCategory, $"Register<{type.Name}>: 기존 바인딩을 덮어쓴다");
_instances[type] = service;
_factories.Remove(type);
}
}
/// <summary>
/// Lazy 팩토리를 등록한다. 첫 <see cref="Resolve{T}"/> 호출 시점에 팩토리가 실행되고,
/// 결과 인스턴스가 캐싱된다(이후 호출은 캐시 반환).
/// </summary>
/// <exception cref="ArgumentNullException"><paramref name="factory"/>가 <c>null</c>.</exception>
public static void Register<T>(Func<T> factory) where T : class
{
if (factory == null) throw new ArgumentNullException(nameof(factory));
var type = typeof(T);
lock (_gate)
{
if (_instances.ContainsKey(type) || _factories.ContainsKey(type))
Log.Warn(LogCategory, $"Register<{type.Name}>(factory): 기존 바인딩을 덮어쓴다");
_instances.Remove(type);
_factories[type] = () => factory();
}
}
// -----------------------------------------------------------------
// 조회
// -----------------------------------------------------------------
/// <summary>등록된 서비스를 조회한다. 미등록이면 예외.</summary>
/// <exception cref="ServiceNotRegisteredException">해당 타입이 등록되지 않음.</exception>
public static T Resolve<T>() where T : class
{
if (TryResolve<T>(out var service)) return service;
throw new ServiceNotRegisteredException(typeof(T));
}
/// <summary>등록된 서비스를 조회한다. 미등록이면 <c>false</c>.</summary>
public static bool TryResolve<T>(out T service) where T : class
{
var type = typeof(T);
lock (_gate)
{
if (_instances.TryGetValue(type, out var cached))
{
service = cached as T;
return service != null;
}
if (_factories.TryGetValue(type, out var factory))
{
object created;
try { created = factory(); }
catch (Exception ex)
{
Log.Error(LogCategory, $"factory for {type.Name} threw", ex);
service = null;
return false;
}
if (created is T typed)
{
_instances[type] = typed; // 캐시
_factories.Remove(type);
service = typed;
return true;
}
Log.Error(LogCategory,
$"factory for {type.Name} returned incompatible type {created?.GetType().Name ?? "<null>"}");
service = null;
return false;
}
}
service = null;
return false;
}
/// <summary>해당 타입이 등록되어 있는지 조회(팩토리 포함).</summary>
public static bool IsRegistered<T>() where T : class
{
var type = typeof(T);
lock (_gate) { return _instances.ContainsKey(type) || _factories.ContainsKey(type); }
}
// -----------------------------------------------------------------
// 해제
// -----------------------------------------------------------------
/// <summary>해당 타입의 서비스 등록을 해제한다.</summary>
public static void Unregister<T>() where T : class
{
var type = typeof(T);
lock (_gate)
{
_instances.Remove(type);
_factories.Remove(type);
}
}
/// <summary>모든 등록을 해제한다(테스트 용도).</summary>
public static void Clear()
{
lock (_gate)
{
_instances.Clear();
_factories.Clear();
}
}
}
}

View File

@ -0,0 +1,26 @@
// ---------------------------------------------------------------------------
// NerdNavis.Framework
// ServiceNotRegisteredException.cs — ServiceLocator 조회 실패 예외
// ---------------------------------------------------------------------------
using System;
namespace NerdNavis.Core.Patterns
{
/// <summary>
/// <see cref="ServiceLocator.Resolve{T}"/> 호출 시 해당 타입이 등록되지 않은 경우 발생.
/// </summary>
/// <remarks>
/// silent null 반환을 금지하는 설계 원칙(§4-9 규칙 3)에 따라 명시적으로 예외를 던진다.
/// 실패를 허용하는 호출부는 <see cref="ServiceLocator.TryResolve{T}"/>를 사용한다.
/// </remarks>
public sealed class ServiceNotRegisteredException : Exception
{
public Type ServiceType { get; }
public ServiceNotRegisteredException(Type serviceType)
: base($"Service '{serviceType?.FullName ?? "<null>"}' is not registered in ServiceLocator.")
{
ServiceType = serviceType;
}
}
}

View File

@ -0,0 +1,30 @@
// ---------------------------------------------------------------------------
// NerdNavis.Framework
// ILogSink.cs — 외부 로그 수신자 인터페이스
// ---------------------------------------------------------------------------
using System;
namespace NerdNavis.Core.Util.Log
{
/// <summary>
/// 로그 이벤트를 외부로 전달할 때 사용하는 sink 인터페이스.
/// 크래시 리포터·파일 로거·원격 서버 전송 등을 구현체로 등록할 수 있다.
/// </summary>
/// <remarks>
/// 기존 <c>ErrorLogHookManager</c>의 역할을 인터페이스로 추상화한 형태다.
/// <see cref="Log"/>는 등록된 모든 sink에 순차적으로 로그를 전달한다.
/// sink 구현은 예외를 내부에서 삼켜야 하며(Log 본체로 재귀하지 않도록),
/// 무거운 I/O는 비동기로 처리하도록 권장한다.
/// </remarks>
public interface ILogSink
{
/// <summary>
/// 로그 이벤트가 발생했을 때 호출된다.
/// </summary>
/// <param name="level">로그 레벨.</param>
/// <param name="category">로그 카테고리(프로젝트가 자유 정의).</param>
/// <param name="message">포맷팅이 끝난 최종 메시지.</param>
/// <param name="exception">예외 객체(없으면 <c>null</c>).</param>
void Emit(LogLevel level, string category, string message, Exception exception);
}
}

View File

@ -0,0 +1,193 @@
// ---------------------------------------------------------------------------
// NerdNavis.Framework
// Log.cs — 카테고리·레벨 필터 지원 중앙 로거
// ---------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using UnityEngine;
using UnityDebug = UnityEngine.Debug;
namespace NerdNavis.Core.Util.Log
{
/// <summary>
/// 너드나비스 프레임워크의 중앙 로거.
/// </summary>
/// <remarks>
/// <para>특징:</para>
/// <list type="bullet">
/// <item><description>레벨 필터: <see cref="MinLevel"/> 미만은 출력하지 않는다.</description></item>
/// <item><description>카테고리 필터: <see cref="EnableCategory"/>/<see cref="DisableCategory"/> 로 제어.</description></item>
/// <item><description>외부 sink: <see cref="AddSink"/>로 파일·원격·크래시리포터 연동.</description></item>
/// <item><description>릴리즈 스트리핑: <see cref="Verbose"/>/<see cref="Info"/> 는
/// <c>UNITY_EDITOR</c>·<c>DEVELOPMENT_BUILD</c>·<c>NERDNAVIS_LOG_VERBOSE</c> 중
/// 하나라도 정의돼야 호출부에 남는다(<see cref="ConditionalAttribute"/>).</description></item>
/// </list>
/// <para>설계 원칙(C11): 카테고리 문자열은 프로젝트 쪽에서 자유 정의하며
/// 코어에는 특정 게임의 카테고리를 하드코딩하지 않는다.</para>
/// </remarks>
public static class Log
{
// -----------------------------------------------------------------
// 상태
// -----------------------------------------------------------------
private static readonly object _gate = new object();
private static readonly List<ILogSink> _sinks = new List<ILogSink>();
private static readonly HashSet<string> _disabledCategories =
new HashSet<string>(StringComparer.Ordinal);
/// <summary>
/// 출력되는 최소 레벨. 이 값 미만의 로그는 차단된다.
/// 기본값은 에디터/개발빌드에서는 <see cref="LogLevel.Verbose"/>,
/// 릴리즈에서는 <see cref="LogLevel.Warn"/>.
/// </summary>
public static LogLevel MinLevel { get; set; }
#if UNITY_EDITOR || DEVELOPMENT_BUILD
= LogLevel.Verbose;
#else
= LogLevel.Warn;
#endif
/// <summary>
/// 메시지 앞에 카테고리 태그를 붙일지 여부.
/// </summary>
public static bool PrefixCategory { get; set; } = true;
// -----------------------------------------------------------------
// Sink 등록
// -----------------------------------------------------------------
/// <summary>외부 sink를 등록한다.</summary>
public static void AddSink(ILogSink sink)
{
if (sink == null) return;
lock (_gate) { if (!_sinks.Contains(sink)) _sinks.Add(sink); }
}
/// <summary>등록된 외부 sink를 제거한다.</summary>
public static void RemoveSink(ILogSink sink)
{
if (sink == null) return;
lock (_gate) { _sinks.Remove(sink); }
}
/// <summary>모든 외부 sink를 제거한다(테스트 용도).</summary>
public static void ClearSinks()
{
lock (_gate) { _sinks.Clear(); }
}
// -----------------------------------------------------------------
// 카테고리 필터
// -----------------------------------------------------------------
/// <summary>특정 카테고리 로그 출력을 차단한다.</summary>
public static void DisableCategory(string category)
{
if (string.IsNullOrEmpty(category)) return;
lock (_gate) { _disabledCategories.Add(category); }
}
/// <summary>특정 카테고리 로그 출력을 허용한다(기본값).</summary>
public static void EnableCategory(string category)
{
if (string.IsNullOrEmpty(category)) return;
lock (_gate) { _disabledCategories.Remove(category); }
}
/// <summary>모든 카테고리 필터를 초기화한다.</summary>
public static void ResetCategoryFilter()
{
lock (_gate) { _disabledCategories.Clear(); }
}
// -----------------------------------------------------------------
// 공용 API — Conditional 스트리핑 대상
// -----------------------------------------------------------------
[Conditional("UNITY_EDITOR")]
[Conditional("DEVELOPMENT_BUILD")]
[Conditional("NERDNAVIS_LOG_VERBOSE")]
public static void Verbose(string category, string message)
=> Emit(LogLevel.Verbose, category, message, null);
[Conditional("UNITY_EDITOR")]
[Conditional("DEVELOPMENT_BUILD")]
[Conditional("NERDNAVIS_LOG_VERBOSE")]
public static void Info(string category, string message)
=> Emit(LogLevel.Info, category, message, null);
// -----------------------------------------------------------------
// 공용 API — 릴리즈에서도 살아남는 레벨
// -----------------------------------------------------------------
public static void Warn(string category, string message)
=> Emit(LogLevel.Warn, category, message, null);
public static void Error(string category, string message, Exception exception = null)
=> Emit(LogLevel.Error, category, message, exception);
public static void Critical(string category, string message, Exception exception = null)
=> Emit(LogLevel.Critical, category, message, exception);
// -----------------------------------------------------------------
// 내부 공통 처리
// -----------------------------------------------------------------
private static void Emit(LogLevel level, string category, string message, Exception exception)
{
// 레벨·카테고리 필터
if (level < MinLevel) return;
lock (_gate)
{
if (category != null && _disabledCategories.Contains(category)) return;
}
var formatted = Format(category, message);
// Unity Console 출력
switch (level)
{
case LogLevel.Verbose:
case LogLevel.Info:
UnityDebug.Log(formatted);
break;
case LogLevel.Warn:
UnityDebug.LogWarning(formatted);
break;
case LogLevel.Error:
case LogLevel.Critical:
if (exception != null) UnityDebug.LogException(exception);
UnityDebug.LogError(formatted);
break;
}
// 외부 sink 전달 (등록된 sink 수만큼 스냅샷 순회)
ILogSink[] snapshot;
lock (_gate)
{
if (_sinks.Count == 0) return;
snapshot = _sinks.ToArray();
}
for (int i = 0; i < snapshot.Length; i++)
{
try { snapshot[i].Emit(level, category, formatted, exception); }
catch
{
// sink 내부 예외는 무시한다(재귀 방지).
}
}
}
private static string Format(string category, string message)
{
if (!PrefixCategory || string.IsNullOrEmpty(category)) return message ?? string.Empty;
var sb = new StringBuilder(
(category?.Length ?? 0) + (message?.Length ?? 0) + 4);
sb.Append('[').Append(category).Append("] ").Append(message);
return sb.ToString();
}
}
}

View File

@ -0,0 +1,27 @@
// ---------------------------------------------------------------------------
// NerdNavis.Framework
// LogLevel.cs — 로그 레벨 정의
// ---------------------------------------------------------------------------
namespace NerdNavis.Core.Util.Log
{
/// <summary>
/// 로그 심각도 레벨. 숫자가 클수록 심각하다.
/// </summary>
public enum LogLevel
{
/// <summary>상세 디버그 정보. 릴리즈에서는 stripping 대상.</summary>
Verbose = 0,
/// <summary>일반 정보. 릴리즈에서는 stripping 대상.</summary>
Info = 1,
/// <summary>경고. 동작은 지속되나 주의 필요.</summary>
Warn = 2,
/// <summary>오류. 동작이 실패했으나 복구 가능.</summary>
Error = 3,
/// <summary>치명적 오류. 애플리케이션 상태가 불안정할 수 있음.</summary>
Critical = 4,
}
}

View File

@ -0,0 +1,14 @@
{
"name": "NerdNavis.Framework",
"rootNamespace": "NerdNavis",
"references": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

@ -0,0 +1,25 @@
{
"name": "NerdNavis.Framework.Editor.Tests",
"rootNamespace": "NerdNavis.Editor.Tests",
"references": [
"NerdNavis.Framework",
"NerdNavis.Framework.Editor",
"UnityEngine.TestRunner",
"UnityEditor.TestRunner"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": true,
"precompiledReferences": [
"nunit.framework.dll"
],
"autoReferenced": false,
"defineConstraints": [
"UNITY_INCLUDE_TESTS"
],
"versionDefines": [],
"noEngineReferences": false
}

View File

@ -0,0 +1,130 @@
// ---------------------------------------------------------------------------
// NerdNavis.Framework.Tests
// CoroutineRunnerTests.cs — CoroutineRunner PlayMode 테스트
// ---------------------------------------------------------------------------
using System.Collections;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using NerdNavis.Core.Coroutine;
namespace NerdNavis.Tests.Core.Coroutine
{
public class CoroutineRunnerTests
{
[SetUp] public void SetUp() => CoroutineRunner.ResetForTests();
[TearDown] public void TearDown() => CoroutineRunner.ResetForTests();
[UnityTest]
public IEnumerator Start_Runs_To_Completion()
{
int counter = 0;
var handle = CoroutineRunner.Start(CountRoutine(3, () => counter++));
Assert.IsTrue(handle.IsValid);
while (CoroutineRunner.IsRunning(handle)) yield return null;
Assert.AreEqual(3, counter);
}
[UnityTest]
public IEnumerator Stop_Prevents_Completion()
{
int counter = 0;
var handle = CoroutineRunner.Start(CountRoutine(10, () => counter++));
yield return null; yield return null;
CoroutineRunner.Stop(handle);
int snapshot = counter;
for (int i = 0; i < 5; i++) yield return null;
Assert.IsFalse(CoroutineRunner.IsRunning(handle));
Assert.AreEqual(snapshot, counter, "중단 후에는 counter가 증가하지 않아야 한다");
}
[UnityTest]
public IEnumerator Pause_Then_Resume_Continues()
{
int counter = 0;
var handle = CoroutineRunner.Start(CountRoutine(10, () => counter++));
yield return null; yield return null;
CoroutineRunner.Pause(handle);
int paused = counter;
for (int i = 0; i < 5; i++) yield return null;
Assert.AreEqual(paused, counter, "일시정지 중에는 진행이 없어야 한다");
CoroutineRunner.Resume(handle);
while (CoroutineRunner.IsRunning(handle)) yield return null;
Assert.AreEqual(10, counter);
}
[UnityTest]
public IEnumerator Duplicate_Replace_Stops_Previous()
{
int a = 0, b = 0;
var h1 = CoroutineRunner.Start("k", CountRoutine(20, () => a++));
yield return null;
var h2 = CoroutineRunner.Start("k", CountRoutine(5, () => b++), DuplicatePolicy.Replace);
Assert.IsFalse(CoroutineRunner.IsRunning(h1), "첫 핸들은 중단되어야 한다");
while (CoroutineRunner.IsRunning(h2)) yield return null;
Assert.AreEqual(5, b);
Assert.Less(a, 20, "첫 루틴은 20회를 채우지 못했어야 한다");
}
[UnityTest]
public IEnumerator Duplicate_Ignore_Keeps_Original()
{
int a = 0, b = 0;
var h1 = CoroutineRunner.Start("k", CountRoutine(5, () => a++));
var h2 = CoroutineRunner.Start("k", CountRoutine(5, () => b++), DuplicatePolicy.Ignore);
Assert.AreEqual(h1.Id, h2.Id, "Ignore는 기존 핸들을 반환해야 한다");
while (CoroutineRunner.IsRunning(h1)) yield return null;
Assert.AreEqual(5, a);
Assert.AreEqual(0, b, "Ignore된 루틴은 실행되지 않아야 한다");
}
[UnityTest]
public IEnumerator StopByKey_Works()
{
int counter = 0;
CoroutineRunner.Start("kill-me", CountRoutine(20, () => counter++));
yield return null; yield return null;
Assert.IsTrue(CoroutineRunner.IsRunningByKey("kill-me"));
CoroutineRunner.StopByKey("kill-me");
Assert.IsFalse(CoroutineRunner.IsRunningByKey("kill-me"));
}
[UnityTest]
public IEnumerator Exception_In_Routine_Is_Captured()
{
LogAssert.Expect(LogType.Error, new System.Text.RegularExpressions.Regex(@"\[Coroutine\] coroutine threw"));
LogAssert.Expect(LogType.Exception, new System.Text.RegularExpressions.Regex("boom"));
var handle = CoroutineRunner.Start(ThrowingRoutine());
while (CoroutineRunner.IsRunning(handle)) yield return null;
// 완료(예외 종료) 후 내부 정리가 되어 IsRunning이 false여야 한다
Assert.IsFalse(CoroutineRunner.IsRunning(handle));
}
// --------- helper routines ---------
private static IEnumerator CountRoutine(int times, System.Action onTick)
{
for (int i = 0; i < times; i++)
{
onTick?.Invoke();
yield return null;
}
}
private static IEnumerator ThrowingRoutine()
{
yield return null;
throw new System.InvalidOperationException("boom");
}
}
}

View File

@ -0,0 +1,134 @@
// ---------------------------------------------------------------------------
// NerdNavis.Framework.Tests
// MonoSingletonTests.cs — MonoSingleton<T> PlayMode 테스트
// ---------------------------------------------------------------------------
using System.Collections;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using NerdNavis.Core.Patterns;
namespace NerdNavis.Tests.Core.Patterns
{
public class MonoSingletonTests
{
// 각 테스트 고유의 파생 타입을 사용해 static 공유 이슈를 회피한다.
// --- SyncCase ---
private class SyncCase : MonoSingleton<SyncCase>
{
public int InitCount;
protected override void OnInitialized() => InitCount++;
}
// --- NoAutoCreateCase ---
private class NoAutoCreateCase : MonoSingleton<NoAutoCreateCase>
{
protected override bool AutoCreate => false;
}
// --- NonPersistentCase ---
private class NonPersistentCase : MonoSingleton<NonPersistentCase>
{
protected override bool Persistent => false;
}
// --- DuplicateCase ---
private class DuplicateCase : MonoSingleton<DuplicateCase>
{
public static int DestroyedCount;
protected override void OnDuplicateDestroyed() => DestroyedCount++;
}
// --- ManualReadyCase ---
private class ManualReadyCase : MonoSingleton<ManualReadyCase>
{
protected override InitMode InitMode => InitMode.ManualReady;
public void Ready() => MarkReady();
}
[TearDown]
public void TearDown()
{
MonoSingleton<SyncCase>.ResetForTests();
MonoSingleton<NoAutoCreateCase>.ResetForTests();
MonoSingleton<NonPersistentCase>.ResetForTests();
MonoSingleton<DuplicateCase>.ResetForTests();
MonoSingleton<ManualReadyCase>.ResetForTests();
DuplicateCase.DestroyedCount = 0;
}
[UnityTest]
public IEnumerator AutoCreate_Instance_On_First_Access()
{
Assert.IsFalse(MonoSingleton<SyncCase>.Exists);
var inst = SyncCase.Instance;
yield return null;
Assert.IsNotNull(inst);
Assert.IsTrue(MonoSingleton<SyncCase>.Exists);
Assert.IsTrue(MonoSingleton<SyncCase>.IsReady);
Assert.AreEqual(1, inst.InitCount, "OnInitialized는 1회 호출");
}
[UnityTest]
public IEnumerator NoAutoCreate_Returns_Null_And_Warns()
{
LogAssert.Expect(LogType.Warning,
new System.Text.RegularExpressions.Regex(@"AutoCreate=false"));
var inst = NoAutoCreateCase.Instance;
yield return null;
Assert.IsNull(inst);
}
[UnityTest]
public IEnumerator NonPersistent_Does_Not_DontDestroyOnLoad()
{
var inst = NonPersistentCase.Instance;
yield return null;
// DontDestroyOnLoad 씬의 scene.buildIndex == -1 임을 역으로 이용
Assert.AreNotEqual(-1, inst.gameObject.scene.buildIndex,
"Persistent=false인데 DontDestroyOnLoad 씬에 들어갔다");
}
[UnityTest]
public IEnumerator Persistent_Goes_To_DontDestroyOnLoad()
{
var inst = SyncCase.Instance;
yield return null;
Assert.AreEqual("DontDestroyOnLoad", inst.gameObject.scene.name);
}
[UnityTest]
public IEnumerator Duplicate_Instance_Is_Destroyed()
{
LogAssert.Expect(LogType.Warning,
new System.Text.RegularExpressions.Regex(@"중복 인스턴스"));
var first = DuplicateCase.Instance;
yield return null;
var extra = new GameObject("extra").AddComponent<DuplicateCase>();
yield return null;
// 파괴 완료 대기
yield return null;
Assert.IsTrue(extra == null, "중복 인스턴스는 Destroy 되어야 한다");
Assert.AreEqual(1, DuplicateCase.DestroyedCount);
Assert.AreSame(first, MonoSingleton<DuplicateCase>.Instance);
}
[UnityTest]
public IEnumerator ManualReady_Stays_Not_Ready_Until_Marked()
{
var inst = ManualReadyCase.Instance;
yield return null;
Assert.IsNotNull(inst);
Assert.IsFalse(MonoSingleton<ManualReadyCase>.IsReady);
inst.Ready();
Assert.IsTrue(MonoSingleton<ManualReadyCase>.IsReady);
}
}
}

View File

@ -0,0 +1,120 @@
// ---------------------------------------------------------------------------
// NerdNavis.Framework.Tests
// ServiceLocatorTests.cs — ServiceLocator 단위 테스트
// ---------------------------------------------------------------------------
using System;
using NUnit.Framework;
using NerdNavis.Core.Patterns;
namespace NerdNavis.Tests.Core.Patterns
{
public class ServiceLocatorTests
{
private interface IGreeter { string Hello(); }
private sealed class EnGreeter : IGreeter { public string Hello() => "Hi"; }
private sealed class KoGreeter : IGreeter { public string Hello() => "안녕"; }
private interface ICounter { int Count { get; } }
private sealed class Counter : ICounter
{
public int Count { get; private set; }
public Counter() { Count = 1; }
}
[SetUp] public void SetUp() => ServiceLocator.Clear();
[TearDown] public void TearDown() => ServiceLocator.Clear();
[Test]
public void Register_Instance_And_Resolve()
{
var svc = new EnGreeter();
ServiceLocator.Register<IGreeter>(svc);
Assert.IsTrue(ServiceLocator.IsRegistered<IGreeter>());
Assert.AreSame(svc, ServiceLocator.Resolve<IGreeter>());
}
[Test]
public void Register_Null_Throws()
{
Assert.Throws<ArgumentNullException>(() => ServiceLocator.Register<IGreeter>((IGreeter)null));
Assert.Throws<ArgumentNullException>(() => ServiceLocator.Register<IGreeter>((Func<IGreeter>)null));
}
[Test]
public void Resolve_Missing_Throws_ServiceNotRegistered()
{
var ex = Assert.Throws<ServiceNotRegisteredException>(() => ServiceLocator.Resolve<IGreeter>());
Assert.AreEqual(typeof(IGreeter), ex.ServiceType);
}
[Test]
public void TryResolve_Missing_Returns_False()
{
Assert.IsFalse(ServiceLocator.TryResolve<IGreeter>(out var svc));
Assert.IsNull(svc);
}
[Test]
public void Factory_Is_Invoked_Once_And_Cached()
{
int factoryCalls = 0;
ServiceLocator.Register<ICounter>(() => { factoryCalls++; return new Counter(); });
Assert.AreEqual(0, factoryCalls, "등록 시점에 factory는 호출되지 않는다");
var a = ServiceLocator.Resolve<ICounter>();
var b = ServiceLocator.Resolve<ICounter>();
Assert.AreEqual(1, factoryCalls, "factory는 첫 Resolve에서만 1회 호출");
Assert.AreSame(a, b, "이후 Resolve는 캐시된 인스턴스를 반환");
}
[Test]
public void Register_Overwrites_Existing_Binding()
{
UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Warning,
new System.Text.RegularExpressions.Regex(@"\[ServiceLocator\] Register<IGreeter>"));
ServiceLocator.Register<IGreeter>(new EnGreeter());
ServiceLocator.Register<IGreeter>(new KoGreeter());
Assert.AreEqual("안녕", ServiceLocator.Resolve<IGreeter>().Hello());
}
[Test]
public void Unregister_Removes_Binding()
{
ServiceLocator.Register<IGreeter>(new EnGreeter());
ServiceLocator.Unregister<IGreeter>();
Assert.IsFalse(ServiceLocator.IsRegistered<IGreeter>());
Assert.Throws<ServiceNotRegisteredException>(() => ServiceLocator.Resolve<IGreeter>());
}
[Test]
public void Clear_Removes_All()
{
ServiceLocator.Register<IGreeter>(new EnGreeter());
ServiceLocator.Register<ICounter>(() => new Counter());
ServiceLocator.Clear();
Assert.IsFalse(ServiceLocator.IsRegistered<IGreeter>());
Assert.IsFalse(ServiceLocator.IsRegistered<ICounter>());
}
[Test]
public void Factory_Exception_Is_Captured_As_False()
{
UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Error,
new System.Text.RegularExpressions.Regex(@"factory for IGreeter threw"));
UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Exception,
new System.Text.RegularExpressions.Regex("boom"));
ServiceLocator.Register<IGreeter>(() => throw new InvalidOperationException("boom"));
Assert.IsFalse(ServiceLocator.TryResolve<IGreeter>(out var svc));
Assert.IsNull(svc);
}
}
}

View File

@ -0,0 +1,134 @@
// ---------------------------------------------------------------------------
// NerdNavis.Framework.Tests
// LogTests.cs — Log 중앙 로거 단위 테스트
// ---------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using NUnit.Framework;
using NerdNavis.Core.Util.Log;
namespace NerdNavis.Tests.Core.Util.Log
{
public class LogTests
{
private sealed class RecordingSink : ILogSink
{
public readonly List<(LogLevel level, string category, string message, Exception exception)> Events
= new List<(LogLevel, string, string, Exception)>();
public void Emit(LogLevel level, string category, string message, Exception exception)
=> Events.Add((level, category, message, exception));
}
private RecordingSink _sink;
private LogLevel _savedMinLevel;
private bool _savedPrefix;
[SetUp]
public void SetUp()
{
_savedMinLevel = global::NerdNavis.Core.Util.Log.Log.MinLevel;
_savedPrefix = global::NerdNavis.Core.Util.Log.Log.PrefixCategory;
global::NerdNavis.Core.Util.Log.Log.ClearSinks();
global::NerdNavis.Core.Util.Log.Log.ResetCategoryFilter();
global::NerdNavis.Core.Util.Log.Log.MinLevel = LogLevel.Verbose;
global::NerdNavis.Core.Util.Log.Log.PrefixCategory = true;
_sink = new RecordingSink();
global::NerdNavis.Core.Util.Log.Log.AddSink(_sink);
}
[TearDown]
public void TearDown()
{
global::NerdNavis.Core.Util.Log.Log.ClearSinks();
global::NerdNavis.Core.Util.Log.Log.ResetCategoryFilter();
global::NerdNavis.Core.Util.Log.Log.MinLevel = _savedMinLevel;
global::NerdNavis.Core.Util.Log.Log.PrefixCategory = _savedPrefix;
}
[Test]
public void Warn_Sink_Receives_Formatted_Message()
{
UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Warning, "[Net] timeout");
global::NerdNavis.Core.Util.Log.Log.Warn("Net", "timeout");
Assert.AreEqual(1, _sink.Events.Count);
Assert.AreEqual(LogLevel.Warn, _sink.Events[0].level);
Assert.AreEqual("Net", _sink.Events[0].category);
Assert.AreEqual("[Net] timeout", _sink.Events[0].message);
Assert.IsNull(_sink.Events[0].exception);
}
[Test]
public void MinLevel_Filters_Below()
{
global::NerdNavis.Core.Util.Log.Log.MinLevel = LogLevel.Error;
global::NerdNavis.Core.Util.Log.Log.Warn("Net", "below");
UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Error, "[Net] above");
global::NerdNavis.Core.Util.Log.Log.Error("Net", "above");
Assert.AreEqual(1, _sink.Events.Count);
Assert.AreEqual(LogLevel.Error, _sink.Events[0].level);
}
[Test]
public void Disabled_Category_Is_Skipped()
{
global::NerdNavis.Core.Util.Log.Log.DisableCategory("Net");
global::NerdNavis.Core.Util.Log.Log.Warn("Net", "blocked");
UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Warning, "[UI] kept");
global::NerdNavis.Core.Util.Log.Log.Warn("UI", "kept");
Assert.AreEqual(1, _sink.Events.Count);
Assert.AreEqual("UI", _sink.Events[0].category);
}
[Test]
public void PrefixCategory_Off_Omits_Tag()
{
global::NerdNavis.Core.Util.Log.Log.PrefixCategory = false;
UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Warning, "no-tag");
global::NerdNavis.Core.Util.Log.Log.Warn("Net", "no-tag");
Assert.AreEqual("no-tag", _sink.Events[0].message);
}
[Test]
public void Error_With_Exception_Is_Forwarded()
{
var ex = new InvalidOperationException("boom");
UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Exception, "InvalidOperationException: boom");
UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Error, "[Net] failed");
global::NerdNavis.Core.Util.Log.Log.Error("Net", "failed", ex);
Assert.AreEqual(1, _sink.Events.Count);
Assert.AreSame(ex, _sink.Events[0].exception);
}
[Test]
public void Sink_Exception_Does_Not_Break_Others()
{
var bad = new ThrowingSink();
var good = new RecordingSink();
global::NerdNavis.Core.Util.Log.Log.AddSink(bad);
global::NerdNavis.Core.Util.Log.Log.AddSink(good);
UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Warning, "[Net] ping");
global::NerdNavis.Core.Util.Log.Log.Warn("Net", "ping");
Assert.AreEqual(1, good.Events.Count, "정상 sink는 수신해야 한다");
}
private sealed class ThrowingSink : ILogSink
{
public void Emit(LogLevel level, string category, string message, Exception exception)
=> throw new InvalidOperationException("sink failure");
}
}
}

View File

@ -0,0 +1,22 @@
{
"name": "NerdNavis.Framework.Tests",
"rootNamespace": "NerdNavis.Tests",
"references": [
"NerdNavis.Framework",
"UnityEngine.TestRunner",
"UnityEditor.TestRunner"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": true,
"precompiledReferences": [
"nunit.framework.dll"
],
"autoReferenced": false,
"defineConstraints": [
"UNITY_INCLUDE_TESTS"
],
"versionDefines": [],
"noEngineReferences": false
}

View File

@ -0,0 +1,18 @@
{
"name": "com.nerdnavis.framework",
"version": "0.1.0",
"displayName": "NerdNavis Framework",
"description": "너드나비스 내부 프로젝트용 범용 Unity 프레임워크. 코루틴·싱글톤·UGUI·Addressable·보안 등 재사용 모듈 모음.",
"unity": "2022.3",
"author": {
"name": "NerdNavis",
"url": "https://nerdnavis.com"
},
"keywords": [
"framework",
"utility",
"ugui",
"addressable"
],
"dependencies": {}
}