From 0033e3566595f3df64175a5cc9b65cce603b5b51 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Thu, 19 Mar 2026 16:56:30 +0900 Subject: [PATCH] =?UTF-8?q?test:=20shop=5Fservice,=20arena=5Fservice,=20po?= =?UTF-8?q?tion=5Fservice=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shop_service 17개 (가격 계산, 장비 생성, 자동 구매) - arena_service 12개 (매칭, 장비 교환, 점수 계산) - potion_service 29개 (사용, 구매, 드랍, 긴급 선택) --- test/core/engine/arena_service_test.dart | 326 ++++++++++++++ test/core/engine/potion_service_test.dart | 510 ++++++++++++++++++++++ test/core/engine/shop_service_test.dart | 261 +++++++++++ 3 files changed, 1097 insertions(+) create mode 100644 test/core/engine/arena_service_test.dart create mode 100644 test/core/engine/potion_service_test.dart create mode 100644 test/core/engine/shop_service_test.dart diff --git a/test/core/engine/arena_service_test.dart b/test/core/engine/arena_service_test.dart new file mode 100644 index 0000000..902760d --- /dev/null +++ b/test/core/engine/arena_service_test.dart @@ -0,0 +1,326 @@ +import 'package:asciineverdie/src/core/engine/arena_service.dart'; +import 'package:asciineverdie/src/core/model/arena_match.dart'; +import 'package:asciineverdie/src/core/model/combat_stats.dart'; +import 'package:asciineverdie/src/core/model/equipment_item.dart'; +import 'package:asciineverdie/src/core/model/equipment_slot.dart'; +import 'package:asciineverdie/src/core/model/hall_of_fame.dart'; +import 'package:asciineverdie/src/core/model/item_stats.dart'; +import 'package:asciineverdie/src/core/util/deterministic_random.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late ArenaService service; + + setUp(() { + service = ArenaService(rng: DeterministicRandom(42)); + }); + + // ============================================================================ + // 상대 결정 (matchmaking) + // ============================================================================ + + group('findOpponent', () { + test('엔트리가 2개 미만이면 null 반환', () { + final hof = HallOfFame(entries: [_createEntry(id: 'a', level: 10)]); + final result = service.findOpponent(hof, 'a'); + expect(result, isNull); + }); + + test('1위 도전자는 2위와 대결', () { + // 점수(score) 높은 순: a(Lv50) > b(Lv30) > c(Lv10) + final hof = HallOfFame( + entries: [ + _createEntry(id: 'a', level: 50), + _createEntry(id: 'b', level: 30), + _createEntry(id: 'c', level: 10), + ], + ); + + final opponent = service.findOpponent(hof, 'a'); + expect(opponent, isNotNull); + expect(opponent!.id, 'b'); + }); + + test('2위 이하는 바로 위 순위와 대결', () { + final hof = HallOfFame( + entries: [ + _createEntry(id: 'a', level: 50), + _createEntry(id: 'b', level: 30), + _createEntry(id: 'c', level: 10), + ], + ); + + // c는 3위 → 2위(b)와 대결 + final opponent = service.findOpponent(hof, 'c'); + expect(opponent, isNotNull); + expect(opponent!.id, 'b'); + }); + + test('존재하지 않는 ID는 null 반환', () { + final hof = HallOfFame( + entries: [ + _createEntry(id: 'a', level: 50), + _createEntry(id: 'b', level: 30), + ], + ); + + final opponent = service.findOpponent(hof, 'nonexistent'); + expect(opponent, isNull); + }); + }); + + // ============================================================================ + // 장비 교환 (equipment exchange) + // ============================================================================ + + group('createResultFromSimulation', () { + test('도전자 승리 시 상대 베팅 슬롯 장비를 획득', () { + final challengerEquip = _createEquipmentList(helmName: 'Basic Helm'); + final opponentEquip = _createEquipmentList(helmName: 'Epic Helm'); + + final match = ArenaMatch( + challenger: _createEntry( + id: 'challenger', + level: 10, + equipment: challengerEquip, + ), + opponent: _createEntry( + id: 'opponent', + level: 10, + equipment: opponentEquip, + ), + // 도전자가 상대의 helm 슬롯을 노림 + challengerBettingSlot: EquipmentSlot.helm, + // 상대가 도전자의 shield 슬롯을 노림 + opponentBettingSlot: EquipmentSlot.shield, + ); + + final result = service.createResultFromSimulation( + match: match, + challengerHp: 50, + opponentHp: 0, + turns: 10, + ); + + // 도전자 승리 + expect(result.isVictory, isTrue); + + // 도전자가 상대의 helm(Epic Helm)을 획득 + final challengerHelm = result.updatedChallenger.finalEquipment! + .firstWhere((e) => e.slot == EquipmentSlot.helm); + expect(challengerHelm.name, 'Epic Helm'); + + // 상대의 helm은 기본 장비로 대체 + final opponentHelm = result.updatedOpponent.finalEquipment!.firstWhere( + (e) => e.slot == EquipmentSlot.helm, + ); + expect(opponentHelm.name, isNot('Epic Helm')); + }); + + test('도전자 패배 시 도전자 베팅 슬롯 장비를 상대가 획득', () { + final challengerEquip = _createEquipmentList(shieldName: 'Rare Shield'); + final opponentEquip = _createEquipmentList(shieldName: 'Old Shield'); + + final match = ArenaMatch( + challenger: _createEntry( + id: 'challenger', + level: 10, + equipment: challengerEquip, + ), + opponent: _createEntry( + id: 'opponent', + level: 10, + equipment: opponentEquip, + ), + challengerBettingSlot: EquipmentSlot.helm, + // 상대가 도전자의 shield를 노림 + opponentBettingSlot: EquipmentSlot.shield, + ); + + final result = service.createResultFromSimulation( + match: match, + challengerHp: 0, + opponentHp: 50, + turns: 10, + ); + + // 도전자 패배 + expect(result.isVictory, isFalse); + + // 상대가 도전자의 shield(Rare Shield)를 획득 + final opponentShield = result.updatedOpponent.finalEquipment!.firstWhere( + (e) => e.slot == EquipmentSlot.shield, + ); + expect(opponentShield.name, 'Rare Shield'); + + // 도전자의 shield는 기본 장비로 대체 + final challengerShield = result.updatedChallenger.finalEquipment! + .firstWhere((e) => e.slot == EquipmentSlot.shield); + expect(challengerShield.name, isNot('Rare Shield')); + }); + + test('양쪽 모두 HP > 0이면 패배 처리', () { + final match = _createSimpleMatch(); + final result = service.createResultFromSimulation( + match: match, + challengerHp: 50, + opponentHp: 50, + turns: 100, + ); + + // 도전자 HP > 0이지만 상대도 > 0이므로 패배 + expect(result.isVictory, isFalse); + }); + }); + + // ============================================================================ + // 베팅 슬롯 선택 (betting slot selection) + // ============================================================================ + + group('selectOpponentBettingSlot', () { + test('장비가 없으면 기본 슬롯(helm) 반환', () { + final entry = _createEntry(id: 'test', level: 5, equipment: []); + final slot = service.selectOpponentBettingSlot(entry); + expect(slot, EquipmentSlot.helm); + }); + + test('무기(weapon)는 약탈 대상에서 제외', () { + // 무기만 높은 점수(score)를 가진 장비 + final equipment = [ + const EquipmentItem( + name: 'God Keyboard', + slot: EquipmentSlot.weapon, + level: 99, + weight: 5, + stats: ItemStats(atk: 999), + rarity: ItemRarity.legendary, + ), + const EquipmentItem( + name: 'Basic Shield', + slot: EquipmentSlot.shield, + level: 1, + weight: 8, + stats: ItemStats(def: 1), + rarity: ItemRarity.common, + ), + ]; + + final entry = _createEntry(id: 'test', level: 50, equipment: equipment); + final slot = service.selectOpponentBettingSlot(entry); + + // 무기가 아닌 shield가 선택되어야 함 + expect(slot, isNot(EquipmentSlot.weapon)); + }); + }); + + group('getBettableSlots', () { + test('무기(weapon)를 제외한 슬롯 목록 반환', () { + final slots = service.getBettableSlots(); + expect(slots, isNot(contains(EquipmentSlot.weapon))); + expect(slots.length, EquipmentSlot.values.length - 1); + }); + }); + + // ============================================================================ + // 아레나 점수 계산 (arena score) + // ============================================================================ + + group('HallOfFameArenaX.calculateArenaScore', () { + test('레벨이 높을수록 점수가 높다', () { + final low = _createEntry(id: 'low', level: 5); + final high = _createEntry(id: 'high', level: 50); + + final lowScore = HallOfFameArenaX.calculateArenaScore(low); + final highScore = HallOfFameArenaX.calculateArenaScore(high); + + expect(highScore, greaterThan(lowScore)); + }); + + test('장비가 좋을수록 점수가 높다', () { + final weak = _createEntry(id: 'weak', level: 10); + final strong = _createEntry( + id: 'strong', + level: 10, + equipment: [ + const EquipmentItem( + name: 'Epic Sword', + slot: EquipmentSlot.weapon, + level: 50, + weight: 5, + stats: ItemStats(atk: 100), + rarity: ItemRarity.epic, + ), + ], + ); + + final weakScore = HallOfFameArenaX.calculateArenaScore(weak); + final strongScore = HallOfFameArenaX.calculateArenaScore(strong); + + expect(strongScore, greaterThan(weakScore)); + }); + }); +} + +// ============================================================================ +// 테스트 헬퍼 (test helpers) +// ============================================================================ + +/// 테스트용 HallOfFameEntry 생성 +HallOfFameEntry _createEntry({ + required String id, + required int level, + List? equipment, + CombatStats? stats, +}) { + return HallOfFameEntry( + id: id, + characterName: 'Test Character $id', + race: 'Human', + klass: 'Fighter', + level: level, + totalPlayTimeMs: 3600000, + totalDeaths: 0, + monstersKilled: 100, + questsCompleted: 10, + clearedAt: DateTime(2025, 1, 1), + finalStats: stats, + finalEquipment: equipment, + ); +} + +/// 테스트용 장비 목록 생성 (helm, shield 이름 커스텀 가능) +List _createEquipmentList({ + String helmName = 'Test Helm', + String shieldName = 'Test Shield', +}) { + return [ + EquipmentItem.defaultWeapon(), + EquipmentItem( + name: shieldName, + slot: EquipmentSlot.shield, + level: 5, + weight: 8, + stats: const ItemStats(def: 5), + rarity: ItemRarity.common, + ), + EquipmentItem( + name: helmName, + slot: EquipmentSlot.helm, + level: 5, + weight: 4, + stats: const ItemStats(def: 3), + rarity: ItemRarity.common, + ), + ]; +} + +/// 간단한 ArenaMatch 생성 +ArenaMatch _createSimpleMatch() { + final equip = _createEquipmentList(); + return ArenaMatch( + challenger: _createEntry(id: 'c', level: 10, equipment: equip), + opponent: _createEntry(id: 'o', level: 10, equipment: equip), + challengerBettingSlot: EquipmentSlot.helm, + opponentBettingSlot: EquipmentSlot.shield, + ); +} diff --git a/test/core/engine/potion_service_test.dart b/test/core/engine/potion_service_test.dart new file mode 100644 index 0000000..f63b58a --- /dev/null +++ b/test/core/engine/potion_service_test.dart @@ -0,0 +1,510 @@ +import 'package:asciineverdie/data/potion_data.dart'; +import 'package:asciineverdie/src/core/engine/potion_service.dart'; +import 'package:asciineverdie/src/core/model/monster_grade.dart'; +import 'package:asciineverdie/src/core/model/potion.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const service = PotionService(); + + // ============================================================================ + // 물약 사용 가능 여부 (canUsePotion) + // ============================================================================ + + group('canUsePotion', () { + test('보유 물약이 있으면 사용 가능', () { + final inventory = const PotionInventory().addPotion( + PotionData.minorHealthPatch.id, + 3, + ); + final (canUse, reason) = service.canUsePotion( + PotionData.minorHealthPatch.id, + inventory, + ); + expect(canUse, isTrue); + expect(reason, isNull); + }); + + test('존재하지 않는 물약 ID는 potionNotFound', () { + final (canUse, reason) = service.canUsePotion( + 'nonexistent_potion', + PotionInventory.empty, + ); + expect(canUse, isFalse); + expect(reason, PotionUseFailReason.potionNotFound); + }); + + test('보유 수량이 0이면 outOfStock', () { + final (canUse, reason) = service.canUsePotion( + PotionData.minorHealthPatch.id, + PotionInventory.empty, + ); + expect(canUse, isFalse); + expect(reason, PotionUseFailReason.outOfStock); + }); + }); + + // ============================================================================ + // 물약 사용 (usePotion) + // ============================================================================ + + group('usePotion', () { + test('HP 물약 사용 시 HP가 회복된다', () { + final potion = PotionData.minorHealthPatch; // 고정 30, 비율 0% + final inventory = PotionInventory.empty.addPotion(potion.id); + + final result = service.usePotion( + potionId: potion.id, + inventory: inventory, + currentHp: 50, + maxHp: 100, + currentMp: 30, + maxMp: 50, + ); + + expect(result.success, isTrue); + expect(result.newHp, 80); // 50 + 30 = 80 + expect(result.healedAmount, 30); + // MP는 변하지 않음 + expect(result.newMp, 30); + // 인벤토리에서 수량 감소 + expect(result.newInventory!.hasPotion(potion.id), isFalse); + }); + + test('HP 물약은 maxHp를 초과하지 않는다', () { + final potion = PotionData.minorHealthPatch; + final inventory = PotionInventory.empty.addPotion(potion.id); + + final result = service.usePotion( + potionId: potion.id, + inventory: inventory, + currentHp: 90, + maxHp: 100, + currentMp: 50, + maxMp: 50, + ); + + expect(result.success, isTrue); + expect(result.newHp, 100); // 90 + 30 = 120 → clamp → 100 + expect(result.healedAmount, 10); // 실제 회복량 = 100 - 90 + }); + + test('MP 물약 사용 시 MP가 회복된다', () { + final potion = PotionData.minorManaCache; // 고정 20, 비율 0% + final inventory = PotionInventory.empty.addPotion(potion.id); + + final result = service.usePotion( + potionId: potion.id, + inventory: inventory, + currentHp: 100, + maxHp: 100, + currentMp: 10, + maxMp: 50, + ); + + expect(result.success, isTrue); + expect(result.newMp, 30); // 10 + 20 = 30 + expect(result.healedAmount, 20); + // HP는 변하지 않음 + expect(result.newHp, 100); + }); + + test('비율 회복(percent heal) 물약은 최대치 비례로 회복', () { + final potion = PotionData.healthPatch; // 고정 50, 비율 10% + final inventory = PotionInventory.empty.addPotion(potion.id); + + final result = service.usePotion( + potionId: potion.id, + inventory: inventory, + currentHp: 100, + maxHp: 1000, + currentMp: 50, + maxMp: 50, + ); + + // 고정 50 + (1000 * 0.10) = 50 + 100 = 150 + expect(result.success, isTrue); + expect(result.newHp, 250); // 100 + 150 + expect(result.healedAmount, 150); + }); + + test('healingMultiplier 적용 시 회복량 증가', () { + final potion = PotionData.minorHealthPatch; // 고정 30 + final inventory = PotionInventory.empty.addPotion(potion.id); + + final result = service.usePotion( + potionId: potion.id, + inventory: inventory, + currentHp: 50, + maxHp: 200, + currentMp: 50, + maxMp: 50, + healingMultiplier: 1.5, // +50% 회복력 보너스(bonus) + ); + + // 30 * 1.5 = 45 + expect(result.success, isTrue); + expect(result.newHp, 95); // 50 + 45 + expect(result.healedAmount, 45); + }); + + test('보유 물약이 없으면 실패', () { + final result = service.usePotion( + potionId: PotionData.minorHealthPatch.id, + inventory: PotionInventory.empty, + currentHp: 50, + maxHp: 100, + currentMp: 30, + maxMp: 50, + ); + + expect(result.success, isFalse); + expect(result.failReason, PotionUseFailReason.outOfStock); + }); + }); + + // ============================================================================ + // 물약 구매 (purchasePotion) + // ============================================================================ + + group('purchasePotion', () { + test('골드가 충분하면 구매 성공', () { + final potion = PotionData.minorHealthPatch; // 가격 25 + final result = service.purchasePotion( + potionId: potion.id, + inventory: PotionInventory.empty, + gold: 100, + ); + + expect(result.success, isTrue); + expect(result.totalCost, 25); + expect(result.newGold, 75); + expect(result.newInventory!.getQuantity(potion.id), 1); + }); + + test('복수 구매 시 총 비용 정확 계산', () { + final potion = PotionData.minorHealthPatch; // 가격 25 + final result = service.purchasePotion( + potionId: potion.id, + inventory: PotionInventory.empty, + gold: 1000, + count: 5, + ); + + expect(result.success, isTrue); + expect(result.totalCost, 125); // 25 * 5 + expect(result.newGold, 875); + expect(result.newInventory!.getQuantity(potion.id), 5); + }); + + test('골드 부족 시 구매 실패', () { + final potion = PotionData.ultraHealthPatch; // 가격 1200 + final result = service.purchasePotion( + potionId: potion.id, + inventory: PotionInventory.empty, + gold: 100, + ); + + expect(result.success, isFalse); + expect(result.failReason, PotionPurchaseFailReason.insufficientGold); + }); + + test('존재하지 않는 물약 ID는 구매 실패', () { + final result = service.purchasePotion( + potionId: 'invalid_potion', + inventory: PotionInventory.empty, + gold: 99999, + ); + + expect(result.success, isFalse); + expect(result.failReason, PotionPurchaseFailReason.potionNotFound); + }); + }); + + // ============================================================================ + // canPurchasePotion - 구매 가능 여부 체크 + // ============================================================================ + + group('canPurchasePotion', () { + test('골드 충분 시 구매 가능', () { + final (canBuy, reason) = service.canPurchasePotion( + PotionData.minorHealthPatch.id, + 100, + ); + expect(canBuy, isTrue); + expect(reason, isNull); + }); + + test('골드 부족 시 insufficientGold', () { + final (canBuy, reason) = service.canPurchasePotion( + PotionData.ultraHealthPatch.id, + 10, + ); + expect(canBuy, isFalse); + expect(reason, PotionPurchaseFailReason.insufficientGold); + }); + }); + + // ============================================================================ + // 자동 구매 (autoPurchasePotions) + // ============================================================================ + + group('autoPurchasePotions', () { + test('레벨에 맞는 티어(tier) 물약을 자동 구매', () { + final result = service.autoPurchasePotions( + playerLevel: 5, // 티어 1 + inventory: PotionInventory.empty, + gold: 10000, + ); + + expect(result.success, isTrue); + expect(result.quantity, greaterThan(0)); + expect(result.totalCost, greaterThan(0)); + // 사용 골드 비율은 20% 이내 + expect(result.totalCost, lessThanOrEqualTo(2000)); + }); + + test('골드가 0이면 구매 실패', () { + final result = service.autoPurchasePotions( + playerLevel: 5, + inventory: PotionInventory.empty, + gold: 0, + ); + + expect(result.success, isFalse); + }); + }); + + // ============================================================================ + // 물약 드랍 (potion drop) + // ============================================================================ + + group('tryPotionDrop', () { + test('roll이 드랍 확률(drop chance) 미만이면 물약 드랍', () { + final (inventory, potion) = service.tryPotionDrop( + playerLevel: 10, + monsterLevel: 10, + monsterGrade: MonsterGrade.normal, + inventory: PotionInventory.empty, + roll: 0, // 항상 드랍 성공 + typeRoll: 30, // HP 물약 (< 60) + ); + + expect(potion, isNotNull); + expect(potion!.isHpPotion, isTrue); + expect(inventory.hasPotion(potion.id), isTrue); + }); + + test('roll이 드랍 확률 이상이면 드랍 실패', () { + final (inventory, potion) = service.tryPotionDrop( + playerLevel: 1, + monsterLevel: 1, + monsterGrade: MonsterGrade.normal, + inventory: PotionInventory.empty, + roll: 99, // 거의 무조건 실패 + typeRoll: 30, + ); + + expect(potion, isNull); + // 인벤토리 변화 없음 + expect(inventory.potions, isEmpty); + }); + + test('typeRoll >= 60이면 MP 물약 드랍', () { + final (_, potion) = service.tryPotionDrop( + playerLevel: 10, + monsterLevel: 10, + monsterGrade: MonsterGrade.normal, + inventory: PotionInventory.empty, + roll: 0, + typeRoll: 70, // MP 물약 (>= 60) + ); + + expect(potion, isNotNull); + expect(potion!.isMpPotion, isTrue); + }); + + test('보스(boss) 몬스터는 드랍 확률 +15%', () { + // 일반 몬스터 Lv1 기본 드랍 확률 = 15% + // 보스 보너스 +15% → 총 30% + + // roll=25: 일반은 실패(25 >= 15), 보스는 성공(25 < 30) + final (_, normalPotion) = service.tryPotionDrop( + playerLevel: 1, + monsterLevel: 1, + monsterGrade: MonsterGrade.normal, + inventory: PotionInventory.empty, + roll: 25, + typeRoll: 30, + ); + + final (_, bossPotion) = service.tryPotionDrop( + playerLevel: 1, + monsterLevel: 1, + monsterGrade: MonsterGrade.boss, + inventory: PotionInventory.empty, + roll: 25, + typeRoll: 30, + ); + + expect(normalPotion, isNull); + expect(bossPotion, isNotNull); + }); + + test('몬스터 레벨 > 플레이어 레벨이면 추가 확률 부여', () { + // 기본 15% + 레벨 차이 10 * 1% = 25% + // roll=20: 레벨 차이 없으면 실패(20 >= 15), 있으면 성공(20 < 25) + final (_, withoutDiffPotion) = service.tryPotionDrop( + playerLevel: 10, + monsterLevel: 10, + monsterGrade: MonsterGrade.normal, + inventory: PotionInventory.empty, + roll: 20, + typeRoll: 30, + ); + + final (_, withDiffPotion) = service.tryPotionDrop( + playerLevel: 10, + monsterLevel: 20, + monsterGrade: MonsterGrade.normal, + inventory: PotionInventory.empty, + roll: 20, + typeRoll: 30, + ); + + // Lv10 기본 확률 = 15 + 10*0.5 = 20% → roll 20 >= 20 → 실패 + expect(withoutDiffPotion, isNull); + // 레벨 차이 10 → 추가 10% → 30% → roll 20 < 30 → 성공 + expect(withDiffPotion, isNotNull); + }); + + test('기존 인벤토리에 물약 추가', () { + final existingInventory = PotionInventory.empty.addPotion( + PotionData.minorHealthPatch.id, + 5, + ); + + final (inventory, potion) = service.tryPotionDrop( + playerLevel: 1, + monsterLevel: 1, + monsterGrade: MonsterGrade.normal, + inventory: existingInventory, + roll: 0, // 드랍 성공 + typeRoll: 30, // HP 물약 + ); + + expect(potion, isNotNull); + // 기존 5개 + 새로 1개 + final droppedId = potion!.id; + if (droppedId == PotionData.minorHealthPatch.id) { + expect(inventory.getQuantity(droppedId), 6); + } else { + // 다른 티어(tier) 물약이 드랍될 수 있음 + expect(inventory.getQuantity(droppedId), 1); + expect(inventory.getQuantity(PotionData.minorHealthPatch.id), 5); + } + }); + }); + + // ============================================================================ + // 인벤토리 관리 (inventory management) + // ============================================================================ + + group('addPotionDrop', () { + test('물약 드랍 추가 시 수량 증가', () { + final result = service.addPotionDrop( + PotionInventory.empty, + PotionData.minorHealthPatch.id, + 3, + ); + expect(result.getQuantity(PotionData.minorHealthPatch.id), 3); + }); + }); + + // ============================================================================ + // 긴급 물약 선택 (emergency potion selection) + // ============================================================================ + + group('selectEmergencyHpPotion', () { + test('HP 손실이 회복량 이상이면 물약 선택', () { + final inventory = PotionInventory.empty.addPotion( + PotionData.minorHealthPatch.id, + 5, + ); + + // maxHp=100, currentHp=50 → 손실 50 >= 회복량 30 + final potion = service.selectEmergencyHpPotion( + currentHp: 50, + maxHp: 100, + inventory: inventory, + playerLevel: 5, + ); + + expect(potion, isNotNull); + expect(potion!.id, PotionData.minorHealthPatch.id); + }); + + test('HP가 만충(full)이면 null', () { + final inventory = PotionInventory.empty.addPotion( + PotionData.minorHealthPatch.id, + 5, + ); + + final potion = service.selectEmergencyHpPotion( + currentHp: 100, + maxHp: 100, + inventory: inventory, + playerLevel: 5, + ); + + expect(potion, isNull); + }); + + test('보유 물약이 없으면 null', () { + final potion = service.selectEmergencyHpPotion( + currentHp: 10, + maxHp: 100, + inventory: PotionInventory.empty, + playerLevel: 5, + ); + + expect(potion, isNull); + }); + }); + + group('selectEmergencyMpPotion', () { + test('MP 손실이 회복량 이상이면 물약 선택', () { + final inventory = PotionInventory.empty.addPotion( + PotionData.minorManaCache.id, + 5, + ); + + // maxMp=100, currentMp=50 → 손실 50 >= 회복량 20 + final potion = service.selectEmergencyMpPotion( + currentMp: 50, + maxMp: 100, + inventory: inventory, + playerLevel: 5, + ); + + expect(potion, isNotNull); + expect(potion!.id, PotionData.minorManaCache.id); + }); + + test('MP가 만충(full)이면 null', () { + final inventory = PotionInventory.empty.addPotion( + PotionData.minorManaCache.id, + 5, + ); + + final potion = service.selectEmergencyMpPotion( + currentMp: 50, + maxMp: 50, + inventory: inventory, + playerLevel: 5, + ); + + expect(potion, isNull); + }); + }); +} diff --git a/test/core/engine/shop_service_test.dart b/test/core/engine/shop_service_test.dart new file mode 100644 index 0000000..b6aae46 --- /dev/null +++ b/test/core/engine/shop_service_test.dart @@ -0,0 +1,261 @@ +import 'package:asciineverdie/src/core/engine/shop_service.dart'; +import 'package:asciineverdie/src/core/model/equipment_container.dart'; +import 'package:asciineverdie/src/core/model/equipment_item.dart'; +import 'package:asciineverdie/src/core/model/equipment_slot.dart'; +import 'package:asciineverdie/src/core/model/item_stats.dart'; +import 'package:asciineverdie/src/core/util/deterministic_random.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late ShopService service; + + setUp(() { + // 결정론적 시드(deterministic seed)로 재현 가능한 테스트 + service = ShopService(rng: DeterministicRandom(42)); + }); + + // ============================================================================ + // 가격 계산 (price calculation) + // ============================================================================ + + group('calculateBuyPrice', () { + test('빈 아이템(empty item)은 가격 0', () { + final emptyItem = EquipmentItem.empty(EquipmentSlot.helm); + expect(service.calculateBuyPrice(emptyItem), 0); + }); + + test('Common 아이템 가격 = 레벨 * 50 * 1.0', () { + const item = EquipmentItem( + name: 'Test Helm', + slot: EquipmentSlot.helm, + level: 10, + weight: 4, + stats: ItemStats(def: 5), + rarity: ItemRarity.common, + ); + // 10 * 50 * 1.0 = 500 + expect(service.calculateBuyPrice(item), 500); + }); + + test('Rare 아이템 가격 = 레벨 * 50 * 5.0', () { + const item = EquipmentItem( + name: 'Superior Helm', + slot: EquipmentSlot.helm, + level: 10, + weight: 4, + stats: ItemStats(def: 10), + rarity: ItemRarity.rare, + ); + // 10 * 50 * 5.0 = 2500 + expect(service.calculateBuyPrice(item), 2500); + }); + + test('Legendary 아이템 가격 = 레벨 * 50 * 50.0', () { + const item = EquipmentItem( + name: 'Legendary Keyboard', + slot: EquipmentSlot.weapon, + level: 5, + weight: 5, + stats: ItemStats(atk: 30), + rarity: ItemRarity.legendary, + ); + // 5 * 50 * 50.0 = 12500 + expect(service.calculateBuyPrice(item), 12500); + }); + }); + + group('calculateSellPrice', () { + test('판매 가격 = 구매 가격 * 0.3', () { + const item = EquipmentItem( + name: 'Test Shield', + slot: EquipmentSlot.shield, + level: 10, + weight: 8, + stats: ItemStats(def: 5), + rarity: ItemRarity.common, + ); + final buyPrice = service.calculateBuyPrice(item); // 500 + final sellPrice = service.calculateSellPrice(item); + expect(sellPrice, (buyPrice * 0.3).round()); + }); + }); + + // ============================================================================ + // 장비 생성 (item generation) + // ============================================================================ + + group('generateShopItem', () { + test('생성된 아이템의 슬롯(slot)이 요청과 일치', () { + final item = service.generateShopItem( + playerLevel: 10, + slot: EquipmentSlot.helm, + ); + expect(item.slot, EquipmentSlot.helm); + expect(item.isNotEmpty, isTrue); + }); + + test('아이템 레벨은 플레이어 레벨 ±2 범위 이내', () { + // 여러 시드(seed)로 반복 검증 + for (var seed = 0; seed < 50; seed++) { + final svc = ShopService(rng: DeterministicRandom(seed)); + final item = svc.generateShopItem( + playerLevel: 10, + slot: EquipmentSlot.weapon, + ); + expect(item.level, inInclusiveRange(8, 12)); + } + }); + + test('targetRarity 지정 시 해당 희귀도로 생성', () { + final item = service.generateShopItem( + playerLevel: 5, + slot: EquipmentSlot.shield, + targetRarity: ItemRarity.rare, + ); + expect(item.rarity, ItemRarity.rare); + }); + + test('무기(weapon) 슬롯은 atk 스탯을 가진다', () { + final item = service.generateShopItem( + playerLevel: 10, + slot: EquipmentSlot.weapon, + targetRarity: ItemRarity.common, + ); + expect(item.stats.atk, greaterThan(0)); + }); + + test('방패(shield) 슬롯은 def 스탯을 가진다', () { + final item = service.generateShopItem( + playerLevel: 10, + slot: EquipmentSlot.shield, + targetRarity: ItemRarity.common, + ); + expect(item.stats.def, greaterThan(0)); + }); + + test('레벨 1에서도 최소 레벨 1의 아이템 생성', () { + final item = service.generateShopItem( + playerLevel: 1, + slot: EquipmentSlot.helm, + ); + expect(item.level, greaterThanOrEqualTo(1)); + }); + }); + + // ============================================================================ + // buyItem - 단일 구매 (single purchase) + // ============================================================================ + + group('buyItem', () { + test('골드가 충분하면 구매 성공', () { + final result = service.buyItem( + playerLevel: 5, + currentGold: 100000, + slot: EquipmentSlot.helm, + ); + expect(result, isNotNull); + expect(result!.item.slot, EquipmentSlot.helm); + expect(result.price, greaterThan(0)); + expect(result.remainingGold, 100000 - result.price); + }); + + test('골드가 부족하면 null 반환', () { + final result = service.buyItem( + playerLevel: 50, + currentGold: 0, + slot: EquipmentSlot.hauberk, + preferredRarity: ItemRarity.epic, + ); + expect(result, isNull); + }); + }); + + // ============================================================================ + // sellItem - 장비 판매 (sell) + // ============================================================================ + + group('sellItem', () { + test('판매 시 골드가 증가한다', () { + const item = EquipmentItem( + name: 'Old Shield', + slot: EquipmentSlot.shield, + level: 5, + weight: 8, + stats: ItemStats(def: 3), + rarity: ItemRarity.common, + ); + final result = service.sellItem(item, 1000); + + expect(result.price, greaterThan(0)); + expect(result.newGold, 1000 + result.price); + expect(result.item, item); + }); + }); + + // ============================================================================ + // autoBuyForEmptySlots - 빈 슬롯 자동 구매 (auto-buy) + // ============================================================================ + + group('autoBuyForEmptySlots', () { + test('빈 슬롯이 있으면 자동으로 구매한다', () { + // 빈 장비(empty equipment)에서 시작 + final emptyEquipment = Equipment.empty(); + final result = service.autoBuyForEmptySlots( + playerLevel: 5, + currentGold: 100000, + currentEquipment: emptyEquipment, + ); + + // 무기(weapon)는 기본 장비가 있으므로 나머지 10개 슬롯 구매 시도 + expect(result.purchasedCount, greaterThan(0)); + expect(result.totalCost, greaterThan(0)); + expect(result.remainingGold, 100000 - result.totalCost); + }); + + test('골드가 0이면 구매하지 않는다', () { + final emptyEquipment = Equipment.empty(); + final result = service.autoBuyForEmptySlots( + playerLevel: 5, + currentGold: 0, + currentEquipment: emptyEquipment, + ); + + expect(result.purchasedCount, 0); + expect(result.totalCost, 0); + }); + + test('모든 슬롯이 차있으면 구매하지 않는다', () { + // 모든 슬롯에 장비가 있는 상태 + final fullEquipment = _createFullEquipment(); + final result = service.autoBuyForEmptySlots( + playerLevel: 5, + currentGold: 100000, + currentEquipment: fullEquipment, + ); + + expect(result.purchasedCount, 0); + expect(result.totalCost, 0); + }); + }); +} + +/// 테스트용 풀 장비 생성 헬퍼 +Equipment _createFullEquipment() { + return Equipment( + items: [ + EquipmentItem.defaultWeapon(), + ...List.generate( + Equipment.slotCount - 1, + (i) => EquipmentItem( + name: 'Test Item ${i + 1}', + slot: EquipmentSlot.values[i + 1], + level: 5, + weight: 3, + stats: const ItemStats(def: 5), + rarity: ItemRarity.common, + ), + ), + ], + bestIndex: 0, + ); +}