Files
MonsterCards/iOS/MonsterCardsTests/BinderExporterTest.swift

240 lines
9.7 KiB
Swift

//
// BinderExporterTest.swift
// MonsterCardsTests
//
// Unit tests for BinderExporter — mirrors Android's BinderImportExportTest.
//
import XCTest
@testable import MonsterCards
final class BinderExporterTest: XCTestCase {
// MARK: - Export with collection name
func testExportBinder_singleCollection_createsValidJson() throws {
let monster = sampleMonster()
let json = BinderExporter.exportBinder(collectionName: "Goblins", monsters: [monster])
// Verify root structure.
guard let data = json.data(using: .utf8),
let root = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let collections = root["collections"] as? [[String: Any]],
let col = collections.first,
let cards = col["cards"] as? [[String: Any]] else {
XCTFail("JSON structure invalid")
return
}
XCTAssertEqual(root["$schema"], "https://majinnaibu.com/schemas/binder.schema.json")
XCTAssertEqual(root["schemaVersion"] as? Int, 1)
XCTAssertEqual(col["name"] as? String, "Goblins")
XCTAssertEqual(cards.count, 1)
// Verify monster card fields.
let card = cards[0]
XCTAssertEqual(card["$schema"] as? String, "https://majinnaibu.com/schemas/monster-card.schema.json")
XCTAssertEqual(card["schemaVersion"] as? Int, 1)
XCTAssertEqual(card["id"] as? String, monster.id?.uuidString ?? "")
XCTAssertEqual(card["name"] as? String, "Goblin Warrior")
XCTAssertEqual(card["size"] as? String, "Small")
XCTAssertEqual(card["type"] as? String, "humanoid")
}
func testExportBinder_customId_preservesIt() throws {
let monster = sampleMonster()
let customId = UUID()
let json = BinderExporter.exportBinder(collectionName: "", monsters: [monster], idOverride: customId)
guard let data = json.data(using: .utf8),
let root = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let collections = root["collections"] as? [[String: Any]],
let cards = collections.first["cards"] as? [[String: Any]] else {
XCTFail("JSON structure invalid")
return
}
XCTAssertEqual(cards[0]["id"] as? String, customId.uuidString)
}
// MARK: - Export with full backup (dashboard)
func testExportFullBackup_withDashboard_includesDashboardField() throws {
let col = BinderExport.CollectionExport(
id: "test-col-id",
name: "Test Collection",
description: "A test collection",
cards: [MonsterExport.makeDictionary(monster: sampleMonster())]
)
let json = BinderExporter.exportFullBackup(collections: [col], dashboardMonsters: [sampleMonster()])
guard let data = json.data(using: .utf8),
let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
XCTFail("JSON structure invalid")
return
}
XCTAssertEqual(root["$schema"] as? String, "https://majinnaibu.com/schemas/binder.schema.json")
// Should have a dashboard field with at least 1 entry.
XCTAssertTrue(root["dashboard"] is NSArray || root["dashboard"] is [[String: Any]])
}
func testExportFullBackup_withoutDashboard_excludesDashboardField() throws {
let col = BinderExport.CollectionExport(
id: "uuid-test",
name: "No Dash",
description: "",
cards: []
)
let json = BinderExporter.exportFullBackup(collections: [col], dashboardMonsters: nil)
XCTAssertFalse(json.contains("\"dashboard\""), "Should not contain 'dashboard' when none provided")
}
// MARK: - Ability score mapping
func testExportBinder_abilityScores_matchJsonKeys() throws {
let monster = sampleMonster()
let json = BinderExporter.exportBinder(collectionName: "", monsters: [monster])
guard let data = json.data(using: .utf8),
let root = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let collections = root["collections"] as? [[String: Any]],
let cards = collections.first["cards"] as? [[String: Any]] else {
XCTFail("JSON structure invalid")
return
}
let monsterJson = cards[0]
// Verify ability score keys and values.
XCTAssertEqual(monsterJson["strengthScore"] as? Int64, monster.strengthScore)
XCTAssertEqual(monsterJson["dexterityScore"] as? Int64, monster.dexterityScore)
XCTAssertEqual(monsterJson["constitutionScore"] as? Int64, monster.constitutionScore)
XCTAssertEqual(monsterJson["intelligenceScore"] as? Int64, monster.intelligenceScore)
XCTAssertEqual(monsterJson["wisdomScore"] as? Int64, monster.wisdomScore)
XCTAssertEqual(monsterJson["charismaScore"] as? Int64, monster.charismaScore)
// Verify saving throw keys exist.
XCTAssertNotNil(monsterJson["strengthSavingThrowProficiency"])
XCTAssertNotNil(monsterJson["strengthSavingThrowAdvantage"])
XCTAssertNotNil(monsterJson["dexteritySavingThrowProficiency"])
}
// MARK: - Round-trip (export then verify it would import)
func testExportRoundTrip_matchesImportExportTest_expectancy() throws {
// Mirror the Android BinderImportExportTest.testExportAndImportBinder expectations.
let monster = sampleMonster()
let json = BinderExporter.exportBinder(collectionName: "Goblins", monsters: [monster])
// Deserialize to verify shape (simulates what the Android BinderImporter would read).
guard let data = json.data(using: .utf8),
let root = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let collections = root["collections"] as? [[String: Any]],
let col = collections.first,
let cards = col["cards"] as? [[String: Any]] else {
XCTFail("Failed to parse exported JSON")
return
}
XCTAssertEqual(root["schemaVersion"] as? Int, 1)
XCTAssertEqual(collections.count, 1)
XCTAssertEqual(col["name"] as? String, "Goblins")
XCTAssertEqual(cards.count, 1)
XCTAssertEqual(cards[0]["name"] as? String, "Goblin Warrior")
XCTAssertEqual(cards[0]["schemaVersion"] as? Int, 1)
}
// MARK: - All MonsterViewModel fields present
func testExportAllMonsterFields_haveValidKeys() throws {
let monster = sampleFullMonster()
let json = BinderExporter.exportBinder(collectionName: "", monsters: [monster])
guard let data = json.data(using: .utf8),
let root = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let cards = (root["collections"] as? [[String: Any]])?.first["cards"] as? [[String: Any]] else {
XCTFail("JSON structure invalid")
return
}
let m = cards[0]
// Verify all expected keys are present.
let expectedKeys: [String] = [
"$schema", "schemaVersion", "id", "name", "size", "type", "subtype",
"alignment",
"strengthScore", "dexterityScore", "constitutionScore", "intelligenceScore",
"wisdomScore", "charismaScore",
"strengthSavingThrowProficiency", "strengthSavingThrowAdvantage",
"dexteritySavingThrowProficiency", "dexteritySavingThrowAdvantage",
"constitutionSavingThrowProficiency", "constitutionSavingThrowAdvantage",
"intelligenceSavingThrowProficiency", "intelligenceSavingThrowAdvantage",
"wisdomSavingThrowProficiency", "wisdomSavingThrowAdvantage",
"charismaSavingThrowProficiency", "charismaSavingThrowAdvantage",
"armorType", "shieldBonus", "naturalArmorBonus", "otherArmorDescription",
"hitDice", "hasCustomHP", "customHitPointsDescription",
"walkSpeed", "burrowSpeed", "climbSpeed", "flySpeed",
"canHover", "swimSpeed", "hasCustomSpeed", "customSpeedDescription",
"challengeRating", "customChallengeRatingDescription", "customProficiencyBonus",
"telepathyRange", "understandsButDescription",
"senses", "damageImmunities", "damageResistances", "damageVulnerabilities",
"conditionImmunities", "skills", "languages",
"abilities", "actions", "reactions",
"legendaryActions", "lairActions", "regionalActions",
"bonusActions"
]
for key in expectedKeys {
XCTAssertTrue(m.keys.contains(key), "Missing key '\(key)' in export; keys found: \(m.keys.sorted())")
}
}
// MARK: - Helpers
private func sampleMonster() -> MonsterViewModel {
let monster = MonsterViewModel()
monster.name = "Goblin Warrior"
monster.size = "Small"
monster.type = "humanoid"
monster.subType = "goblinoid"
monster.alignment = "neutral evil"
monster.strengthScore = 8
monster.dexterityScore = 14
// Keep id as default (not set).
return monster
}
private func sampleFullMonster() -> MonsterViewModel {
let m = sampleMonster()
m.hitDice = 2
m.strengthScore = 8
m.dexterityScore = 14
m.constitutionScore = 10
m.intelligenceScore = 8
m.wisdomScore = 8
m.charismaScore = 8
m.walkSpeed = 30
m.hitDice = 2
// Add some damage immunities.
let di = StringViewModel("fire")
m.damageImmunities.append(di)
// Add abilities.
m.abilities.append(AbilityViewModel("Nimble Escape", "Disengage or Hide as a bonus action."))
m.actions.append(AbilityViewModel("Scimitar", "+4 to hit, 1d6+2 slashing"))
return m
}
}