ThreeKingdomsSlingshot/Assets/Tests/PlayMode/UILayerTests.cs

252 lines
14 KiB
C#

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
{
/// <summary>
/// UI 캔버스 층 · 아틀라스 구조(PD 2026-11-10 「리소스·정보가 얼마나 자주 바뀌는지에 따라 캔버스를 나누어 최적화 · 드로우콜 최소화 구조 필수」) —
/// ① 중첩 캔버스 = overrideSorting 끔 + 등급 표시 ② 아웃게임 화면마다 자기 캔버스 · 스크롤 내용 = Frame ③ 매 프레임 요소(호흡·튀김·커서·미니맵)는 Frame 캔버스 안
/// ④ 중첩 캔버스 위 누름이 그대로 동작하고 팝업이 아래 화면을 가림 ⑤ 테마 스프라이트(큰 그림 제외)는 아틀라스로 묶임 · 월드 9-slice 사본이 아틀라스 자리에서 잘림.
/// 기준값(큰 그림 넓이)은 표 Assets/Data/UI/ui_atlas.csv 에서 읽는다(C45).
/// </summary>
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<Canvas>(true) : null, what + ": 중첩이어야 함");
Assert.IsFalse(c.overrideSorting, what + ": overrideSorting 은 끈다(순서·자르기 유지)");
var tag = c.GetComponent<UiLayerInfo>();
Assert.IsNotNull(tag, what + ": 등급 표시 없음");
Assert.AreEqual(rate, tag.rate, what + ": 등급");
}
/// <summary>t 가 그리는 캔버스(가장 가까운 것)가 Frame 등급인가.</summary>
static void AssertInFrame(Transform t, string what)
{
var c = t.GetComponentInParent<Canvas>(true);
Assert.IsNotNull(c, what);
var tag = c.GetComponent<UiLayerInfo>();
Assert.IsTrue(tag != null && tag.rate == UiRate.Frame, what + ": 매 프레임 요소가 Frame 캔버스 밖(" + c.name + ")");
}
static GameObject TopHit(Vector2 screen)
{
var hits = new List<RaycastResult>();
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<Canvas>(), UiRate.Event, scr.GetType().Name);
Assert.IsNotNull(scr.Rt.GetComponent<GraphicRaycaster>(), scr.GetType().Name + ": 레이캐스터");
}
// 로비 배경 연출(깃발·불빛·새·꽃잎 · PD 2026-11-11) = 자기 Frame 층 · 화면 캔버스(Event)는 매 프레임 다시 묶이지 않음
og.ShowLobby();
yield return null;
var amb = og.Lobby.Rt.GetComponentInChildren<LobbyAmbient>(true);
Assert.IsTrue(UISkin.Icon("bg_lobby") == null || amb != null, "로비 배경 연출 층 없음");
if (amb != null)
{
AssertNested(amb.GetComponent<Canvas>(), UiRate.Frame, "로비 배경 연출");
var waves = amb.GetComponentsInChildren<UiWaveImage>(true);
Assert.Greater(waves.Length, 0, "로비 깃발 없음(표 lobby_scene.csv flag 줄 · 테마 lobby_banner_*)");
foreach (var w in waves) AssertInFrame(w.transform, "로비 깃발 " + w.name);
// 그리는 순서 = 뒤 연출(깃발·불빛) → 영웅 → 앞 연출(새·꽃잎) · PD 2026-11-11 「벚꽃잎은 유닛보다 앞 레이어」
var back = amb.transform.Find("Back");
var line = amb.transform.Find("Lineup");
var front = amb.transform.Find("Front");
Assert.IsTrue(back != null && line != null && front != null, "로비 장면 층(Back · Lineup · Front) 없음");
Assert.Less(back.GetSiblingIndex(), line.GetSiblingIndex(), "깃발·불빛은 영웅 뒤");
Assert.Less(line.GetSiblingIndex(), front.GetSiblingIndex(), "꽃잎·새는 영웅 앞");
Assert.Greater(front.childCount, 0, "앞 층(꽃잎·새) 비어 있음");
Assert.Greater(line.childCount, 0, "영웅 층 비어 있음");
foreach (var fa in line.GetComponentsInChildren<UiFrameAnim>(true)) AssertInFrame(fa.transform, "로비 영웅 대기 " + fa.name);
}
og.Show(og.HeroList);
yield return null;
yield return null;
var scrolls = og.HeroList.Rt.GetComponentsInChildren<ScrollRect>(true);
Assert.Greater(scrolls.Length, 0, "영웅 목록 스크롤");
foreach (var sr in scrolls) AssertNested(sr.content.GetComponent<Canvas>(), UiRate.Frame, "스크롤 내용 " + sr.name);
// ④ 중첩 캔버스(스크롤 내용) 위 카드 누름이 맞고, 팝업을 띄우면 같은 점이 팝업에 맞는다
var content = scrolls[0].content;
Selectable card = null;
foreach (var s in content.GetComponentsInChildren<Selectable>()) { card = s; break; }
Component handler = card;
if (handler == null) handler = content.GetComponentInChildren<TapDrag>();
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<UiPulse>(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<Canvas>(), UiRate.Frame, "월드맵 커서");
AssertNested(cursor.transform.parent.GetComponent<Canvas>(), UiRate.Frame, "월드맵 스크롤 내용");
}
wm.OpenPopup(0);
yield return null;
AssertNested(GameObject.Find("WorldMapCanvas/Popup").GetComponent<Canvas>(), 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<UiLayerInfo>().rate, "HUD 루트 = 정적 판");
AssertNested(hud.Find("Info").GetComponent<Canvas>(), UiRate.Event, "HUD Info");
AssertNested(hud.Find("Result").GetComponent<Canvas>(), UiRate.Event, "HUD 결과");
var live = hud.Find("Minimap/MinimapLive");
Assert.IsNotNull(live, "미니맵 실시간 층");
AssertNested(live.GetComponent<Canvas>(), UiRate.Frame, "미니맵 실시간");
AssertInFrame(live.Find("View"), "미니맵 시야");
foreach (var p in hud.GetComponentsInChildren<UiPulse>(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<Sprite>();
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 + ")");
}
}
/// <summary>
/// 대기 재생 규칙(PD 2026-11-11 「4장 + 깜빡임」) — meta 의 sequence 0→1→2→3 이 단계별 유지 초대로 돌고, 깜빡임 프레임은 쉼(첫 프레임)일 때만 every_min~max 간격으로 dur 동안.
/// 값은 유닛 meta.json 에서 읽음(C45) · 유비 = 시제품(meta 에 sequence·blink 가 없으면 건너뜀).
/// </summary>
[Test]
public void IdleSequenceAndBlink_FollowMeta()
{
var d = UnitArt.Load("liubei");
Assert.IsNotNull(d, "유비 유닛 아트");
const string clip = "front_idle";
Sprite[] fr;
Assert.IsTrue(d.Clips.TryGetValue(clip, out fr), clip);
int n = fr.Length;
var blink = d.ClipBlink(clip, n);
int[] seq;
if (blink == null || !d.Sequences.TryGetValue(clip, out seq)) Assert.Ignore("유비 대기에 sequence·blink 없음");
seq = d.Sequences[clip];
float cycle = d.CycleSeconds(clip, n);
// ① 순서: 한 바퀴를 촘촘히 훑으면 순서대로 바뀜 · 깜빡임 프레임은 순서에 없음
var seen = new List<int>();
for (float t = 0f; t < cycle; t += 0.005f)
{
int k = d.FrameAt(clip, n, t);
Assert.AreNotEqual(blink.frame, k, "깜빡임 프레임이 순서에 섞임");
if (seen.Count == 0 || seen[seen.Count - 1] != k) seen.Add(k);
}
CollectionAssert.AreEqual(seq, seen.ToArray(), "재생 순서");
// ② 깜빡임: 30 초 동안 간격·길이·쉼 자세에서만
float next = 0f, end = -1f, start = -1f, last = -1f;
int count = 0;
for (float t = 0f; t < 30f; t += 0.01f)
{
int raw = d.FrameAt(clip, n, t);
int k = d.ApplyBlink(clip, n, raw, t, ref next, ref end);
if (k == blink.frame)
{
Assert.AreEqual(d.RestFrame(clip, n), raw, "깜빡임은 쉼 자세에서만");
if (start < 0f)
{
if (last >= 0f) Assert.GreaterOrEqual(t - last, blink.every_min - 0.02f, "깜빡임 간격 하한");
start = t;
count++;
}
}
else if (start >= 0f)
{
Assert.LessOrEqual(t - start, blink.dur + 0.03f, "깜빡임 길이");
last = start;
start = -1f;
}
}
Assert.GreaterOrEqual(count, Mathf.FloorToInt(30f / (blink.every_max + cycle)), "30 초 동안 깜빡임 횟수");
}
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;
}
}
}