test: 저장 무결성 + Model 직렬화 라운드트립 테스트 추가
- save_integrity_test: 13개 (sign/verify 라운드트립, 변조 감지, 레거시 호환) - save_data_roundtrip_test: 16개 (toJson/fromJson, v2→v4 마이그레이션, 기본값)
This commit is contained in:
568
test/core/model/save_data_roundtrip_test.dart
Normal file
568
test/core/model/save_data_roundtrip_test.dart
Normal file
@@ -0,0 +1,568 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
|
||||
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/model/monetization_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/monster_grade.dart';
|
||||
import 'package:asciineverdie/src/core/model/save_data.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('GameSave 직렬화(serialization) 라운드트립', () {
|
||||
// =========================================================================
|
||||
// 헬퍼: 모든 필드가 채워진 GameSave 생성
|
||||
// =========================================================================
|
||||
|
||||
GameSave _createFullSave() {
|
||||
return GameSave(
|
||||
version: kSaveVersion,
|
||||
rngState: 12345,
|
||||
cheatsEnabled: true,
|
||||
traits: const Traits(
|
||||
name: 'TestHero',
|
||||
race: 'Kernel Giant',
|
||||
klass: 'Bug Hunter',
|
||||
level: 42,
|
||||
motto: 'Never Die',
|
||||
guild: 'ASCII Warriors',
|
||||
raceId: 'kernel_giant',
|
||||
classId: 'bug_hunter',
|
||||
),
|
||||
stats: const Stats(
|
||||
str: 18,
|
||||
con: 16,
|
||||
dex: 14,
|
||||
intelligence: 12,
|
||||
wis: 10,
|
||||
cha: 8,
|
||||
hpMax: 200,
|
||||
mpMax: 100,
|
||||
hpCurrent: 150,
|
||||
mpCurrent: 75,
|
||||
),
|
||||
inventory: const Inventory(
|
||||
gold: 9999,
|
||||
items: [
|
||||
InventoryEntry(name: 'Goblin Ear', count: 5),
|
||||
InventoryEntry(name: 'Dragon Scale', count: 1),
|
||||
],
|
||||
),
|
||||
equipment: Equipment(
|
||||
items: [
|
||||
const EquipmentItem(
|
||||
name: 'Flaming Sword',
|
||||
slot: EquipmentSlot.weapon,
|
||||
level: 10,
|
||||
weight: 15,
|
||||
stats: ItemStats(atk: 50, criRate: 0.1),
|
||||
rarity: ItemRarity.epic,
|
||||
),
|
||||
const EquipmentItem(
|
||||
name: 'Tower Shield',
|
||||
slot: EquipmentSlot.shield,
|
||||
level: 8,
|
||||
weight: 20,
|
||||
stats: ItemStats(def: 30, blockRate: 0.2),
|
||||
rarity: ItemRarity.rare,
|
||||
),
|
||||
EquipmentItem.empty(EquipmentSlot.helm),
|
||||
EquipmentItem.empty(EquipmentSlot.hauberk),
|
||||
EquipmentItem.empty(EquipmentSlot.brassairts),
|
||||
EquipmentItem.empty(EquipmentSlot.vambraces),
|
||||
EquipmentItem.empty(EquipmentSlot.gauntlets),
|
||||
EquipmentItem.empty(EquipmentSlot.gambeson),
|
||||
EquipmentItem.empty(EquipmentSlot.cuisses),
|
||||
EquipmentItem.empty(EquipmentSlot.greaves),
|
||||
EquipmentItem.empty(EquipmentSlot.sollerets),
|
||||
],
|
||||
bestIndex: 0,
|
||||
),
|
||||
skillBook: const SkillBook(
|
||||
skills: [
|
||||
SkillEntry(name: 'Hack', rank: 'III'),
|
||||
SkillEntry(name: 'Slash', rank: 'V'),
|
||||
],
|
||||
),
|
||||
progress: ProgressState(
|
||||
task: const ProgressBarState(position: 50, max: 100),
|
||||
quest: const ProgressBarState(position: 3, max: 10),
|
||||
plot: const ProgressBarState(position: 1, max: 5),
|
||||
exp: const ProgressBarState(position: 500, max: 1000),
|
||||
encumbrance: const ProgressBarState(position: 30, max: 100),
|
||||
currentTask: const TaskInfo(
|
||||
caption: 'Executing a Goblin',
|
||||
type: TaskType.kill,
|
||||
monsterBaseName: 'Goblin',
|
||||
monsterPart: 'ear',
|
||||
monsterLevel: 5,
|
||||
monsterGrade: MonsterGrade.elite,
|
||||
),
|
||||
plotStageCount: 3,
|
||||
questCount: 7,
|
||||
plotHistory: const [
|
||||
HistoryEntry(caption: 'Prologue', isComplete: true),
|
||||
HistoryEntry(caption: 'Act I', isComplete: false),
|
||||
],
|
||||
questHistory: const [
|
||||
HistoryEntry(caption: 'Exterminate Goblins', isComplete: true),
|
||||
],
|
||||
currentQuestMonster: const QuestMonsterInfo(
|
||||
monsterData: 'Goblin|3|ear',
|
||||
monsterIndex: 2,
|
||||
),
|
||||
monstersKilled: 150,
|
||||
deathCount: 3,
|
||||
),
|
||||
queue: QueueState(
|
||||
entries: Queue<QueueEntry>.from(const [
|
||||
QueueEntry(
|
||||
kind: QueueKind.task,
|
||||
durationMillis: 2000,
|
||||
caption: 'Heading to market',
|
||||
taskType: TaskType.market,
|
||||
),
|
||||
]),
|
||||
),
|
||||
monetization: const MonetizationState(
|
||||
adRemovalPurchased: true,
|
||||
rollsRemaining: 3,
|
||||
undoRemaining: 2,
|
||||
pendingChests: 4,
|
||||
autoReviveEndMs: 50000,
|
||||
speedBoostEndMs: 80000,
|
||||
luckyCharmEndMs: 60000,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// toJson → fromJson 라운드트립 테스트
|
||||
// =========================================================================
|
||||
|
||||
test('toJson() → fromJson() 라운드트립 — 모든 필드 보존', () {
|
||||
final original = _createFullSave();
|
||||
// jsonEncode → jsonDecode로 순수 Map 변환 (freezed 객체 제거)
|
||||
final json = jsonDecode(jsonEncode(original.toJson()))
|
||||
as Map<String, dynamic>;
|
||||
final restored = GameSave.fromJson(json);
|
||||
|
||||
// traits 검증
|
||||
expect(restored.traits.name, equals('TestHero'));
|
||||
expect(restored.traits.race, equals('Kernel Giant'));
|
||||
expect(restored.traits.klass, equals('Bug Hunter'));
|
||||
expect(restored.traits.level, equals(42));
|
||||
expect(restored.traits.motto, equals('Never Die'));
|
||||
expect(restored.traits.guild, equals('ASCII Warriors'));
|
||||
expect(restored.traits.raceId, equals('kernel_giant'));
|
||||
expect(restored.traits.classId, equals('bug_hunter'));
|
||||
|
||||
// stats 검증
|
||||
expect(restored.stats.str, equals(18));
|
||||
expect(restored.stats.con, equals(16));
|
||||
expect(restored.stats.dex, equals(14));
|
||||
expect(restored.stats.intelligence, equals(12));
|
||||
expect(restored.stats.wis, equals(10));
|
||||
expect(restored.stats.cha, equals(8));
|
||||
expect(restored.stats.hpMax, equals(200));
|
||||
expect(restored.stats.mpMax, equals(100));
|
||||
expect(restored.stats.hpCurrent, equals(150));
|
||||
expect(restored.stats.mpCurrent, equals(75));
|
||||
|
||||
// inventory 검증
|
||||
expect(restored.inventory.gold, equals(9999));
|
||||
expect(restored.inventory.items.length, equals(2));
|
||||
expect(restored.inventory.items[0].name, equals('Goblin Ear'));
|
||||
expect(restored.inventory.items[0].count, equals(5));
|
||||
expect(restored.inventory.items[1].name, equals('Dragon Scale'));
|
||||
expect(restored.inventory.items[1].count, equals(1));
|
||||
|
||||
// equipment 검증
|
||||
expect(restored.equipment.weapon, equals('Flaming Sword'));
|
||||
expect(restored.equipment.shield, equals('Tower Shield'));
|
||||
expect(restored.equipment.bestIndex, equals(0));
|
||||
expect(
|
||||
restored.equipment.weaponItem.rarity,
|
||||
equals(ItemRarity.epic),
|
||||
);
|
||||
expect(restored.equipment.weaponItem.level, equals(10));
|
||||
expect(restored.equipment.weaponItem.stats.atk, equals(50));
|
||||
|
||||
// skillBook 검증
|
||||
expect(restored.skillBook.skills.length, equals(2));
|
||||
expect(restored.skillBook.skills[0].name, equals('Hack'));
|
||||
expect(restored.skillBook.skills[0].rank, equals('III'));
|
||||
|
||||
// progress 검증
|
||||
expect(restored.progress.task.position, equals(50));
|
||||
expect(restored.progress.task.max, equals(100));
|
||||
expect(restored.progress.quest.position, equals(3));
|
||||
expect(restored.progress.exp.position, equals(500));
|
||||
expect(restored.progress.currentTask.caption, equals('Executing a Goblin'));
|
||||
expect(restored.progress.currentTask.type, equals(TaskType.kill));
|
||||
expect(
|
||||
restored.progress.currentTask.monsterGrade,
|
||||
equals(MonsterGrade.elite),
|
||||
);
|
||||
expect(restored.progress.plotStageCount, equals(3));
|
||||
expect(restored.progress.questCount, equals(7));
|
||||
expect(restored.progress.plotHistory.length, equals(2));
|
||||
expect(restored.progress.plotHistory[0].isComplete, isTrue);
|
||||
expect(restored.progress.questHistory.length, equals(1));
|
||||
expect(restored.progress.currentQuestMonster, isNotNull);
|
||||
expect(
|
||||
restored.progress.currentQuestMonster!.monsterData,
|
||||
equals('Goblin|3|ear'),
|
||||
);
|
||||
expect(restored.progress.monstersKilled, equals(150));
|
||||
expect(restored.progress.deathCount, equals(3));
|
||||
|
||||
// queue 검증
|
||||
expect(restored.queue.entries.length, equals(1));
|
||||
expect(restored.queue.entries.first.kind, equals(QueueKind.task));
|
||||
expect(restored.queue.entries.first.durationMillis, equals(2000));
|
||||
expect(restored.queue.entries.first.taskType, equals(TaskType.market));
|
||||
|
||||
// monetization 검증
|
||||
expect(restored.monetization, isNotNull);
|
||||
expect(restored.monetization!.adRemovalPurchased, isTrue);
|
||||
expect(restored.monetization!.rollsRemaining, equals(3));
|
||||
expect(restored.monetization!.undoRemaining, equals(2));
|
||||
expect(restored.monetization!.pendingChests, equals(4));
|
||||
expect(restored.monetization!.autoReviveEndMs, equals(50000));
|
||||
expect(restored.monetization!.speedBoostEndMs, equals(80000));
|
||||
expect(restored.monetization!.luckyCharmEndMs, equals(60000));
|
||||
|
||||
// 메타(meta) 검증
|
||||
expect(restored.version, equals(kSaveVersion));
|
||||
expect(restored.rngState, equals(12345));
|
||||
expect(restored.cheatsEnabled, isTrue);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// v2→v4 마이그레이션(migration) 테스트 — 레거시 장비 문자열 → 새 형식
|
||||
// =========================================================================
|
||||
|
||||
group('v2→v4 마이그레이션', () {
|
||||
test('레거시 장비 문자열이 EquipmentItem으로 변환', () {
|
||||
final legacyJson = <String, dynamic>{
|
||||
'version': 2,
|
||||
'rng': 100,
|
||||
'traits': {'name': 'OldHero', 'race': 'Elf', 'klass': 'Mage'},
|
||||
'stats': {'str': 10, 'con': 10, 'dex': 10, 'int': 10, 'wis': 10, 'cha': 10},
|
||||
'inventory': {'gold': 500, 'items': []},
|
||||
'equipment': {
|
||||
'weapon': 'Ancient Sword',
|
||||
'shield': 'Wooden Shield',
|
||||
'helm': 'Iron Helm',
|
||||
'hauberk': '',
|
||||
'brassairts': '',
|
||||
'vambraces': '',
|
||||
'gauntlets': '',
|
||||
'gambeson': '',
|
||||
'cuisses': '',
|
||||
'greaves': '',
|
||||
'sollerets': '',
|
||||
'bestIndex': 0,
|
||||
},
|
||||
'skills': [],
|
||||
'progress': {
|
||||
'task': {'pos': 0, 'max': 1},
|
||||
'quest': {'pos': 0, 'max': 1},
|
||||
'plot': {'pos': 0, 'max': 1},
|
||||
'exp': {'pos': 0, 'max': 1},
|
||||
'encumbrance': {'pos': 0, 'max': 1},
|
||||
},
|
||||
'queue': [],
|
||||
};
|
||||
|
||||
final restored = GameSave.fromJson(legacyJson);
|
||||
|
||||
// 레거시 장비 문자열 → EquipmentItem 변환 확인
|
||||
expect(restored.equipment.weapon, equals('Ancient Sword'));
|
||||
expect(restored.equipment.shield, equals('Wooden Shield'));
|
||||
expect(restored.equipment.helm, equals('Iron Helm'));
|
||||
// 빈 슬롯은 빈 이름으로 유지
|
||||
expect(restored.equipment.hauberk, isEmpty);
|
||||
|
||||
// 레거시 아이템은 level 1, common으로 변환
|
||||
expect(restored.equipment.weaponItem.level, equals(1));
|
||||
expect(restored.equipment.weaponItem.rarity, equals(ItemRarity.common));
|
||||
expect(restored.equipment.weaponItem.slot, equals(EquipmentSlot.weapon));
|
||||
|
||||
// 버전 정보 보존
|
||||
expect(restored.version, equals(2));
|
||||
});
|
||||
|
||||
test('v2 세이브에 monetization 없으면 null', () {
|
||||
// jsonDecode로 순수 Map<String, dynamic> 생성 (타입 캐스팅 호환)
|
||||
final legacyJson = jsonDecode(jsonEncode({
|
||||
'version': 2,
|
||||
'rng': 0,
|
||||
'traits': {},
|
||||
'stats': {},
|
||||
'inventory': {'gold': 0, 'items': []},
|
||||
'equipment': {
|
||||
'weapon': 'Keyboard',
|
||||
'shield': '',
|
||||
'helm': '',
|
||||
'hauberk': '',
|
||||
'brassairts': '',
|
||||
'vambraces': '',
|
||||
'gauntlets': '',
|
||||
'gambeson': '',
|
||||
'cuisses': '',
|
||||
'greaves': '',
|
||||
'sollerets': '',
|
||||
'bestIndex': 0,
|
||||
},
|
||||
'skills': [],
|
||||
'progress': {},
|
||||
'queue': [],
|
||||
})) as Map<String, dynamic>;
|
||||
|
||||
final restored = GameSave.fromJson(legacyJson);
|
||||
|
||||
expect(restored.monetization, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 누락된 JSON 키(missing keys)에 대한 기본값(default) 처리
|
||||
// =========================================================================
|
||||
|
||||
group('누락된 JSON 키 기본값 처리', () {
|
||||
test('완전히 빈 JSON에서 기본값으로 GameSave 생성', () {
|
||||
final emptyJson = <String, dynamic>{};
|
||||
|
||||
final restored = GameSave.fromJson(emptyJson);
|
||||
|
||||
// 기본값 확인
|
||||
expect(restored.version, equals(kSaveVersion));
|
||||
expect(restored.rngState, equals(0));
|
||||
expect(restored.cheatsEnabled, isFalse);
|
||||
expect(restored.traits.name, isEmpty);
|
||||
expect(restored.traits.level, equals(1));
|
||||
expect(restored.stats.str, equals(0));
|
||||
expect(restored.stats.hpMax, equals(0));
|
||||
expect(restored.inventory.gold, equals(0));
|
||||
expect(restored.inventory.items, isEmpty);
|
||||
expect(restored.skillBook.skills, isEmpty);
|
||||
expect(restored.queue.entries, isEmpty);
|
||||
expect(restored.monetization, isNull);
|
||||
});
|
||||
|
||||
test('traits 키만 있고 내부 필드 누락 시 기본값', () {
|
||||
final json = <String, dynamic>{
|
||||
'traits': {'name': 'OnlyName'},
|
||||
};
|
||||
|
||||
final restored = GameSave.fromJson(json);
|
||||
|
||||
expect(restored.traits.name, equals('OnlyName'));
|
||||
expect(restored.traits.race, isEmpty);
|
||||
expect(restored.traits.klass, isEmpty);
|
||||
expect(restored.traits.level, equals(1));
|
||||
expect(restored.traits.raceId, isEmpty);
|
||||
expect(restored.traits.classId, isEmpty);
|
||||
});
|
||||
|
||||
test('stats에 hpCurrent/mpCurrent 누락 시 null', () {
|
||||
final json = <String, dynamic>{
|
||||
'stats': {'str': 10, 'hpMax': 50, 'mpMax': 30},
|
||||
};
|
||||
|
||||
final restored = GameSave.fromJson(json);
|
||||
|
||||
expect(restored.stats.str, equals(10));
|
||||
expect(restored.stats.hpMax, equals(50));
|
||||
expect(restored.stats.hpCurrent, isNull);
|
||||
expect(restored.stats.mpCurrent, isNull);
|
||||
});
|
||||
|
||||
test('progress 키 누락 시 기본 ProgressBarState', () {
|
||||
final json = <String, dynamic>{
|
||||
'progress': {'plotStages': 5, 'questCount': 10},
|
||||
};
|
||||
|
||||
final restored = GameSave.fromJson(json);
|
||||
|
||||
expect(restored.progress.task.position, equals(0));
|
||||
expect(restored.progress.task.max, equals(1));
|
||||
expect(restored.progress.plotStageCount, equals(5));
|
||||
expect(restored.progress.questCount, equals(10));
|
||||
});
|
||||
|
||||
test('알 수 없는 TaskType/QueueKind는 기본값으로 대체(fallback)', () {
|
||||
final json = <String, dynamic>{
|
||||
'progress': {
|
||||
'task': {'pos': 0, 'max': 1},
|
||||
'quest': {'pos': 0, 'max': 1},
|
||||
'plot': {'pos': 0, 'max': 1},
|
||||
'exp': {'pos': 0, 'max': 1},
|
||||
'encumbrance': {'pos': 0, 'max': 1},
|
||||
'taskInfo': {
|
||||
'caption': 'Unknown task',
|
||||
'type': 'nonexistent_type',
|
||||
},
|
||||
},
|
||||
'queue': [
|
||||
{
|
||||
'kind': 'nonexistent_kind',
|
||||
'duration': 1000,
|
||||
'caption': 'Unknown',
|
||||
'taskType': 'nonexistent_task_type',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
final restored = GameSave.fromJson(json);
|
||||
|
||||
// 알 수 없는 enum 값은 기본값으로 대체
|
||||
expect(restored.progress.currentTask.type, equals(TaskType.neutral));
|
||||
expect(restored.queue.entries.first.kind, equals(QueueKind.task));
|
||||
expect(
|
||||
restored.queue.entries.first.taskType,
|
||||
equals(TaskType.neutral),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// null/빈 배열(empty array) 처리 테스트
|
||||
// =========================================================================
|
||||
|
||||
group('null/빈 배열 처리', () {
|
||||
test('inventory.items가 빈 배열이면 빈 리스트', () {
|
||||
final json = <String, dynamic>{
|
||||
'inventory': {'gold': 100, 'items': []},
|
||||
};
|
||||
|
||||
final restored = GameSave.fromJson(json);
|
||||
|
||||
expect(restored.inventory.items, isEmpty);
|
||||
expect(restored.inventory.gold, equals(100));
|
||||
});
|
||||
|
||||
test('skills가 빈 배열이면 빈 스킬북', () {
|
||||
final json = <String, dynamic>{
|
||||
'skills': [],
|
||||
};
|
||||
|
||||
final restored = GameSave.fromJson(json);
|
||||
|
||||
expect(restored.skillBook.skills, isEmpty);
|
||||
});
|
||||
|
||||
test('plotHistory/questHistory null이면 빈 리스트', () {
|
||||
final json = <String, dynamic>{
|
||||
'progress': {
|
||||
'task': {'pos': 0, 'max': 1},
|
||||
'quest': {'pos': 0, 'max': 1},
|
||||
'plot': {'pos': 0, 'max': 1},
|
||||
'exp': {'pos': 0, 'max': 1},
|
||||
'encumbrance': {'pos': 0, 'max': 1},
|
||||
'plotHistory': null,
|
||||
'questHistory': null,
|
||||
},
|
||||
};
|
||||
|
||||
final restored = GameSave.fromJson(json);
|
||||
|
||||
expect(restored.progress.plotHistory, isEmpty);
|
||||
expect(restored.progress.questHistory, isEmpty);
|
||||
});
|
||||
|
||||
test('questMonster null이면 null 유지', () {
|
||||
final json = <String, dynamic>{
|
||||
'progress': {
|
||||
'task': {'pos': 0, 'max': 1},
|
||||
'quest': {'pos': 0, 'max': 1},
|
||||
'plot': {'pos': 0, 'max': 1},
|
||||
'exp': {'pos': 0, 'max': 1},
|
||||
'encumbrance': {'pos': 0, 'max': 1},
|
||||
'questMonster': null,
|
||||
},
|
||||
};
|
||||
|
||||
final restored = GameSave.fromJson(json);
|
||||
|
||||
expect(restored.progress.currentQuestMonster, isNull);
|
||||
});
|
||||
|
||||
test('queue가 빈 배열이면 빈 큐', () {
|
||||
final json = <String, dynamic>{
|
||||
'queue': [],
|
||||
};
|
||||
|
||||
final restored = GameSave.fromJson(json);
|
||||
|
||||
expect(restored.queue.entries, isEmpty);
|
||||
});
|
||||
|
||||
test('monetization null이면 null 유지', () {
|
||||
final json = <String, dynamic>{
|
||||
'monetization': null,
|
||||
};
|
||||
|
||||
final restored = GameSave.fromJson(json);
|
||||
|
||||
expect(restored.monetization, isNull);
|
||||
});
|
||||
|
||||
test('monsterGrade null이면 null 유지 (neutral 타입)', () {
|
||||
final json = <String, dynamic>{
|
||||
'progress': {
|
||||
'task': {'pos': 0, 'max': 1},
|
||||
'quest': {'pos': 0, 'max': 1},
|
||||
'plot': {'pos': 0, 'max': 1},
|
||||
'exp': {'pos': 0, 'max': 1},
|
||||
'encumbrance': {'pos': 0, 'max': 1},
|
||||
'taskInfo': {
|
||||
'caption': 'Walking',
|
||||
'type': 'neutral',
|
||||
'monsterGrade': null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
final restored = GameSave.fromJson(json);
|
||||
|
||||
expect(restored.progress.currentTask.monsterGrade, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// toState() 변환 테스트
|
||||
// =========================================================================
|
||||
|
||||
test('toState()로 GameState 변환 시 DeterministicRandom 상태 보존', () {
|
||||
final save = GameSave(
|
||||
version: kSaveVersion,
|
||||
rngState: 42,
|
||||
traits: const Traits(
|
||||
name: 'Test',
|
||||
race: 'Human',
|
||||
klass: 'Warrior',
|
||||
level: 1,
|
||||
motto: '',
|
||||
guild: '',
|
||||
),
|
||||
stats: Stats.empty(),
|
||||
inventory: Inventory.empty(),
|
||||
equipment: Equipment.empty(),
|
||||
skillBook: SkillBook.empty(),
|
||||
progress: ProgressState.empty(),
|
||||
queue: QueueState.empty(),
|
||||
);
|
||||
|
||||
final state = save.toState();
|
||||
|
||||
expect(state.rng.state, equals(42));
|
||||
expect(state.traits.name, equals('Test'));
|
||||
});
|
||||
});
|
||||
}
|
||||
191
test/core/storage/save_integrity_test.dart
Normal file
191
test/core/storage/save_integrity_test.dart
Normal file
@@ -0,0 +1,191 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:asciineverdie/src/core/storage/save_integrity.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('SaveIntegrity', () {
|
||||
// =========================================================================
|
||||
// sign() → verify() 라운드트립(roundtrip) 테스트
|
||||
// =========================================================================
|
||||
|
||||
group('sign → verify 라운드트립', () {
|
||||
test('서명 후 검증 성공 — 원본 데이터 보존', () {
|
||||
final original = Uint8List.fromList([10, 20, 30, 40, 50]);
|
||||
final signed = SaveIntegrity.sign(original);
|
||||
|
||||
// 서명된 데이터 = HMAC(32바이트) + 원본
|
||||
expect(signed.length, equals(SaveIntegrity.hmacLength + original.length));
|
||||
|
||||
final result = SaveIntegrity.verify(signed);
|
||||
|
||||
expect(result.isLegacy, isFalse);
|
||||
expect(result.gzipBytes, equals(original));
|
||||
});
|
||||
|
||||
test('큰 데이터에도 라운드트립 성공', () {
|
||||
// 1KB 데이터
|
||||
final original = Uint8List.fromList(
|
||||
List.generate(1024, (i) => i % 256),
|
||||
);
|
||||
final signed = SaveIntegrity.sign(original);
|
||||
|
||||
final result = SaveIntegrity.verify(signed);
|
||||
|
||||
expect(result.isLegacy, isFalse);
|
||||
expect(result.gzipBytes, equals(original));
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 변조(tampered) 데이터 검증 실패 테스트
|
||||
// =========================================================================
|
||||
|
||||
group('변조된 데이터 verify 실패', () {
|
||||
test('데이터 영역 변조 시 SaveIntegrityException 발생', () {
|
||||
final original = Uint8List.fromList([10, 20, 30, 40, 50]);
|
||||
final signed = SaveIntegrity.sign(original);
|
||||
|
||||
// 데이터 영역(HMAC 이후) 변조
|
||||
signed[SaveIntegrity.hmacLength] = 0xFF;
|
||||
|
||||
expect(
|
||||
() => SaveIntegrity.verify(signed),
|
||||
throwsA(isA<SaveIntegrityException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('HMAC 영역 변조 시 SaveIntegrityException 발생', () {
|
||||
final original = Uint8List.fromList([10, 20, 30, 40, 50]);
|
||||
final signed = SaveIntegrity.sign(original);
|
||||
|
||||
// HMAC 영역 변조
|
||||
signed[0] ^= 0xFF;
|
||||
|
||||
expect(
|
||||
() => SaveIntegrity.verify(signed),
|
||||
throwsA(isA<SaveIntegrityException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('HMAC과 데이터 모두 변조 시 예외 발생', () {
|
||||
final original = Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
final signed = SaveIntegrity.sign(original);
|
||||
|
||||
// 양쪽 변조
|
||||
signed[0] ^= 0x01;
|
||||
signed[SaveIntegrity.hmacLength + 2] ^= 0x01;
|
||||
|
||||
expect(
|
||||
() => SaveIntegrity.verify(signed),
|
||||
throwsA(isA<SaveIntegrityException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 레거시 포맷(legacy format) 감지 테스트
|
||||
// =========================================================================
|
||||
|
||||
group('레거시 포맷 감지', () {
|
||||
test('GZip 매직 바이트(0x1f 0x8b)로 시작하면 isLegacy=true', () {
|
||||
// GZip 매직 바이트 + 임의 데이터
|
||||
final legacyData = Uint8List.fromList([0x1f, 0x8b, 0x08, 0x00, 0x00]);
|
||||
|
||||
final result = SaveIntegrity.verify(legacyData);
|
||||
|
||||
expect(result.isLegacy, isTrue);
|
||||
expect(result.gzipBytes, equals(legacyData));
|
||||
});
|
||||
|
||||
test('레거시 포맷은 HMAC 검증을 건너뛰고 원본 데이터 반환', () {
|
||||
// GZip 매직 바이트로 시작하는 큰 데이터
|
||||
final legacyData = Uint8List.fromList([
|
||||
0x1f, 0x8b, ...List.generate(100, (i) => i % 256),
|
||||
]);
|
||||
|
||||
final result = SaveIntegrity.verify(legacyData);
|
||||
|
||||
expect(result.isLegacy, isTrue);
|
||||
expect(result.gzipBytes.length, equals(legacyData.length));
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 너무 작은 데이터 verify 실패 테스트
|
||||
// =========================================================================
|
||||
|
||||
group('너무 작은 데이터', () {
|
||||
test('HMAC 길이 미만 데이터 시 SaveIntegrityException 발생', () {
|
||||
// HMAC 32바이트 미만이면서 GZip 매직 바이트가 아닌 데이터
|
||||
final tooSmall = Uint8List.fromList([0x00, 0x01, 0x02]);
|
||||
|
||||
expect(
|
||||
() => SaveIntegrity.verify(tooSmall),
|
||||
throwsA(isA<SaveIntegrityException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('정확히 HMAC 길이(32바이트) 데이터 — 빈 페이로드로 검증 시도', () {
|
||||
// 32바이트 = HMAC만 있고 데이터 없음 → HMAC 불일치로 실패
|
||||
final exactHmacLength = Uint8List(SaveIntegrity.hmacLength);
|
||||
|
||||
// HMAC이 맞지 않으므로 예외 발생
|
||||
expect(
|
||||
() => SaveIntegrity.verify(exactHmacLength),
|
||||
throwsA(isA<SaveIntegrityException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('1바이트 데이터 — 레거시도 아니고 너무 작음', () {
|
||||
final oneByte = Uint8List.fromList([0x42]);
|
||||
|
||||
expect(
|
||||
() => SaveIntegrity.verify(oneByte),
|
||||
throwsA(isA<SaveIntegrityException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 빈 데이터(empty data) 처리 테스트
|
||||
// =========================================================================
|
||||
|
||||
group('빈 데이터 처리', () {
|
||||
test('빈 바이트 배열 verify 시 예외 발생', () {
|
||||
final empty = Uint8List(0);
|
||||
|
||||
expect(
|
||||
() => SaveIntegrity.verify(empty),
|
||||
throwsA(isA<SaveIntegrityException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('빈 데이터에 sign() 후 verify() 라운드트립 성공', () {
|
||||
// 빈 페이로드(payload)도 서명/검증 가능
|
||||
final emptyPayload = Uint8List(0);
|
||||
final signed = SaveIntegrity.sign(emptyPayload);
|
||||
|
||||
expect(signed.length, equals(SaveIntegrity.hmacLength));
|
||||
|
||||
final result = SaveIntegrity.verify(signed);
|
||||
|
||||
expect(result.isLegacy, isFalse);
|
||||
expect(result.gzipBytes, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// SaveIntegrityException 테스트
|
||||
// =========================================================================
|
||||
|
||||
group('SaveIntegrityException', () {
|
||||
test('toString()에 메시지 포함', () {
|
||||
const exception = SaveIntegrityException('테스트 메시지');
|
||||
|
||||
expect(exception.toString(), contains('테스트 메시지'));
|
||||
expect(exception.message, equals('테스트 메시지'));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user