From 9a38c6d98f43fa5ef4c0f8946bf9113ddac611a8 Mon Sep 17 00:00:00 2001 From: Tom Hicks Date: Tue, 22 Sep 2026 00:29:39 -0700 Subject: [PATCH] Add BinderExporter iOS implementation with tests --- .../ImportExport/BinderExporter.swift | 250 ++++++++++++++++++ .../BinderExporterTest.swift | 239 +++++++++++++++++ 2 files changed, 489 insertions(+) create mode 100644 iOS/MonsterCards/ImportExport/BinderExporter.swift create mode 100644 iOS/MonsterCardsTests/BinderExporterTest.swift diff --git a/iOS/MonsterCards/ImportExport/BinderExporter.swift b/iOS/MonsterCards/ImportExport/BinderExporter.swift new file mode 100644 index 0000000..061545d --- /dev/null +++ b/iOS/MonsterCards/ImportExport/BinderExporter.swift @@ -0,0 +1,250 @@ +// +// BinderExporter.swift +// MonsterCards +// +// Port of com.majinnaibu.monstercards.exporters.BinderExporter.java +// Serialises MonsterViewModel → Binder platform JSON (pretty-printed, Gson-compatible). +// + +import Foundation + +// MARK: - Binder export DTOs + +struct BinderExport { + var schema = "https://majinnaibu.com/schemas/binder.schema.json" + var schemaVersion = 1 + var collections: [CollectionExport] = [] + + struct CollectionExport { + let id: String + let name: String + let description: String + let cards: [MonsterExport] + + init(id: String, name: String, description: String, cards: [MonsterExport]) { + self.id = id + self.name = name + self.description = description + // Preserve empty list explicitly (Android uses new ArrayList<>()) + self.cards = cards.count > 0 ? cards : [] + } + + static func named(_ name: String, cards: [MonsterExport]) -> CollectionExport { + CollectionExport(id: UUID().uuidString, name: name, description: "", cards: cards) + } + } +} + +struct MonsterExport { + /// Build a JSON-serialisable dictionary from a `MonsterViewModel`. + static func makeDictionary(monster: MonsterViewModel, idOverride: UUID? = nil) -> [String: Any] { + var d: [String: Any] = [ + "$schema": "https://majinnaibu.com/schemas/monster-card.schema.json", + "schemaVersion": 1, + + // Core identity + "id": idOverride?.uuidString ?? UUID().uuidString, + "name": monster.name ?? "", + "size": monster.size ?? "", + "type": monster.type ?? "", + "subtype": monster.subType ?? "", + "alignment": monster.alignment ?? "", + + // Ability scores — keys match Android Monster.java Gson output literally. + "strengthScore": monster.strengthScore, + "dexterityScore": monster.dexterityScore, + "constitutionScore": monster.constitutionScore, + "intelligenceScore": monster.intelligenceScore, + "wisdomScore": monster.wisdomScore, + "charismaScore": monster.charismaScore, + + // Saving throw proficiencies & advantages (raw string from Android enum.toString()). + "strengthSavingThrowProficiency": monster.strengthSavingThrowProficiency.rawValue, + "strengthSavingThrowAdvantage": monster.strengthSavingThrowAdvantage.rawValue, + "dexteritySavingThrowProficiency": monster.dexteritySavingThrowProficiency.rawValue, + "dexteritySavingThrowAdvantage": monster.dexteritySavingThrowAdvantage.rawValue, + "constitutionSavingThrowProficiency": monster.constitutionSavingThrowProficiency.rawValue, + "constitutionSavingThrowAdvantage": monster.constitutionSavingThrowAdvantage.rawValue, + "intelligenceSavingThrowProficiency": monster.intelligenceSavingThrowProficiency.rawValue, + "intelligenceSavingThrowAdvantage": monster.intelligenceSavingThrowAdvantage.rawValue, + "wisdomSavingThrowProficiency": monster.wisdomSavingThrowProficiency.rawValue, + "wisdomSavingThrowAdvantage": monster.wisdomSavingThrowAdvantage.rawValue, + "charismaSavingThrowProficiency": monster.charismaSavingThrowProficiency.rawValue, + "charismaSavingThrowAdvantage": monster.charismaSavingThrowAdvantage.rawValue, + + // Armor + "armorType": monster.armorType.rawValue, + "shieldBonus": Int32(monster.shieldBonus), + "naturalArmorBonus": monster.naturalArmorBonus, + "otherArmorDescription": monster.otherArmorDescription ?? "", + + // Hit points + "hitDice": monster.hitDice, + "hasCustomHP": monster.hasCustomHP, + "customHitPointsDescription": monster.customHP ?? "", + + // Speeds + "walkSpeed": monster.walkSpeed, + "burrowSpeed": monster.burrowSpeed, + "climbSpeed": monster.climbSpeed, + "flySpeed": monster.flySpeed, + "canHover": monster.canHover, + "swimSpeed": monster.swimSpeed, + "hasCustomSpeed": monster.hasCustomSpeed, + "customSpeedDescription": monster.customSpeed ?? "", + + // Challenge rating + "challengeRating": monster.challengeRating.rawValue, + "customChallengeRatingDescription": monster.customChallengeRating ?? "", + "customProficiencyBonus": monster.customProficiencyBonus, + + // Telepathy & understanding + "telepathyRange": monster.telepathy, + "understandsButDescription": monster.understandsBut ?? "", + + // Lists (strings) — keys match Android Monster.java @ColumnInfo / Gson names. + "senses": monster.senses.map { ($0.name ?? "") }, + "damageImmunities": monster.damageImmunities.map { ($0.name ?? "") }, + "damageResistances": monster.damageResistances.map { ($0.name ?? "") }, + "damageVulnerabilities": monster.damageVulnerabilities.map { ($0.name ?? "") }, + "conditionImmunities": monster.conditionImmunities.map { ($0.name ?? "") }, + + // Skills — each is {"name": ..., "stat": "...", "proficiency": "..."} + "skills": monster.skills.map { s -> [String: Any] in + return ["name": s.name ?? "", "stat": s.stat.rawValue, "proficiency": s.proficiency.rawValue] + }, + + // Languages — each is {"name": ..., "speaks": Bool} + "languages": monster.languages.map { l -> [String: Any] in + return ["name": l.name ?? "", "speaks": l.speaks] + }, + + // Combat lists — each is {"name": ..., "desc": ...} + "abilities": monster.abilities.map { ["name": $0.name ?? "", "desc": $0.description ?? ""] }, + "actions": monster.actions.map { ["name": $0.name ?? "", "desc": $0.description ?? ""] }, + "reactions": monster.reactions.map { ["name": $0.name ?? "", "desc": $0.description ?? ""] }, + "legendaryActions": monster.legendaryActions.map { ["name": $0.name ?? "", "desc": $0.description ?? ""] }, + "lairActions": monster.lairActions.map { ["name": $0.name ?? "", "desc": $0.description ?? ""] }, + "regionalActions": monster.regionalActions.map { ["name": $0.name ?? "", "desc": $0.description ?? ""] }, + "bonusActions": monster.bonusActions.map { ["name": $0.name ?? "", "desc": $0.description ?? ""] }, + + // Mythic actions — Android Monster model has this; iOS lacks it. + "mythicActions": [] as [[String: Any]], + + // Personality / physical description — Android-only fields on Monster.java. + "playerName": "", + "background": "", + "personalityTraits": "", + "ideals": "", + "bonds": "", + "flaws": "", + "age": "", + "height": "", + "weight": "", + "eyes": "", + "skin": "", + "hair": "", + "appearance": "", + "backstory": "", + "alliesAndOrganizations": "", + "sourceUrl": "" + ] + return d + } +} + +// MARK: - BinderExporter (mirrors public API of com.majinnaibu.monstercards.exporters.BinderExporter) + +struct BinderExporter { + + // MARK: -- Utility — pretty-print JSON [String:Any] → string (Gson-compatible format) + + private static func toJsonString(_ obj: Any) -> String? { + guard let data = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted]) else { return nil } + return String(data: data, encoding: .utf8) + } + + // MARK: -- Public API matching Android exactly + + /// `exportBinder(String collectionName, List monsters)` + static func exportBinder(collectionName: String, monsters: [MonsterViewModel], idOverride: UUID? = nil) -> String { + let col = BinderExport.CollectionExport( + id: UUID().uuidString, + name: collectionName.isEmpty ? "" : collectionName, + description: "", + cards: monsters.map { MonsterExport.makeDictionary(monster: $0, idOverride: idOverride) } + ) + + let root: [String: Any] = [ + "$schema": "https://majinnaibu.com/schemas/binder.schema.json", + "schemaVersion": 1, + "collections": [[ + "id": col.id, + "name": col.name, + "description": col.description, + "cards": col.cards.map { $0 } + ]] + ] + + return toJsonString(root) ?? "{}" + } + + /// `exportBinder(Collection collection, List monsters)` + static func exportBinder(collection: NSManagedObject?, monsters: [MonsterViewModel]) -> String { + guard let colObj = collection else { + // Empty-collection fallback (same path as Android with null). + return exportBinder(collectionName: "", monsters: monsters) + } + + let id = (colObj.value(forKey: "id") as? UUID)?.uuidString ?? UUID().uuidString + let name = (colObj.value(forKey: "name") as? String) ?? "" + let desc = (colObj.value(forKey: "description") as? String) ?? "" + + let col = BinderExport.CollectionExport( + id: id, + name: name, + description: desc, + cards: monsters.map { MonsterExport.makeDictionary(monster: $0) } + ) + + let root: [String: Any] = [ + "$schema": "https://majinnaibu.com/schemas/binder.schema.json", + "schemaVersion": 1, + "collections": [[ + "id": col.id, + "name": col.name, + "description": col.description, + "cards": col.cards.map { $0 } + ]] + ] + + return toJsonString(root) ?? "{}" + } + + /// `exportFullBackup(List collections, List dashboardMonsters)` + static func exportFullBackup(collections: [BinderExport.CollectionExport], dashboardMonsters: [MonsterViewModel]?) -> String { + let colDicts = collections.map { col -> [String: Any] in + return [ + "id": col.id, + "name": col.name, + "description": col.description, + "cards": col.cards.map { $0 } // already a [String: Any] from makeDictionary + ] + } + + let base: [String: Any] = [ + "$schema": "https://majinnaibu.com/schemas/binder.schema.json", + "schemaVersion": 1, + "collections": colDicts + ] + + guard let dashboard = dashboardMonsters, !dashboard.isEmpty else { + // Android Gson only includes the field when it's populated. + return toJsonString(base) ?? "{}" + } + + var root = base + root["dashboard"] = dashboard.map { MonsterExport.makeDictionary(monster: $0) } + return toJsonString(root) ?? "{}" + } +} diff --git a/iOS/MonsterCardsTests/BinderExporterTest.swift b/iOS/MonsterCardsTests/BinderExporterTest.swift new file mode 100644 index 0000000..09ad170 --- /dev/null +++ b/iOS/MonsterCardsTests/BinderExporterTest.swift @@ -0,0 +1,239 @@ +// +// 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 + } +}