using System.Collections; using System.Collections.Generic; using System.IO; using NUnit.Framework; using TKS.OutGame; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.SceneManagement; using UnityEngine.TestTools; using UnityEngine.UI; namespace TKS.Tests { /// /// UI 캔버스 층 · 아틀라스 구조(PD 2026-11-10 「리소스·정보가 얼마나 자주 바뀌는지에 따라 캔버스를 나누어 최적화 · 드로우콜 최소화 구조 필수」) — /// ① 중첩 캔버스 = overrideSorting 끔 + 등급 표시 ② 아웃게임 화면마다 자기 캔버스 · 스크롤 내용 = Frame ③ 매 프레임 요소(호흡·튀김·커서·미니맵)는 Frame 캔버스 안 /// ④ 중첩 캔버스 위 누름이 그대로 동작하고 팝업이 아래 화면을 가림 ⑤ 테마 스프라이트(큰 그림 제외)는 아틀라스로 묶임 · 월드 9-slice 사본이 아틀라스 자리에서 잘림. /// 기준값(큰 그림 넓이)은 표 Assets/Data/UI/ui_atlas.csv 에서 읽는다(C45). /// public class UILayerTests { [TearDown] public void TearDown() { SaveStore.UseMemory(false); } static void AssertNested(Canvas c, UiRate rate, string what) { Assert.IsNotNull(c, what + ": 캔버스 없음"); // 꺼진 캔버스는 isRootCanvas = true 로 보고되므로 부모 사슬로 판정 Assert.IsNotNull(c.transform.parent != null ? c.transform.parent.GetComponentInParent(true) : null, what + ": 중첩이어야 함"); Assert.IsFalse(c.overrideSorting, what + ": overrideSorting 은 끈다(순서·자르기 유지)"); var tag = c.GetComponent(); Assert.IsNotNull(tag, what + ": 등급 표시 없음"); Assert.AreEqual(rate, tag.rate, what + ": 등급"); } /// t 가 그리는 캔버스(가장 가까운 것)가 Frame 등급인가. static void AssertInFrame(Transform t, string what) { var c = t.GetComponentInParent(true); Assert.IsNotNull(c, what); var tag = c.GetComponent(); Assert.IsTrue(tag != null && tag.rate == UiRate.Frame, what + ": 매 프레임 요소가 Frame 캔버스 밖(" + c.name + ")"); } static GameObject TopHit(Vector2 screen) { var hits = new List(); EventSystem.current.RaycastAll(new PointerEventData(EventSystem.current) { position = screen }, hits); return hits.Count > 0 ? hits[0].gameObject : null; } [UnityTest] public IEnumerator Canvases_SplitByUpdateRate_InputAndAtlas() { WorldProgress.Reset(); SaveStore.UseMemory(true); SceneManager.LoadScene("Battle"); yield return null; yield return null; var og = OutGameRoot.Instance; Assert.IsNotNull(og); // ② 화면마다 자기 캔버스(Event · 누름 있음) foreach (var scr in new OutGameScreen[] { og.Lobby, og.HeroList, og.HeroDetail, og.Formation, og.Trade, og.Domestic, og.Treasures, og.Arena }) { AssertNested(scr.Rt.GetComponent(), UiRate.Event, scr.GetType().Name); Assert.IsNotNull(scr.Rt.GetComponent(), scr.GetType().Name + ": 레이캐스터"); } og.Show(og.HeroList); yield return null; yield return null; var scrolls = og.HeroList.Rt.GetComponentsInChildren(true); Assert.Greater(scrolls.Length, 0, "영웅 목록 스크롤"); foreach (var sr in scrolls) AssertNested(sr.content.GetComponent(), UiRate.Frame, "스크롤 내용 " + sr.name); // ④ 중첩 캔버스(스크롤 내용) 위 카드 누름이 맞고, 팝업을 띄우면 같은 점이 팝업에 맞는다 var content = scrolls[0].content; Selectable card = null; foreach (var s in content.GetComponentsInChildren()) { card = s; break; } Component handler = card; if (handler == null) handler = content.GetComponentInChildren(); Assert.IsNotNull(handler, "영웅 카드(누름) 없음"); var crt = (RectTransform)handler.transform; var screen = RectTransformUtility.WorldToScreenPoint(og.Cam, crt.TransformPoint(crt.rect.center)); var hit = TopHit(screen); Assert.IsNotNull(hit, "카드 위 레이캐스트 결과 없음"); Assert.IsTrue(hit.transform.IsChildOf(content), "카드 위 첫 결과가 스크롤 내용 밖: " + hit.name); og.Modal("검사", "팝업", new OutGameRoot.ModalButton("확인", Color.gray, null)); yield return null; hit = TopHit(screen); Assert.IsNotNull(hit); Assert.IsTrue(hit.transform.IsChildOf(og.TopRoot), "팝업이 아래 화면 누름을 가려야 함: " + hit.name); og.CloseModal(); // ③ 아웃게임의 매 프레임 요소 og.Show(og.Treasures); og.Treasures.SetPage(2); yield return null; foreach (var p in Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None)) AssertInFrame(p.transform, "호흡 " + p.name); // 월드맵: 스크롤 내용 = Frame · 커서 = 자기 Frame 캔버스 · 팝업 = Event var wm = og.Map; og.OpenWorldMap(); yield return null; yield return null; var cursor = GameObject.Find("cursor"); if (cursor != null) { AssertNested(cursor.GetComponent(), UiRate.Frame, "월드맵 커서"); AssertNested(cursor.transform.parent.GetComponent(), UiRate.Frame, "월드맵 스크롤 내용"); } wm.OpenPopup(0); yield return null; AssertNested(GameObject.Find("WorldMapCanvas/Popup").GetComponent(), UiRate.Event, "월드맵 팝업"); // 전투 HUD: 루트 = Static · Info = Event · 미니맵 실시간 = Frame · 결과 = Event wm.ConfirmPopup(); var bm = BattleManager.Instance; float t0 = Time.realtimeSinceStartup; while (bm.Phase != BattlePhase.PlayerTurn && Time.realtimeSinceStartup - t0 < 10f) yield return null; var hud = bm.Hud.transform; Assert.AreEqual(UiRate.Static, hud.GetComponent().rate, "HUD 루트 = 정적 판"); AssertNested(hud.Find("Info").GetComponent(), UiRate.Event, "HUD Info"); AssertNested(hud.Find("Result").GetComponent(), UiRate.Event, "HUD 결과"); var live = hud.Find("Minimap/MinimapLive"); Assert.IsNotNull(live, "미니맵 실시간 층"); AssertNested(live.GetComponent(), UiRate.Frame, "미니맵 실시간"); AssertInFrame(live.Find("View"), "미니맵 시야"); foreach (var p in hud.GetComponentsInChildren(true)) AssertInFrame(p.transform, "HUD 호흡 " + p.name); Assert.AreEqual(hud.childCount - 1, hud.Find("Result").GetSiblingIndex(), "결과 층 = 맨 위"); Assert.AreEqual(hud.childCount - 2, hud.Find("Info").GetSiblingIndex(), "Info 층 = 정적 판 위"); // ⑤ 아틀라스 — 테마 스프라이트(큰 그림 제외)는 묶임 · 월드 9-slice 사본 = 같은 크기 long single = SingleArea(); int checkedN = 0; var theme = UITheme.Current; Assert.IsNotNull(theme); var sprites = new List(); foreach (var s in theme.skins) sprites.Add(s.Sprite); foreach (var i in theme.icons) sprites.Add(i.Sprite); foreach (var sp in sprites) { if (sp == null || (long)(sp.rect.width * sp.rect.height) > single) continue; Assert.IsTrue(sp.packed, "아틀라스에 안 묶임: " + sp.name + " — 메뉴 TKS/Build UI Atlases"); checkedN++; } Assert.Greater(checkedN, 0); foreach (var id in new[] { "hp_bg", "hp_fill", "pip" }) { var src = UISkin.Theme.Skin(id); if (src == null) continue; var ws = UISkin.WorldSprite(id, 100f); Assert.AreEqual(src.Sprite.rect.size, ws.rect.size, "월드 9-slice 사본 크기(" + id + ")"); } } static long SingleArea() { var path = Path.Combine(Application.dataPath, "Data/UI/ui_atlas.csv"); Assert.IsTrue(File.Exists(path), path); foreach (var line in File.ReadAllLines(path)) { var f = line.Split(','); long v; if (f.Length >= 4 && f[0].Trim() == "option" && f[1].Trim() == "single_area" && long.TryParse(f[3].Trim(), out v)) return v; } Assert.Fail("ui_atlas.csv 에 single_area 없음"); return 0; } } }