using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using NUnit.Framework;
using TKS.OutGame;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.TestTools;
namespace TKS.Tests
{
///
/// 아웃게임 1차(2026-11-05 #1093 · 시스템 기획 §12) — 임포터(행 수·필수 열·id 유일·참조 무결) · 공식(표 값으로 기대치) · 군량 회복·오프라인 ·
/// 승급 6칸 · 진화 조각 · 뽑기 확률·천장·10연 보장 · 보상 공식·별 · 편성 → 스폰 · 세이브 왕복 · 로비 탭·뒤로 · 군량 입장·결과·소탕 · 상점.
/// 기대값은 전부 표(CSV·자산)에서 계산한다(C45 · 테스트에 수치 상수 없음).
///
public class OutGameTests
{
static readonly DateTime T0 = new DateTime(2026, 11, 5, 3, 0, 0, DateTimeKind.Utc);
static string[] ReadTables()
{
var dir = Path.Combine(Application.dataPath, OutGameTables.Folder);
var texts = new string[OutGameTables.Files.Length];
for (int i = 0; i < texts.Length; i++) texts[i] = File.ReadAllText(Path.Combine(dir, OutGameTables.Files[i]), System.Text.Encoding.UTF8);
return texts;
}
static int CsvRows(string file)
{
int n = 0;
foreach (var l in File.ReadAllLines(Path.Combine(Application.dataPath, OutGameTables.Folder, file), System.Text.Encoding.UTF8))
if (l.Trim().Length > 0) n++;
return n - 1;
}
static OutGameData Db
{
get { return OutGameData.Default; }
}
static BalanceConfig Cfg
{
get { return Resources.Load("BalanceConfig"); }
}
static SaveData NewSave()
{
return HeroRules.NewSave(Db, Cfg.playerSpawnPoints.Length, T0);
}
// ---------- 임포터 ----------
[Test]
public void Import_Tables_RowCounts_Columns_UniqueIds_References()
{
var r = OutGameTables.Parse(ReadTables());
try
{
Assert.AreEqual(0, r.errors.Count, string.Join("\n", r.errors.ToArray()));
foreach (var f in OutGameTables.Files) Assert.AreEqual(CsvRows(f), r.rowCounts[f], f + " 행 수");
var d = r.data;
Assert.AreEqual(CsvRows("heroes.csv"), d.Heroes.heroes.Count);
Assert.AreEqual(CsvRows("skills.csv"), d.Skills.skills.Count);
Assert.AreEqual(CsvRows("gear_items.csv"), d.Gear.items.Count);
Assert.AreEqual(CsvRows("hero_levels.csv"), d.Levels.rows.Count);
int gearRows = 0;
foreach (var h in d.Heroes.heroes)
{
foreach (var sid in new[] { h.skillActive, h.skillPassive })
if (sid.Length > 0) Assert.AreEqual(h.id, d.Skills.Get(sid).heroId, h.id + " ↔ " + sid);
foreach (var set in h.gear)
foreach (var g in set.slots)
{
Assert.IsNotNull(d.Gear.Get(g), h.id + " " + set.grade + " " + g);
gearRows++;
}
if (h.isSoldier) Assert.AreEqual(0, h.gear.Count, h.id + " 병사 = 승급 없음");
}
Assert.AreEqual(CsvRows("hero_gear.csv"), gearRows, "hero_gear 행 = 영웅 × 등급 × 칸");
foreach (var p in d.Gacha.pool) Assert.IsNotNull(d.Heroes.Get(p.heroId), "뽑기 풀 " + p.heroId);
foreach (var id in d.Economy.StartHeroes) Assert.IsNotNull(d.Heroes.Get(id), "시작 영웅 " + id);
Assert.AreEqual(1, d.Promotion.Get(d.Promotion.FirstGrade).order, "초기 등급 = order 1");
}
finally
{
OutGameTables.Destroy(r);
}
}
[Test]
public void Import_BrokenTable_ReportsErrors()
{
var texts = ReadTables();
int hi = Array.IndexOf(OutGameTables.Files, "heroes.csv");
var lines = new List(texts[hi].Split('\n'));
lines.Insert(2, lines[1]); // 첫 영웅 행 중복
texts[hi] = string.Join("\n", lines.ToArray());
int si = Array.IndexOf(OutGameTables.Files, "skills.csv");
texts[si] = texts[si].Replace("cooldown", "cool_down"); // 필수 열 이름 틀림
int gi = Array.IndexOf(OutGameTables.Files, "hero_gear.csv");
int cut = texts[gi].IndexOf("gear_", texts[gi].IndexOf('\n') + 1, StringComparison.Ordinal); // 머리줄(gear_id) 다음 첫 데이터 행
texts[gi] = texts[gi].Substring(0, cut) + "gear_nope_" + texts[gi].Substring(cut + 5); // 없는 장비 참조
var r = OutGameTables.Parse(texts);
var all = string.Join("\n", r.errors.ToArray());
OutGameTables.Destroy(r);
StringAssert.Contains("중복", all);
StringAssert.Contains("cooldown", all);
StringAssert.Contains("gear_nope_", all);
Assert.AreEqual(CsvRows("heroes.csv"), Db.Heroes.heroes.Count, "실패한 파싱은 자산을 건드리지 않음");
}
[Test]
public void Import_AssetsMatchCsv()
{
Assert.IsTrue(Db.Complete, "Resources/OutGame 9 자산(TKS/Import OutGame Data)");
var r = OutGameTables.Parse(ReadTables());
try
{
var p = r.data;
Assert.AreEqual(p.Heroes.heroes.Count, Db.Heroes.heroes.Count);
for (int i = 0; i < p.Heroes.heroes.Count; i++)
{
var a = p.Heroes.heroes[i];
var b = Db.Heroes.heroes[i];
Assert.AreEqual(a.id, b.id);
Assert.AreEqual(a.type, b.type, a.id);
Assert.AreEqual(a.hpMult, b.hpMult, 1e-6f, a.id);
Assert.AreEqual(a.atkMult, b.atkMult, 1e-6f, a.id);
Assert.AreEqual(a.defMult, b.defMult, 1e-6f, a.id);
Assert.AreEqual(a.art, b.art, a.id);
Assert.AreEqual(a.gear.Count, b.gear.Count, a.id);
}
Assert.AreEqual(p.Gear.items.Count, Db.Gear.items.Count);
Assert.AreEqual(p.Skills.skills.Count, Db.Skills.skills.Count);
Assert.AreEqual(p.Levels.rows.Count, Db.Levels.rows.Count);
Assert.AreEqual(p.Rewards.rows.Count, Db.Rewards.rows.Count);
Assert.AreEqual(p.Economy.values.entries.Count, Db.Economy.values.entries.Count);
foreach (var e in p.Economy.values.entries) Assert.AreEqual(e.value, Db.Economy.values.S(e.key), e.key);
foreach (var e in p.Gacha.values.entries) Assert.AreEqual(e.value, Db.Gacha.values.S(e.key), e.key);
}
finally
{
OutGameTables.Destroy(r);
}
}
// ---------- 공식 · 성장 ----------
[Test]
public void Stats_Formula_FromTables()
{
var cfg = Cfg;
var h = Db.Heroes.Get(Db.Economy.StartHeroes[1]);
string grade = Db.Promotion.Get(Db.Promotion.FirstGrade).nextGrade;
var s = new HeroState { id = h.id, lv = 10, grade = grade, gearTotal = 6, star = 4 };
var st = HeroRules.Compute(Db, cfg, h, s);
var b = cfg.GetStats(h.type);
float g = (1f + Db.Levels.Get(10).statPct / 100f) * (1f + (Db.Promotion.Get(grade).promoPct + 6 * Db.Economy.GearBonusPct) / 100f) * (1f + Db.Evolve.Get(4).starPct / 100f);
Assert.AreEqual(b.maxHp * h.hpMult * g, st.hp, 1e-3f);
Assert.AreEqual(b.atk * h.atkMult * g, st.atk, 1e-3f);
Assert.AreEqual(b.def * h.defMult * g, st.def, 1e-3f);
var e = Db.Economy;
Assert.AreEqual(Mathf.RoundToInt(st.hp / e.PowerHpDiv + st.atk * e.PowerAtkW + st.def * e.PowerDefW), st.power, "전투력 = hp/10 + 공×3 + 방×2");
Assert.AreEqual(0f, st.trainPct, "훈련 = C 단계");
HeroDb.Hero soldier = null;
foreach (var x in Db.Heroes.heroes)
if (x.isSoldier) { soldier = x; break; }
var ss = HeroRules.Compute(Db, cfg, soldier, new UnitState { id = soldier.id, lv = 1 });
Assert.AreEqual(0f, ss.promoPct + ss.gearPct + ss.starPct, "병사 = 승급·장비·진화 없음");
Assert.AreEqual(cfg.GetStats(soldier.type).maxHp * soldier.hpMult * (1f + Db.Levels.Get(1).statPct / 100f), ss.hp, 1e-3f);
}
[Test]
public void Level_GoldExp_ChapterCap()
{
var d = NewSave();
Assert.AreEqual(Db.Economy.StartGold, d.gold);
Assert.AreEqual(Db.Economy.StartGem, d.gem);
Assert.AreEqual(Db.Economy.StartStamina, d.stamina);
var hs = d.Hero(Db.Economy.StartHeroes[0]);
Assert.AreEqual(1, hs.lv);
Assert.AreEqual(Db.Promotion.FirstGrade, hs.grade);
Assert.AreEqual(Db.Heroes.Get(hs.id).rarity, hs.star, "★ = 희귀도");
StringAssert.Contains("경험", HeroRules.CanLevelUp(Db, d, hs));
var row = Db.Levels.Get(1);
HeroRules.GrantExp(hs, row.expToNext);
int gold = d.gold;
Assert.IsTrue(HeroRules.LevelUp(Db, d, hs));
Assert.AreEqual(2, hs.lv);
Assert.AreEqual(gold - row.goldCost, d.gold);
Assert.AreEqual(0, hs.exp);
int cap = Db.Levels.CapFor(1);
Assert.AreEqual(cap, HeroRules.LevelCap(Db, d), "클리어 최고 챕터 1 = 상한");
hs.lv = cap;
hs.exp = 1000000;
d.gold = 1000000;
StringAssert.Contains("상한", HeroRules.CanLevelUp(Db, d, hs));
d.chapterMax = HeroRules.NextCapChapter(Db, cap);
Assert.IsNull(HeroRules.CanLevelUp(Db, d, hs), "다음 챕터 클리어 → 상한 해제");
d.gold = 0;
StringAssert.Contains("골드", HeroRules.CanLevelUp(Db, d, hs));
}
[Test]
public void Stamina_RegenOffline_Cap_GemAndAd()
{
var e = Db.Economy;
var d = NewSave();
d.stamina = 10;
d.staminaAt = SaveStore.Iso(T0);
EconomyRules.TickStamina(Db, d, T0.AddSeconds(3 * e.StaminaRegenSec + 5));
Assert.AreEqual(13, d.stamina, "regen 초마다 1");
Assert.AreEqual(e.StaminaRegenSec - 5, EconomyRules.SecondsToNext(Db, d, T0.AddSeconds(3 * e.StaminaRegenSec + 5)), "남은 초");
var later = T0.AddSeconds(1000.0 * e.StaminaRegenSec); // 오프라인 오래 → 최대에서 멈춤
EconomyRules.TickStamina(Db, d, later);
Assert.AreEqual(e.StaminaMax, d.stamina);
Assert.IsTrue(EconomyRules.SpendStamina(Db, d, e.StageCost, later));
Assert.AreEqual(e.StaminaMax - e.StageCost, d.stamina);
Assert.AreEqual(e.StaminaRegenSec, EconomyRules.SecondsToNext(Db, d, later), "가득 찬 상태에서 쓰면 그때부터 회복");
d.stamina = 0;
Assert.IsFalse(EconomyRules.SpendStamina(Db, d, e.StageCost, later), "부족 = 출전 불가");
d.gem = e.GemStaminaCost * (e.GemStaminaDaily + 1);
for (int i = 0; i < e.GemStaminaDaily; i++) Assert.IsNull(EconomyRules.BuyStaminaWithGem(Db, d, later));
Assert.AreEqual(e.GemStaminaAmount * e.GemStaminaDaily, d.stamina);
Assert.IsNotNull(EconomyRules.BuyStaminaWithGem(Db, d, later), "보옥 구매 일일 상한");
for (int i = 0; i < e.AdStaminaDaily; i++) Assert.IsNull(EconomyRules.AdStamina(Db, d, later));
Assert.IsNotNull(EconomyRules.AdStamina(Db, d, later), "광고 일일 상한");
Assert.IsNull(EconomyRules.AdStamina(Db, d, later.AddDays(1)), "다음 날 초기화");
}
[Test]
public void Promote_SixSlots_GradeUp_ConsumesGear()
{
var d = NewSave();
var h = Db.Heroes.Get(Db.Economy.StartHeroes[0]);
var hs = d.Hero(h.id);
string g0 = hs.grade;
var need = Db.Heroes.GearFor(h, g0);
Assert.IsNotNull(need);
Assert.AreEqual(HeroRules.GearSlotCount(Db), need.Count);
Assert.AreEqual("장비 없음", HeroRules.CanEquip(Db, d, h, hs, 0));
StringAssert.Contains("장비 0", HeroRules.CanPromote(Db, h, hs));
foreach (var id in need) d.inv.AddGear(id, 1);
for (int i = 0; i < need.Count; i++) Assert.IsTrue(HeroRules.Equip(Db, d, h, hs, i), "칸 " + i);
Assert.AreEqual(need.Count, hs.gearTotal, "1칸 = 1강(누적)");
foreach (var id in need) Assert.AreEqual(0, d.inv.GearCount(id), "가방에서 빠짐");
var before = HeroRules.Compute(Db, Cfg, h, hs);
Assert.IsTrue(HeroRules.Promote(Db, h, hs));
Assert.AreEqual(Db.Promotion.Get(g0).nextGrade, hs.grade, "등급 +1");
foreach (var g in hs.gear) Assert.AreEqual("", g, "6칸 비움");
Assert.AreEqual(need.Count, hs.gearTotal, "누적 장비 보너스 유지");
var after = HeroRules.Compute(Db, Cfg, h, hs);
Assert.AreEqual(Db.Promotion.Get(hs.grade).promoPct, after.promoPct);
Assert.Greater(after.hp, before.hp);
Assert.AreEqual(Db.Promotion.Get(hs.grade).order, HeroRules.SkillCap(Db, hs), "스킬 상한 = 등급 순서");
}
[Test]
public void Evolve_ShardsToNextStar()
{
var d = NewSave();
HeroDb.Hero h = null;
foreach (var x in Db.Heroes.heroes)
if (!x.isSoldier && x.rarity == 1) { h = x; break; }
HeroRules.Grant(Db, d, h, HeroRules.GearSlotCount(Db));
var hs = d.Hero(h.id);
Assert.AreEqual(1, hs.star);
int need = Db.Evolve.Get(2).shardsNeeded;
hs.shards = need - 1;
Assert.IsNotNull(HeroRules.CanEvolve(Db, h, hs));
hs.shards = need;
Assert.IsTrue(HeroRules.Evolve(Db, h, hs));
Assert.AreEqual(2, hs.star);
Assert.AreEqual(0, hs.shards);
Assert.AreEqual(Db.Evolve.Get(2).starPct, HeroRules.Compute(Db, Cfg, h, hs).starPct);
hs.star = Db.Evolve.MaxStar;
hs.shards = 100000;
Assert.AreEqual("최고 ★", HeroRules.CanEvolve(Db, h, hs));
}
// ---------- 뽑기 ----------
[Test]
public void Gacha_ProbSumOne_Pity_TenGuarantee_Duplicates()
{
var g = Db.Gacha;
Assert.AreEqual(1f, g.P(1) + g.P(2) + g.P(3), 1e-4f, "확률 합 = 1");
var rng = new System.Random(1093);
var d = NewSave();
int gem = d.gem;
Assert.IsTrue(GachaRules.FirstTenFree(Db, d));
var ten = GachaRules.Draw(Db, d, g.TenCount, rng);
Assert.AreEqual(g.TenCount, ten.Count);
Assert.AreEqual(gem, d.gem, "첫 10연 무료");
Assert.IsTrue(d.gacha.firstTenUsed);
Assert.AreEqual(g.CostTen, GachaRules.Cost(Db, d, g.TenCount));
d.gem = 100000000;
d.gacha.pity = g.Pity - 1;
var p = GachaRules.Draw(Db, d, 1, rng);
Assert.AreEqual(3, p[0].tier, "천장 = ★★★ 확정");
Assert.IsTrue(p[0].pity);
Assert.AreEqual(0, d.gacha.pity, "획득 시 리셋");
Assert.AreEqual(3, Db.Heroes.Get(p[0].heroId).rarity);
for (int k = 0; k < 200; k++)
{
var batch = GachaRules.Draw(Db, d, g.TenCount, rng);
bool ok = false;
foreach (var x in batch) ok |= x.tier >= g.TenGuaranteeRarity;
Assert.IsTrue(ok, "10연 = ★★ 이상 1 보장");
}
var counts = new int[4];
int n = 20000, shards = 0, gold = 0;
for (int k = 0; k < n; k++)
{
d.gacha.pity = 0;
var x = GachaRules.Draw(Db, d, 1, rng)[0];
counts[x.tier]++;
shards += x.shards;
gold += x.gold;
var h = Db.Heroes.Get(x.heroId);
Assert.AreEqual(h.isSoldier ? 1 : h.rarity, x.tier, x.heroId + " 등급 풀");
}
for (int t = 1; t <= 3; t++) Assert.AreEqual(g.P(t), counts[t] / (float)n, 0.01f, "★" + t + " 빈도 ≈ 확률");
Assert.Greater(shards, 0, "중복 영웅 = 조각");
Assert.Greater(gold, 0, "중복 병사 = 골드");
}
// ---------- 보상 · 별 ----------
static BalanceConfig.StageDef FirstStage(StageKind kind)
{
foreach (var s in Cfg.stages)
if (s.chapter == Cfg.stages[0].chapter && s.kind == kind) return s;
return null;
}
[Test]
public void Reward_FirstRepeatBoss_Stars_Sweep()
{
var e = Db.Economy;
var d = NewSave();
var rng = new System.Random(5);
var s = FirstStage(StageKind.Normal);
var row = Db.Rewards.For(s.chapter);
float baseGold = row.goldBase + s.stage * e.GoldPerStage;
var r = EconomyRules.Compute(Db, d, s, true, true, 1, false, rng);
Assert.IsTrue(r.first);
Assert.AreEqual(Mathf.RoundToInt(baseGold * e.FirstClearGoldMult), r.gold);
Assert.AreEqual(row.expBase, r.exp);
Assert.AreEqual(row.gemFirst, r.gem);
Assert.AreEqual(3, r.stars);
int gold = d.gold;
var ids = new List(e.StartHeroes);
EconomyRules.Apply(d, r, ids);
Assert.AreEqual(gold + r.gold, d.gold);
Assert.AreEqual(row.expBase, d.Hero(ids[0]).exp, "출전 전원 경험");
Assert.AreEqual(3, EconomyRules.StarsOf(d, s));
Assert.AreEqual(1, d.Stage(s.chapter, s.stage, false).clears);
Assert.IsTrue(EconomyRules.CanSweep(d, s));
var rep = EconomyRules.Compute(Db, d, s, true, false, row.starTurns + 1, false, rng);
Assert.IsFalse(rep.first);
Assert.AreEqual(Mathf.RoundToInt(baseGold * e.RepeatRewardMult), rep.gold, "반복 ×0.5");
Assert.AreEqual(Mathf.RoundToInt(row.expBase * e.RepeatRewardMult), rep.exp);
Assert.AreEqual(0, rep.gem);
Assert.AreEqual(1, rep.stars, "생존·턴 조건 미달 = 별 1");
EconomyRules.Apply(d, rep, ids);
Assert.AreEqual(3, EconomyRules.StarsOf(d, s), "최고 별 유지");
var sw = EconomyRules.Compute(Db, d, s, true, true, 0, true, rng);
Assert.IsTrue(sw.sweep);
Assert.IsFalse(sw.first);
Assert.AreEqual(rep.gold, sw.gold, "소탕 = 반복 보상");
var boss = FirstStage(StageKind.Boss);
var br = EconomyRules.Compute(Db, d, boss, true, true, 1, false, rng);
Assert.AreEqual(row.gemFirst + row.gemBoss, br.gem, "보스 첫 클리어 보옥");
Assert.IsNotNull(br.gearId, "보스 장비 확정");
Assert.LessOrEqual(Db.Gear.Get(br.gearId).order, Db.Gear.OrderOf(row.gearGradeMax), "등급 ≤ gear_grade_max");
Assert.LessOrEqual(Db.Gear.Get(br.gearId).dropFromChapter, boss.chapter);
var lose = EconomyRules.Compute(Db, d, boss, false, false, 3, false, rng);
Assert.AreEqual(0, lose.gold + lose.exp + lose.gem + lose.stars);
Assert.IsNull(lose.gearId);
}
[Test]
public void Save_RoundTrip_AndSanitize()
{
SaveStore.UseMemory(true);
var d = NewSave();
d.gold = 12345;
d.inv.AddGear(Db.Gear.items[0].id, 3);
d.Stage(1, 2, true).stars = 2;
d.presets[1].slots[0] = "";
d.gacha.pity = 7;
SaveStore.Write(d, T0);
var l = SaveStore.Load();
Assert.IsNotNull(l);
Assert.AreEqual(SaveStore.Version, l.ver);
Assert.AreEqual(JsonUtility.ToJson(d), JsonUtility.ToJson(l), "세이브 왕복 = 같은 JSON");
Assert.AreEqual(3, l.inv.GearCount(Db.Gear.items[0].id));
var broken = new SaveData { presets = null };
broken.heroes.Add(new HeroState { id = Db.Economy.StartHeroes[0], gear = null, grade = "" });
HeroRules.Sanitize(Db, broken, Cfg.playerSpawnPoints.Length);
Assert.AreEqual(Db.Economy.PresetCount, broken.presets.Count);
Assert.AreEqual(Cfg.playerSpawnPoints.Length, broken.presets[0].slots.Count);
Assert.AreEqual(HeroRules.GearSlotCount(Db), broken.heroes[0].gear.Count);
Assert.AreEqual(Db.Promotion.FirstGrade, broken.heroes[0].grade);
}
[Test]
public void Shop_DailySix_Buy_Refresh()
{
var e = Db.Economy;
var d = NewSave();
var rng = new System.Random(3);
EconomyRules.EnsureShop(Db, d, T0, rng);
Assert.AreEqual(e.ShopSlots, d.shop.items.Count);
var grades = new List(e.ShopGrades);
var seen = new HashSet();
foreach (var it in d.shop.items)
{
var g = Db.Gear.Get(it.gearId);
Assert.IsTrue(grades.Contains(g.grade), it.gearId);
Assert.IsTrue(seen.Add(it.gearId), "겹침 없음");
Assert.AreEqual(Mathf.RoundToInt(g.sellGold * e.ShopPriceMult), it.price);
}
int gold = d.gold;
var first = d.shop.items[0];
Assert.IsNull(EconomyRules.BuyShop(d, 0));
Assert.AreEqual(gold - first.price, d.gold);
Assert.AreEqual(1, d.inv.GearCount(first.gearId));
Assert.AreEqual("품절", EconomyRules.BuyShop(d, 0));
int gem = d.gem;
Assert.IsNull(EconomyRules.RefreshShop(Db, d, rng));
Assert.AreEqual(gem - e.ShopRefreshGem, d.gem);
foreach (var it in d.shop.items) Assert.IsFalse(it.sold);
var day = d.shop.day;
EconomyRules.EnsureShop(Db, d, T0.AddDays(1), rng);
Assert.AreNotEqual(day, d.shop.day, "다음 날 새로 깜");
}
// ---------- 씬(로비 · 편성 · 입장 · 결과) ----------
OutGameRoot og;
BattleManager bm;
WorldMapScreen wm;
IEnumerator Boot()
{
WorldProgress.Reset();
SaveStore.UseMemory(true);
SceneManager.LoadScene("Battle");
yield return null;
yield return null;
og = OutGameRoot.Instance;
bm = BattleManager.Instance;
wm = WorldMapScreen.Instance;
Assert.IsNotNull(og);
Assert.IsTrue(og.Session.IsNewUser, "메모리 세이브 = 신규 유저");
}
[UnityTest]
public IEnumerator Lobby_Tabs_Back_ComingSoon()
{
yield return Boot();
Assert.IsTrue(og.LobbyVisible);
og.OnTab(0);
Assert.AreEqual(og.HeroList, og.Current);
yield return null;
// PD 2026-11-10: 하단 탭 글자 = 선택 안 된 탭도 · 선택된 탭 다시 누르면 닫기(로비)
Assert.AreEqual(5, og.VisibleTabLabels, "하단 탭 글자 5개 모두 보임");
og.OnTab(0);
Assert.AreEqual(og.Lobby, og.Current, "선택된 탭 다시 = 닫기(로비)");
Assert.AreEqual(5, og.VisibleTabLabels, "로비에서도 탭 글자 보임");
og.OnTab(0);
Assert.AreEqual(og.HeroList, og.Current, "다시 열림");
yield return null;
Assert.AreEqual(og.Session.Owned(false).Count, og.HeroList.CardCount, "보유 장수 카드");
// 미보유 장수 = 빈 카드가 아니라 그 영웅 그림 카드(PD 2026-11-10 「유닛 목록에 장수들의 모습이 보이도록」)
int heroTotal = 0;
foreach (var h in og.Session.Db.Heroes.heroes)
if (!h.isSoldier) heroTotal++;
Assert.AreEqual(heroTotal - og.Session.Owned(false).Count, og.HeroList.LockedCount, "미보유 장수 카드 = 그림 카드");
Assert.Greater(og.HeroList.LockedCount, 0, "신규 유저 = 미보유 있음");
var lockedTail = og.HeroList.Rt.GetComponentsInChildren(true);
int lockedWithArt = 0;
foreach (var tr in lockedTail)
if (tr.name == "Locked" && tr.parent.Find("Bg/Character") != null) lockedWithArt++;
Assert.AreEqual(og.HeroList.LockedCount, lockedWithArt, "미보유 카드마다 일러(Character)");
og.HeroList.SetFilter(true);
yield return null;
Assert.AreEqual(og.Session.Owned(true).Count, og.HeroList.CardCount, "병사 필터");
var id = og.Session.Db.Economy.StartHeroes[0];
og.ShowHero(id);
Assert.AreEqual(og.HeroDetail, og.Current);
Assert.AreEqual(id, og.HeroDetail.HeroId);
og.Back();
Assert.AreEqual(og.HeroList, og.Current, "상세 ◀ = 목록");
og.Back();
Assert.IsTrue(og.LobbyVisible, "목록 ◀ = 로비");
og.OnTab(1);
Assert.AreEqual(og.Treasures, og.Current, "보물 = 수집품 화면(PD #1093 C)");
og.OnTab(2);
Assert.AreEqual(og.Domestic, og.Current, "내정 = 훈련·순찰·개발(PD #1093 C)");
og.Back();
Assert.IsTrue(og.LobbyVisible, "내정 ◀ = 로비");
og.OnTab(3);
Assert.IsTrue(og.ModalVisible, "동맹 = 추후 업데이트");
og.CloseModal();
og.OnTab(4);
Assert.AreEqual(og.Trade, og.Current);
og.Trade.SetPage(2);
yield return null;
Assert.AreEqual(og.Session.Db.Economy.ShopSlots, og.Trade.ShopCardCount, "장비 상점 칸");
og.Back();
Assert.IsTrue(og.LobbyVisible);
og.OpenWorldMap();
yield return null;
Assert.IsTrue(wm.IsVisible);
og.Back();
yield return null;
Assert.IsFalse(wm.IsVisible, "월드맵 ◀ = 로비");
Assert.IsTrue(og.LobbyVisible);
}
[UnityTest]
public IEnumerator Formation_PresetToSpawn_FullAndPartial()
{
yield return Boot();
var s = og.Session;
var cfg = bm.Config;
wm.EnterStage(0, 0);
yield return null;
Assert.AreEqual(s.Save.ActivePreset.slots.Count, bm.Deployed.Count, "프리셋 5 → 출전 5");
Assert.AreEqual(bm.Deployed.Count, bm.AliveCount(Faction.Player));
foreach (var u in bm.AliveUnits(Faction.Player))
{
LineupEntry e = null;
foreach (var x in bm.Deployed)
if (x.name == u.DisplayName) e = x;
Assert.IsNotNull(e, u.DisplayName);
Assert.Less(Vector2.Distance(cfg.playerSpawnPoints[e.slot], u.Position), 0.01f, u.DisplayName + " 자리 = 편성 순서");
var st = s.StatsOf(e.id);
Assert.AreEqual(st.Hp, u.MaxHp, u.DisplayName + " HP = §3-2 공식");
Assert.AreEqual(st.Atk, u.Atk, u.DisplayName + " 공");
Assert.AreEqual(st.Def, u.Def, u.DisplayName + " 방");
}
int front = s.Db.Economy.FormationFront;
for (int i = 0; i < cfg.playerSpawnPoints.Length; i++)
Assert.AreEqual(i < front ? cfg.playerSpawnPoints[0].y : cfg.playerSpawnPoints[cfg.playerSpawnPoints.Length - 1].y, cfg.playerSpawnPoints[i].y, 0.01f, "전열 앞줄 · 후열 뒷줄");
Assert.Greater(cfg.playerSpawnPoints[0].y, cfg.playerSpawnPoints[cfg.playerSpawnPoints.Length - 1].y, "전열이 적 쪽(위)");
var ids = s.Save.ActivePreset.slots;
var partial = new List();
for (int i = 0; i < ids.Count; i++) partial.Add(i == 1 || i == 3 ? ids[i] : "");
Assert.IsNull(s.SavePreset(1, partial));
Assert.AreEqual(1, s.Save.presetIdx, "저장 = 출전 프리셋");
bm.StartStage(0);
yield return null;
Assert.AreEqual(2, bm.AliveCount(Faction.Player), "빈 칸 허용");
foreach (var e in bm.Deployed) Assert.IsTrue(e.slot == 1 || e.slot == 3);
Assert.IsNotNull(s.SavePreset(2, new List { "", "", "", "", "" }), "최소 1");
// 편성 화면 조작: 카드 탭 = 빼기/넣기 · 자리 맞바꾸기 · 저장
og.ShowLobby();
og.Show(og.Formation);
var f = og.Formation;
Assert.AreEqual(1, f.Preset);
f.TapCard(ids[1]);
Assert.AreEqual(1, CountFilled(f.Working), "카드 탭 = 빼기");
f.TapCard(ids[1]);
Assert.AreEqual(2, CountFilled(f.Working), "다시 탭 = 빈 칸에 넣기");
string a0 = f.Working[0], a3 = f.Working[3];
f.Swap(0, 3);
Assert.AreEqual(a3, f.Working[0]);
Assert.AreEqual(a0, f.Working[3]);
f.Place(ids[4], 2);
Assert.AreEqual(ids[4], f.Working[2], "끌어 놓기 = 자리 넣기");
Assert.IsTrue(f.Dirty);
f.SaveNow();
Assert.IsFalse(f.Dirty);
Assert.AreEqual(ids[4], s.Save.presets[1].slots[2]);
}
static int CountFilled(IList l)
{
int n = 0;
foreach (var x in l)
if (!string.IsNullOrEmpty(x)) n++;
return n;
}
[UnityTest]
public IEnumerator WorldMap_StaminaEntry_Result_Stars_Sweep()
{
yield return Boot();
var s = og.Session;
var e = s.Db.Economy;
og.OpenWorldMap();
yield return null;
int si = wm.StageIndexOf(0, 0);
var stage = bm.Config.stages[si];
int cost = EconomyRules.StageCost(s.Db, stage);
s.Save.stamina = cost - 1;
wm.OpenPopup(0);
wm.ConfirmPopup();
yield return null;
Assert.IsTrue(wm.IsVisible, "군량 부족 = 출전 안 됨");
Assert.IsTrue(og.ModalVisible, "군량 충전 팝업");
Assert.AreEqual(BattlePhase.Setup, bm.Phase);
og.CloseModal();
s.Save.stamina = e.StaminaMax;
int gold = s.Save.gold;
int exp0 = s.Save.Hero(e.StartHeroes[0]).exp;
wm.OpenPopup(0);
wm.ConfirmPopup();
yield return null;
Assert.AreEqual(BattlePhase.PlayerTurn, bm.Phase);
Assert.AreEqual(e.StaminaMax - cost, s.Save.stamina, "스테이지 군량 소모");
foreach (var u in bm.AliveUnits(Faction.Enemy)) u.TakeDamage(u.Hp);
bm.CheckBattleEnd();
yield return null;
Assert.AreEqual(BattlePhase.StageClear, bm.Phase);
var r = s.LastReward;
Assert.IsNotNull(r);
Assert.AreEqual(3, r.stars, "1턴 · 전원 생존 = 별 3");
Assert.IsTrue(og.ResultVisible, "결과 화면 = 보상 패널");
Assert.AreEqual(gold + r.gold, s.Save.gold);
Assert.AreEqual(exp0 + r.exp, s.Save.Hero(e.StartHeroes[0]).exp, "출전 영웅 경험");
Assert.AreEqual(bm.Deployed.Count, r.expTo.Count);
bm.ContinueAfterResult();
yield return null;
Assert.IsTrue(wm.IsVisible);
Assert.AreEqual(3, wm.StarsOf(si));
Assert.AreEqual(NodeState.Clear, wm.StateOf(0, 0));
Assert.IsTrue(wm.CanSweep(si), "별 3 = 소탕");
int st = s.Save.stamina;
wm.Sweep(si);
Assert.AreEqual(st - cost, s.Save.stamina, "소탕 = 군량만");
Assert.AreEqual(2, s.Save.Stage(stage.chapter, stage.stage, false).clears);
Assert.IsTrue(s.LastReward.sweep);
Assert.AreEqual(BattlePhase.Setup, bm.Phase, "소탕 = 전투 없음");
}
}
}