diff --git a/공유/PD_지시_트래킹/개발팀_PD_지시_로그.md b/공유/PD_지시_트래킹/개발팀_PD_지시_로그.md index db36718..4f72e8b 100644 --- a/공유/PD_지시_트래킹/개발팀_PD_지시_로그.md +++ b/공유/PD_지시_트래킹/개발팀_PD_지시_로그.md @@ -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` | - | - | | 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님 지시) | 서버팀 가동 시점에 블로커급 재개. 담당: 서버팀장. 재개 트리거: 서버 파트 정비 완료 통보 | diff --git a/코어코드/NerdNavis.Framework/.gitattributes b/코어코드/NerdNavis.Framework/.gitattributes new file mode 100644 index 0000000..16361b5 --- /dev/null +++ b/코어코드/NerdNavis.Framework/.gitattributes @@ -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 diff --git a/코어코드/NerdNavis.Framework/.gitignore b/코어코드/NerdNavis.Framework/.gitignore new file mode 100644 index 0000000..dd637a6 --- /dev/null +++ b/코어코드/NerdNavis.Framework/.gitignore @@ -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 diff --git a/코어코드/NerdNavis.Framework/CHANGELOG.md b/코어코드/NerdNavis.Framework/CHANGELOG.md new file mode 100644 index 0000000..ccd4abe --- /dev/null +++ b/코어코드/NerdNavis.Framework/CHANGELOG.md @@ -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 + +최초 릴리즈 예정. diff --git a/코어코드/NerdNavis.Framework/Documentation~/.gitkeep b/코어코드/NerdNavis.Framework/Documentation~/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/코어코드/NerdNavis.Framework/Editor/NerdNavis.Framework.Editor.asmdef b/코어코드/NerdNavis.Framework/Editor/NerdNavis.Framework.Editor.asmdef new file mode 100644 index 0000000..703ca01 --- /dev/null +++ b/코어코드/NerdNavis.Framework/Editor/NerdNavis.Framework.Editor.asmdef @@ -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 +} diff --git a/코어코드/NerdNavis.Framework/README.md b/코어코드/NerdNavis.Framework/README.md new file mode 100644 index 0000000..cbdd783 --- /dev/null +++ b/코어코드/NerdNavis.Framework/README.md @@ -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` 참조. + +## 라이선스 + +사내 사용. 외부 배포 금지. diff --git a/코어코드/NerdNavis.Framework/Runtime/Addressable/.gitkeep b/코어코드/NerdNavis.Framework/Runtime/Addressable/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/코어코드/NerdNavis.Framework/Runtime/AssemblyInfo.cs b/코어코드/NerdNavis.Framework/Runtime/AssemblyInfo.cs new file mode 100644 index 0000000..341c6ef --- /dev/null +++ b/코어코드/NerdNavis.Framework/Runtime/AssemblyInfo.cs @@ -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")] diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Coroutine/CoroutineHandle.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Coroutine/CoroutineHandle.cs new file mode 100644 index 0000000..d727db9 --- /dev/null +++ b/코어코드/NerdNavis.Framework/Runtime/Core/Coroutine/CoroutineHandle.cs @@ -0,0 +1,36 @@ +// --------------------------------------------------------------------------- +// NerdNavis.Framework +// CoroutineHandle.cs — CoroutineRunner 반환 핸들 +// --------------------------------------------------------------------------- +using System; + +namespace NerdNavis.Core.Coroutine +{ + /// + /// 에서 발급하는 코루틴 핸들. + /// + /// + /// ID 기반 참조값이며 값 타입이다. 은 유효하지 않은 핸들을 의미한다. + /// 핸들을 보관한 쪽에서 중단·일시정지·재개 등을 요청할 때 사용한다. + /// + public readonly struct CoroutineHandle : IEquatable + { + /// 유효하지 않은 핸들. + public static readonly CoroutineHandle None = new CoroutineHandle(0); + + /// 내부 식별 ID. 0은 유효하지 않음. + public readonly ulong Id; + + internal CoroutineHandle(ulong id) { Id = id; } + + /// 핸들이 유효한지 여부(ID가 0이 아닌지). + 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"; + } +} diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Coroutine/CoroutineRunner.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Coroutine/CoroutineRunner.cs new file mode 100644 index 0000000..ed05180 --- /dev/null +++ b/코어코드/NerdNavis.Framework/Runtime/Core/Coroutine/CoroutineRunner.cs @@ -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 +{ + /// + /// 전역에서 사용 가능한 코루틴 러너. + /// + /// + /// 기존 NerdNavisCore의 CoroutineHandlerCoroutineRunner 2종을 하나로 통합했다. + /// 수상한 잡화점의 MyCoroutine도 이 러너로 흡수된다. + /// 호스트는 내부 로, + /// DontDestroyOnLoad 처리된다. 첫 호출 시점에 지연 생성되며, 씬 전환과 무관하게 유지된다. + /// 주요 기능: + /// + /// 핸들 기반 중단 / 일시정지 / 재개 + /// 문자열 키 기반 중복 정책 (Replace/Ignore/Allow) + /// 전체 중단 + /// + /// + public static class CoroutineRunner + { + private const string LogCategory = "Coroutine"; + + private static CoroutineHost _host; + private static ulong _nextId = 1; + private static readonly Dictionary _entries = new Dictionary(); + private static readonly Dictionary _keyToId = new Dictionary(); + + // ----------------------------------------------------------------- + // 공개 API — 단순 시작 + // ----------------------------------------------------------------- + + /// 코루틴을 시작한다. + /// 실행할 . + /// 중단·제어에 사용하는 핸들. 이 null이면 . + public static CoroutineHandle Start(IEnumerator routine) + => StartInternal(routine, key: null); + + /// 문자열 키로 코루틴을 시작한다. 동일 키 실행 중이면 에 따라 처리. + 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 — 제어 + // ----------------------------------------------------------------- + + /// 핸들로 실행 중인 코루틴을 중단한다. + public static void Stop(CoroutineHandle handle) + { + if (!handle.IsValid) return; + StopInternal(handle.Id); + } + + /// 키로 실행 중인 코루틴을 중단한다. + public static void StopByKey(string key) + { + if (string.IsNullOrEmpty(key)) return; + if (_keyToId.TryGetValue(key, out var id)) StopInternal(id); + } + + /// 실행 중인 모든 코루틴을 중단한다. + public static void StopAll() + { + if (_host == null) return; + _host.StopAllCoroutines(); + _entries.Clear(); + _keyToId.Clear(); + } + + /// 핸들로 일시정지. 재개 전까지 yield return 이 소비되지 않는다. + public static void Pause(CoroutineHandle handle) + { + if (_entries.TryGetValue(handle.Id, out var e)) e.IsPaused = true; + } + + /// 핸들로 재개. + public static void Resume(CoroutineHandle handle) + { + if (_entries.TryGetValue(handle.Id, out var e)) e.IsPaused = false; + } + + /// 핸들이 현재 실행 중인지 조회. + public static bool IsRunning(CoroutineHandle handle) + => handle.IsValid && _entries.ContainsKey(handle.Id); + + /// 키가 현재 실행 중인지 조회. + public static bool IsRunningByKey(string key) + => !string.IsNullOrEmpty(key) && _keyToId.ContainsKey(key); + + /// 핸들에 해당하는 코루틴의 일시정지 여부. + 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 ?? ""})", 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(); + } + + /// 테스트 전용: 내부 상태 초기화. + 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; + } + + /// 전역 코루틴 호스트. + private sealed class CoroutineHost : MonoBehaviour { } + } +} diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Coroutine/DuplicatePolicy.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Coroutine/DuplicatePolicy.cs new file mode 100644 index 0000000..1341126 --- /dev/null +++ b/코어코드/NerdNavis.Framework/Runtime/Core/Coroutine/DuplicatePolicy.cs @@ -0,0 +1,22 @@ +// --------------------------------------------------------------------------- +// NerdNavis.Framework +// DuplicatePolicy.cs — 키 기반 코루틴 중복 정책 +// --------------------------------------------------------------------------- +namespace NerdNavis.Core.Coroutine +{ + /// + /// 동일 키로 + /// 가 호출됐을 때 기존 실행 중인 코루틴을 어떻게 처리할지 정의한다. + /// + public enum DuplicatePolicy + { + /// 기존 코루틴을 중단하고 새로 시작한다(기본값). + Replace = 0, + + /// 기존 코루틴을 유지하고 새 요청은 무시한다. + Ignore = 1, + + /// 기존 코루틴을 유지하고 중복 시작을 허용한다(키 공유, 핸들만 다름). + Allow = 2, + } +} diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Patterns/InitMode.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Patterns/InitMode.cs new file mode 100644 index 0000000..a04fec1 --- /dev/null +++ b/코어코드/NerdNavis.Framework/Runtime/Core/Patterns/InitMode.cs @@ -0,0 +1,30 @@ +// --------------------------------------------------------------------------- +// NerdNavis.Framework +// InitMode.cs — MonoSingleton 초기화 모드 +// --------------------------------------------------------------------------- +namespace NerdNavis.Core.Patterns +{ + /// + /// 의 초기화 모드. + /// + public enum InitMode + { + /// + /// Awake에서 즉시 실행되고 + /// 는 그 직후 true가 된다(기본값). + /// + Sync = 0, + + /// + /// 가 코루틴으로 실행되며 + /// 완료 시 true로 전환된다. + /// + Async = 1, + + /// + /// 구현체가 를 직접 호출할 때까지 + /// false를 유지한다. + /// + ManualReady = 2, + } +} diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Patterns/MonoSingleton.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Patterns/MonoSingleton.cs new file mode 100644 index 0000000..972efd7 --- /dev/null +++ b/코어코드/NerdNavis.Framework/Runtime/Core/Patterns/MonoSingleton.cs @@ -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 +{ + /// + /// 단일 인스턴스 베이스 클래스. + /// + /// + /// 기존 NerdNavisCore의 4종(Singleton, AsyncSingleton, InnerSingleton, + /// ReadySingleton)을 통합했다(설계 문서 §4-1). 옵션은 방식이 아닌 + /// 가상 프로퍼티로 노출하여 런타임 리플렉션 비용을 제거했다. + /// 동작 규칙: + /// + /// 는 씬에 있으면 찾고, 없으면 판단 후 생성. + /// 이면 DontDestroyOnLoad 적용. + /// 에 따라 / + /// / 중 하나로 준비 상태가 결정된다. + /// 중복 인스턴스가 발견되면 나중 것이 파괴된다( 훅). + /// 애플리케이션 종료 후 접근은 null을 반환한다(Unity destroyed object 회피). + /// + /// 주의: Unity 메인 스레드에서만 접근한다. 다른 스레드 호출은 지원하지 않는다. + /// + public abstract class MonoSingleton : MonoBehaviour where T : MonoSingleton + { + private const string LogCategory = "Singleton"; + + private static T _instance; + private static bool _applicationIsQuitting; + private static readonly object _gate = new object(); + + /// + /// 싱글톤 인스턴스. 없으면 규칙으로 자동 생성 시도. + /// 애플리케이션 종료 중이면 null을 반환한다. + /// + public static T Instance + { + get + { + if (_applicationIsQuitting) return null; + if (_instance != null) return _instance; + + lock (_gate) + { + if (_instance != null) return _instance; + + // 씬 검색 + var found = FindAnyObjectByType(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(); + 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; + } + } + } + + /// 싱글톤이 이미 존재하거나 조회 가능한지 확인(자동 생성 트리거 없음). + public static bool Exists => !_applicationIsQuitting && _instance != null; + + /// 초기화 완료 여부. 에 따라 결정된다. + public static bool IsReady { get; private set; } + + // ----------------------------------------------------------------- + // 옵션 (파생에서 override) + // ----------------------------------------------------------------- + + /// 씬 전환에도 파괴되지 않을지 여부. 기본 true. + protected virtual bool Persistent => true; + + /// 인스턴스 부재 시 자동 생성 여부. 기본 true. + protected virtual bool AutoCreate => true; + + /// 초기화 모드. 기본 . + protected virtual InitMode InitMode => InitMode.Sync; + + // ----------------------------------------------------------------- + // 라이프사이클 훅 (파생에서 override) + // ----------------------------------------------------------------- + + /// 에서 Awake 시 호출된다. + protected virtual void OnInitialized() { } + + /// 에서 코루틴으로 실행된다. 종료 시점에 true. + protected virtual IEnumerator OnInitializeAsync() { yield break; } + + /// 중복 인스턴스가 파괴될 때 마지막 호출. 리소스 정리 훅. + protected virtual void OnDuplicateDestroyed() { } + + /// 에서 구현체가 호출하여 준비 상태로 전환. + 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); + } + + /// 테스트 전용: 정적 상태 초기화. + internal static void ResetForTests() + { + if (_instance != null && _instance.gameObject != null) + Object.DestroyImmediate(_instance.gameObject); + _instance = null; + IsReady = false; + _applicationIsQuitting = false; + } + } +} diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Patterns/ServiceLocator.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Patterns/ServiceLocator.cs new file mode 100644 index 0000000..9c9dce8 --- /dev/null +++ b/코어코드/NerdNavis.Framework/Runtime/Core/Patterns/ServiceLocator.cs @@ -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 +{ + /// + /// 타입 키 기반 경량 서비스 레지스트리. + /// + /// + /// 설계 문서 §4-9 (v1.2 신설)에 따른 구현. MonoBehaviour와 무관한 순수 C# 서비스 + /// 의 중앙 레지스트리로, MonoSingleton(씬 생명주기 동반)과 EventBus(이벤트 분기)와 역할이 + /// 분리된 3축의 한 축을 담당한다. + /// 용도: + /// + /// 인터페이스 기반 느슨한 결합 — ISaveProvider 등으로 등록 후 구현 교체 용이 + /// 테스트 시 Mock 주입 — 로 전역 초기화, Mock + /// Lazy 생성 — 로 첫 시 생성 + /// + /// 주의 규칙 (§4-9): + /// + /// 인터페이스 타입 등록 권장, 구체 타입 강결합 회피 + /// 글로벌 서비스만 등록. 씬 전환 시 스코프가 필요하면 MonoSingleton 사용 + /// 실패 시 — silent null 금지 + /// 코어 자체는 ServiceLocator에 의존하지 않음(순환 방지) + /// + /// + public static class ServiceLocator + { + private const string LogCategory = "ServiceLocator"; + + private static readonly object _gate = new object(); + private static readonly Dictionary _instances = new Dictionary(); + private static readonly Dictionary> _factories = new Dictionary>(); + + // ----------------------------------------------------------------- + // 등록 + // ----------------------------------------------------------------- + + /// + /// 서비스 인스턴스를 등록한다. 동일 타입 재등록은 덮어쓴다(경고 로그). + /// + /// null. + public static void Register(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); + } + } + + /// + /// Lazy 팩토리를 등록한다. 첫 호출 시점에 팩토리가 실행되고, + /// 결과 인스턴스가 캐싱된다(이후 호출은 캐시 반환). + /// + /// null. + public static void Register(Func 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(); + } + } + + // ----------------------------------------------------------------- + // 조회 + // ----------------------------------------------------------------- + + /// 등록된 서비스를 조회한다. 미등록이면 예외. + /// 해당 타입이 등록되지 않음. + public static T Resolve() where T : class + { + if (TryResolve(out var service)) return service; + throw new ServiceNotRegisteredException(typeof(T)); + } + + /// 등록된 서비스를 조회한다. 미등록이면 false. + public static bool TryResolve(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 ?? ""}"); + service = null; + return false; + } + } + service = null; + return false; + } + + /// 해당 타입이 등록되어 있는지 조회(팩토리 포함). + public static bool IsRegistered() where T : class + { + var type = typeof(T); + lock (_gate) { return _instances.ContainsKey(type) || _factories.ContainsKey(type); } + } + + // ----------------------------------------------------------------- + // 해제 + // ----------------------------------------------------------------- + + /// 해당 타입의 서비스 등록을 해제한다. + public static void Unregister() where T : class + { + var type = typeof(T); + lock (_gate) + { + _instances.Remove(type); + _factories.Remove(type); + } + } + + /// 모든 등록을 해제한다(테스트 용도). + public static void Clear() + { + lock (_gate) + { + _instances.Clear(); + _factories.Clear(); + } + } + } +} diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Patterns/ServiceNotRegisteredException.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Patterns/ServiceNotRegisteredException.cs new file mode 100644 index 0000000..9798a22 --- /dev/null +++ b/코어코드/NerdNavis.Framework/Runtime/Core/Patterns/ServiceNotRegisteredException.cs @@ -0,0 +1,26 @@ +// --------------------------------------------------------------------------- +// NerdNavis.Framework +// ServiceNotRegisteredException.cs — ServiceLocator 조회 실패 예외 +// --------------------------------------------------------------------------- +using System; + +namespace NerdNavis.Core.Patterns +{ + /// + /// 호출 시 해당 타입이 등록되지 않은 경우 발생. + /// + /// + /// silent null 반환을 금지하는 설계 원칙(§4-9 규칙 3)에 따라 명시적으로 예외를 던진다. + /// 실패를 허용하는 호출부는 를 사용한다. + /// + public sealed class ServiceNotRegisteredException : Exception + { + public Type ServiceType { get; } + + public ServiceNotRegisteredException(Type serviceType) + : base($"Service '{serviceType?.FullName ?? ""}' is not registered in ServiceLocator.") + { + ServiceType = serviceType; + } + } +} diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Util/.gitkeep b/코어코드/NerdNavis.Framework/Runtime/Core/Util/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Util/Log/ILogSink.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Util/Log/ILogSink.cs new file mode 100644 index 0000000..a17013e --- /dev/null +++ b/코어코드/NerdNavis.Framework/Runtime/Core/Util/Log/ILogSink.cs @@ -0,0 +1,30 @@ +// --------------------------------------------------------------------------- +// NerdNavis.Framework +// ILogSink.cs — 외부 로그 수신자 인터페이스 +// --------------------------------------------------------------------------- +using System; + +namespace NerdNavis.Core.Util.Log +{ + /// + /// 로그 이벤트를 외부로 전달할 때 사용하는 sink 인터페이스. + /// 크래시 리포터·파일 로거·원격 서버 전송 등을 구현체로 등록할 수 있다. + /// + /// + /// 기존 ErrorLogHookManager의 역할을 인터페이스로 추상화한 형태다. + /// 는 등록된 모든 sink에 순차적으로 로그를 전달한다. + /// sink 구현은 예외를 내부에서 삼켜야 하며(Log 본체로 재귀하지 않도록), + /// 무거운 I/O는 비동기로 처리하도록 권장한다. + /// + public interface ILogSink + { + /// + /// 로그 이벤트가 발생했을 때 호출된다. + /// + /// 로그 레벨. + /// 로그 카테고리(프로젝트가 자유 정의). + /// 포맷팅이 끝난 최종 메시지. + /// 예외 객체(없으면 null). + void Emit(LogLevel level, string category, string message, Exception exception); + } +} diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Util/Log/Log.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Util/Log/Log.cs new file mode 100644 index 0000000..c66cf33 --- /dev/null +++ b/코어코드/NerdNavis.Framework/Runtime/Core/Util/Log/Log.cs @@ -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 +{ + /// + /// 너드나비스 프레임워크의 중앙 로거. + /// + /// + /// 특징: + /// + /// 레벨 필터: 미만은 출력하지 않는다. + /// 카테고리 필터: / 로 제어. + /// 외부 sink: 로 파일·원격·크래시리포터 연동. + /// 릴리즈 스트리핑: / 는 + /// UNITY_EDITOR·DEVELOPMENT_BUILD·NERDNAVIS_LOG_VERBOSE 중 + /// 하나라도 정의돼야 호출부에 남는다(). + /// + /// 설계 원칙(C11): 카테고리 문자열은 프로젝트 쪽에서 자유 정의하며 + /// 코어에는 특정 게임의 카테고리를 하드코딩하지 않는다. + /// + public static class Log + { + // ----------------------------------------------------------------- + // 상태 + // ----------------------------------------------------------------- + + private static readonly object _gate = new object(); + private static readonly List _sinks = new List(); + private static readonly HashSet _disabledCategories = + new HashSet(StringComparer.Ordinal); + + /// + /// 출력되는 최소 레벨. 이 값 미만의 로그는 차단된다. + /// 기본값은 에디터/개발빌드에서는 , + /// 릴리즈에서는 . + /// + public static LogLevel MinLevel { get; set; } +#if UNITY_EDITOR || DEVELOPMENT_BUILD + = LogLevel.Verbose; +#else + = LogLevel.Warn; +#endif + + /// + /// 메시지 앞에 카테고리 태그를 붙일지 여부. + /// + public static bool PrefixCategory { get; set; } = true; + + // ----------------------------------------------------------------- + // Sink 등록 + // ----------------------------------------------------------------- + + /// 외부 sink를 등록한다. + public static void AddSink(ILogSink sink) + { + if (sink == null) return; + lock (_gate) { if (!_sinks.Contains(sink)) _sinks.Add(sink); } + } + + /// 등록된 외부 sink를 제거한다. + public static void RemoveSink(ILogSink sink) + { + if (sink == null) return; + lock (_gate) { _sinks.Remove(sink); } + } + + /// 모든 외부 sink를 제거한다(테스트 용도). + public static void ClearSinks() + { + lock (_gate) { _sinks.Clear(); } + } + + // ----------------------------------------------------------------- + // 카테고리 필터 + // ----------------------------------------------------------------- + + /// 특정 카테고리 로그 출력을 차단한다. + public static void DisableCategory(string category) + { + if (string.IsNullOrEmpty(category)) return; + lock (_gate) { _disabledCategories.Add(category); } + } + + /// 특정 카테고리 로그 출력을 허용한다(기본값). + public static void EnableCategory(string category) + { + if (string.IsNullOrEmpty(category)) return; + lock (_gate) { _disabledCategories.Remove(category); } + } + + /// 모든 카테고리 필터를 초기화한다. + 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(); + } + } +} diff --git a/코어코드/NerdNavis.Framework/Runtime/Core/Util/Log/LogLevel.cs b/코어코드/NerdNavis.Framework/Runtime/Core/Util/Log/LogLevel.cs new file mode 100644 index 0000000..a40ba1f --- /dev/null +++ b/코어코드/NerdNavis.Framework/Runtime/Core/Util/Log/LogLevel.cs @@ -0,0 +1,27 @@ +// --------------------------------------------------------------------------- +// NerdNavis.Framework +// LogLevel.cs — 로그 레벨 정의 +// --------------------------------------------------------------------------- +namespace NerdNavis.Core.Util.Log +{ + /// + /// 로그 심각도 레벨. 숫자가 클수록 심각하다. + /// + public enum LogLevel + { + /// 상세 디버그 정보. 릴리즈에서는 stripping 대상. + Verbose = 0, + + /// 일반 정보. 릴리즈에서는 stripping 대상. + Info = 1, + + /// 경고. 동작은 지속되나 주의 필요. + Warn = 2, + + /// 오류. 동작이 실패했으나 복구 가능. + Error = 3, + + /// 치명적 오류. 애플리케이션 상태가 불안정할 수 있음. + Critical = 4, + } +} diff --git a/코어코드/NerdNavis.Framework/Runtime/NerdNavis.Framework.asmdef b/코어코드/NerdNavis.Framework/Runtime/NerdNavis.Framework.asmdef new file mode 100644 index 0000000..05b64a3 --- /dev/null +++ b/코어코드/NerdNavis.Framework/Runtime/NerdNavis.Framework.asmdef @@ -0,0 +1,14 @@ +{ + "name": "NerdNavis.Framework", + "rootNamespace": "NerdNavis", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/코어코드/NerdNavis.Framework/Runtime/Security/.gitkeep b/코어코드/NerdNavis.Framework/Runtime/Security/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/코어코드/NerdNavis.Framework/Runtime/UI/Components/.gitkeep b/코어코드/NerdNavis.Framework/Runtime/UI/Components/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/코어코드/NerdNavis.Framework/Runtime/UI/UGUI/.gitkeep b/코어코드/NerdNavis.Framework/Runtime/UI/UGUI/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/코어코드/NerdNavis.Framework/Tests/Editor/NerdNavis.Framework.Editor.Tests.asmdef b/코어코드/NerdNavis.Framework/Tests/Editor/NerdNavis.Framework.Editor.Tests.asmdef new file mode 100644 index 0000000..88001bd --- /dev/null +++ b/코어코드/NerdNavis.Framework/Tests/Editor/NerdNavis.Framework.Editor.Tests.asmdef @@ -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 +} diff --git a/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Coroutine/CoroutineRunnerTests.cs b/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Coroutine/CoroutineRunnerTests.cs new file mode 100644 index 0000000..5eaaa22 --- /dev/null +++ b/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Coroutine/CoroutineRunnerTests.cs @@ -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"); + } + } +} diff --git a/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Patterns/MonoSingletonTests.cs b/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Patterns/MonoSingletonTests.cs new file mode 100644 index 0000000..09bbb0a --- /dev/null +++ b/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Patterns/MonoSingletonTests.cs @@ -0,0 +1,134 @@ +// --------------------------------------------------------------------------- +// NerdNavis.Framework.Tests +// MonoSingletonTests.cs — MonoSingleton 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 + { + public int InitCount; + protected override void OnInitialized() => InitCount++; + } + + // --- NoAutoCreateCase --- + private class NoAutoCreateCase : MonoSingleton + { + protected override bool AutoCreate => false; + } + + // --- NonPersistentCase --- + private class NonPersistentCase : MonoSingleton + { + protected override bool Persistent => false; + } + + // --- DuplicateCase --- + private class DuplicateCase : MonoSingleton + { + public static int DestroyedCount; + protected override void OnDuplicateDestroyed() => DestroyedCount++; + } + + // --- ManualReadyCase --- + private class ManualReadyCase : MonoSingleton + { + protected override InitMode InitMode => InitMode.ManualReady; + public void Ready() => MarkReady(); + } + + [TearDown] + public void TearDown() + { + MonoSingleton.ResetForTests(); + MonoSingleton.ResetForTests(); + MonoSingleton.ResetForTests(); + MonoSingleton.ResetForTests(); + MonoSingleton.ResetForTests(); + DuplicateCase.DestroyedCount = 0; + } + + [UnityTest] + public IEnumerator AutoCreate_Instance_On_First_Access() + { + Assert.IsFalse(MonoSingleton.Exists); + var inst = SyncCase.Instance; + yield return null; + + Assert.IsNotNull(inst); + Assert.IsTrue(MonoSingleton.Exists); + Assert.IsTrue(MonoSingleton.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(); + yield return null; + // 파괴 완료 대기 + yield return null; + + Assert.IsTrue(extra == null, "중복 인스턴스는 Destroy 되어야 한다"); + Assert.AreEqual(1, DuplicateCase.DestroyedCount); + Assert.AreSame(first, MonoSingleton.Instance); + } + + [UnityTest] + public IEnumerator ManualReady_Stays_Not_Ready_Until_Marked() + { + var inst = ManualReadyCase.Instance; + yield return null; + Assert.IsNotNull(inst); + Assert.IsFalse(MonoSingleton.IsReady); + + inst.Ready(); + Assert.IsTrue(MonoSingleton.IsReady); + } + } +} diff --git a/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Patterns/ServiceLocatorTests.cs b/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Patterns/ServiceLocatorTests.cs new file mode 100644 index 0000000..afe8808 --- /dev/null +++ b/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Patterns/ServiceLocatorTests.cs @@ -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(svc); + + Assert.IsTrue(ServiceLocator.IsRegistered()); + Assert.AreSame(svc, ServiceLocator.Resolve()); + } + + [Test] + public void Register_Null_Throws() + { + Assert.Throws(() => ServiceLocator.Register((IGreeter)null)); + Assert.Throws(() => ServiceLocator.Register((Func)null)); + } + + [Test] + public void Resolve_Missing_Throws_ServiceNotRegistered() + { + var ex = Assert.Throws(() => ServiceLocator.Resolve()); + Assert.AreEqual(typeof(IGreeter), ex.ServiceType); + } + + [Test] + public void TryResolve_Missing_Returns_False() + { + Assert.IsFalse(ServiceLocator.TryResolve(out var svc)); + Assert.IsNull(svc); + } + + [Test] + public void Factory_Is_Invoked_Once_And_Cached() + { + int factoryCalls = 0; + ServiceLocator.Register(() => { factoryCalls++; return new Counter(); }); + + Assert.AreEqual(0, factoryCalls, "등록 시점에 factory는 호출되지 않는다"); + + var a = ServiceLocator.Resolve(); + var b = ServiceLocator.Resolve(); + + 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")); + + ServiceLocator.Register(new EnGreeter()); + ServiceLocator.Register(new KoGreeter()); + + Assert.AreEqual("안녕", ServiceLocator.Resolve().Hello()); + } + + [Test] + public void Unregister_Removes_Binding() + { + ServiceLocator.Register(new EnGreeter()); + ServiceLocator.Unregister(); + + Assert.IsFalse(ServiceLocator.IsRegistered()); + Assert.Throws(() => ServiceLocator.Resolve()); + } + + [Test] + public void Clear_Removes_All() + { + ServiceLocator.Register(new EnGreeter()); + ServiceLocator.Register(() => new Counter()); + ServiceLocator.Clear(); + + Assert.IsFalse(ServiceLocator.IsRegistered()); + Assert.IsFalse(ServiceLocator.IsRegistered()); + } + + [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(() => throw new InvalidOperationException("boom")); + + Assert.IsFalse(ServiceLocator.TryResolve(out var svc)); + Assert.IsNull(svc); + } + } +} diff --git a/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Util/Log/LogTests.cs b/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Util/Log/LogTests.cs new file mode 100644 index 0000000..563ca80 --- /dev/null +++ b/코어코드/NerdNavis.Framework/Tests/Runtime/Core/Util/Log/LogTests.cs @@ -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"); + } + } +} diff --git a/코어코드/NerdNavis.Framework/Tests/Runtime/NerdNavis.Framework.Tests.asmdef b/코어코드/NerdNavis.Framework/Tests/Runtime/NerdNavis.Framework.Tests.asmdef new file mode 100644 index 0000000..923964d --- /dev/null +++ b/코어코드/NerdNavis.Framework/Tests/Runtime/NerdNavis.Framework.Tests.asmdef @@ -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 +} diff --git a/코어코드/NerdNavis.Framework/package.json b/코어코드/NerdNavis.Framework/package.json new file mode 100644 index 0000000..b033b63 --- /dev/null +++ b/코어코드/NerdNavis.Framework/package.json @@ -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": {} +}