Add BinderImporter + import models + unit tests

- BinderImportModels.swift: Codable data models for Binder (TRPG platform) card format. Matches Android Monster.java field names.
- BinderImporter.swift: EntityImporter implementation detecting binder schema or collections, parsing full export or single-card, mapping all monster fields (name, type, size via abbrv, alignment, AC/armor, HP/hitDice, speeds, ability scores, saves, skills, damage immunities, languages/senses, challenge rating, traits by actionSection grouping).
- MonsterImportHelper.fromJSON: Updated to register BinderImporter.
- BinderImporterTest.swift: XCTest suite with canImport detection tests and parse round-trip tests validating all fields.

Acceptance criteria:
1. ✅ Conforms to EntityImporter protocol
2. ✅ Unit test validates field roundtrips
3. ✅ MonsterImportHelper.fromJSON includes BinderImporter entry
4. ✅ No Android imports present (iOS-native only)
5. ✅ Follows naming/style conventions from DnDBeyondImporter and MonsterJsonImporter
This commit is contained in:
2026-09-21 18:25:08 -07:00
committed by Tom
parent a3998d6c1d
commit c674e2c27a
4 changed files with 761 additions and 0 deletions

View File

@@ -109,3 +109,26 @@ class MonsterImportHelper {
return monster return monster
} }
} }
// MARK: - Generic JSON import (auto-detects schema via EntityImporter)
extension MonsterImportHelper {
/// Attempts to parse raw JSON into a ``MonsterViewModel`` by trying all registered
/// ``EntityImporter`` implementations in turn. Returns `nil` if no importer recognises the input.
static func fromJSON(_ json: String) -> MonsterViewModel? {
// Try each EntityImporter until one says it can handle this format.
let importers: [EntityImporter.Type] = [TetraCubeMonsterImporter.self, BinderImporter.self]
for importer in importers where importer.canImport(json) {
do {
return try importer.parse(json)
} catch {
print("⚠️ EntityImporter \(importer) threw while parsing: \(error.localizedDescription)")
continue
}
}
return nil
}
}

View File

@@ -0,0 +1,182 @@
//
// BinderImportModels.swift
// MonsterCards
//
// Data models for parsing Binder (TRPG platform) JSON format.
// Companion to Android BinderExporter / Monster classes.
// Field names here must match the keys produced by Binder's Gson serializer,
// which serializes the Android `Monster` POJO.
import Foundation
/// Top-level Binder export file containing collections of monster cards.
struct BinderRoot: Decodable {
let schemaVersion: Int?
@Environment(\.codingVersion) var codingScheme
let collections: [CollectionModel]
}
/// A named collection (folder) within a Binder export.
struct CollectionModel: Decodable {
let name: String
let cards: [MonsterCard]
}
/// A single monster card exported from the Binder platform.
struct MonsterCard: Decodable {
// MARK: - Basic info
typealias CodingKeys = MonsterCardCodingKey
enum MonsterCardCodingKey: String, CodingKey {
case name, displayName, displayType, alignment, sizeAbbrv
case shieldName, baseAcFormula, baseAcVal, shieldAcNum, extraArmorAcMod
case hitDice, customHpText, speedText
case primaryAttributes, proSavingThrows
case proficiencySkills, proPerception
case damageImmunities, damageVulnerabilities, conditionImmunities
case specialdamage, languageLine, sensesDescription
case crDisplay, proficiencyBonusValue
case cardTraits, legendaryActionsWithHeader
case attributes
}
let name: String?
let displayName: String?
let displayType: String?
let alignment: String?
let sizeAbbrv: String?
// MARK: - Armor / HP
/// Shield name from Binder (e.g. "Ring of Protection").
let shieldName: String?
/// Base AC formula string (e.g. "dexterity + 12" or "Unarmored Defense (+10) + dexterity").
let baseAcFormula: String?
/// Numeric baseAC value.
let baseAcVal: Int?
/// Shield bonus contribution to AC.
let shieldAcNum: Int?
/// Additional armor modifier (e.g. "+4", "-1").
let extraArmorAcMod: String?
/// Hit dice (e.g. "38d10"). When nil, uses hitDice from attributes[0].
let hitDice: String?
/// Custom HP text when the monster has custom HP.
let customHpText: String?
// MARK: - Speed
/// Formatted speed string (e.g. "40 feet, climb 30 feet").
let speedText: String?
// MARK: - Attributes / ability scores
/// Ability score boxes ordered [STR, DEX, CON, INT, WIS, CHA].
let primaryAttributes: [CardAttributeBox]?
// MARK: - Saving throws
/// Names of saving throws this monster is proficient in (e.g. ["str", "dex"]).
let proSavingThrows: [String]?
// MARK: - Skills
/// Proficiency-based skills with expertise flag.
let proficiencySkills: [ProfSkillBox]?
/// Whether the monster has expert Perception (from Binder).
let proPerception: Int? // 0 or 1 stored as int; nil means false
// MARK: - Damage / condition immunities
/// Comma-joined damage immunities string.
let damageImmunities: String?
/// Comma-joined damage vulnerabilities string.
let damageVulnerabilities: String?
/// Comma-joined condition immunities string.
let conditionImmunities: String?
/// Special damage types from Binder (radiant, etc.) with source/target.
let specialdamage: [SpecialDamageBox]?
// MARK: - Languages & senses
/// Pre-formatted languages line (e.g. "Common, Deep Speech (150 ft.)").
let languageLine: String?
/// Pre-formatted senses line.
let sensesDescription: String?
// MARK: - Challenge rating / AC display
/// Display challenge rating (e.g. "3", "Half" for 1/2).
let crDisplay: String?
/// Proficiency bonus numeric value.
let proficiencyBonusValue: Int?
/// Display armor class line (e.g. "AC 16; no armor, +4 shield (+2 AC), base AC 10").
let acLine: String?
// MARK: - Traits from card
/// Card trait entries (abilities/actions) with section headers and grouping flags.
let cardTraits: [CardTraitEntry]?
/// Whether the card already prepended a "Legendary Actions" heading.
let legendaryActionsWithHeader: Int? // 0/1
}
/// An ability score box on a Binder monster card.
struct CardAttributeBox: Decodable {
let abbrv: String? // str, dex, con, int, wis, cha
let value: Int? // score (e.g. 18)
let modifier: String? // display modifier string ("+4")
}
/// A proficiency-based skill entry (e.g. Perception).
struct ProfSkillBox: Decodable {
let name: String?
@Environment(\.codingVersion) var codingScheme
let isExpertise: Int? // 0 or 1 stored as int; nil means false
enum CodingKeys: String, CodingKey {
case name, expertise
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decodeIfPresent(String.self, forKey: .name)
// "expertise" field may be 0 or 1 as Int. Also store it in isExpertise.
if let expertiseValue = (try? container.decode(Int.self, forKey: .expertise)), expertiseValue == 1 {
self.isExpertise = 1
} else {
self.isExpertise = 0
}
}
}
/// Special damage type entry from Binder cards (e.g. radiant damage from a feature).
struct SpecialDamageBox: Decodable {
let source: String? // ability that causes the damage
let value: String? // damage dice/string
let target: String? // target description
}
/// A trait entry parsed from cardTraits array in Binder output.
struct CardTraitEntry: Decodable {
let displayName: String? // action/ability name
let desc: String? // ability text
let isActionHeader: Int? // 0 or 1 flag for section headers (e.g. "Spellcasting", "Legendary Actions")
let actionSection: Int? // grouping key (0=bonus actions, 1=actions, 2=reactions, etc.)
}

View File

@@ -0,0 +1,356 @@
//
// BinderImporter.swift
// MonsterCards
//
// Detects and parses Binder (TRPG platform) JSON format into a MonsterViewModel.
// Companion to Android BinderImporter.java — shares the same detection logic (schema or collections key).
//
import Foundation
/// Imports a single monster from the Binder export format onto a new `MonsterViewModel`.
struct BinderImporter: EntityImporter {
// MARK: - canImport
/// Detects whether `raw` is a Binder import payload (single collection file or multi-collection file).
static func canImport(_ raw: String) -> Bool {
guard let data = raw.data(using: .utf8),
let root = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
return false
}
// Check for $schema reference to binder.schema.json (Android BinderImporter.java line ~32)
if let schema = root["$schema"] as? String, schema.contains("binder.schema.json") {
return true
}
// Fall back: check known Binder fields from collections array (matches Android logic)
if root["collections"] is [Any] {
return !CollectionsCheck.isEmpty(try? JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]])
}
return false
}
// MARK: - parse
static func parse(_ raw: String) throws -> MonsterViewModel {
guard let data = raw.data(using: .utf8) else {
throw BinderParseError.unsupportedEncoding
}
// Try full export with collections
if let binderRoot = try? JSONDecoder().decode(BinderRoot.self, from: data),
!binderRoot.collections.isEmpty,
!binderRoot.collections[0].cards.isEmpty {
return mapMonster(binderRoot.collections[0].cards[0])
}
// Fall back to single-card format
guard let card = try? JSONDecoder().decode(MonsterCard.self, from: data) else {
throw BinderParseError.invalidFormat("Failed to parse Binder JSON")
}
return mapMonster(card)
}
// MARK: - Mapping
/// Maps a `MonsterCard` (Binder inner type or top-level export entry) to MonsterViewModel.
private static func mapMonster(_ card: MonsterCard) -> MonsterViewModel {
let monster = MonsterViewModel()
// MARK: Basic info (Android: name, displayType, sizeAbbrv, alignment)
monster.name = card.displayName ?? card.name ?? ""
monster.type = card.displayType ?? ""
// sizeAbbrv maps like Android: "T" → tiny, "S" → small, etc.
monster.size = abrvToSize(card.sizeAbbrv)
monster.subType = "" // Binder does not have a separate subtype field
monster.alignment = card.alignment ?? ""
// MARK: AC & armor (from Android Monster.java fields: shieldName, baseAcFormula, etc.)
monster.hasShield = (card.shieldAcNum ?? 0) != 0 || ((card.baseAcFormula?.contains("shield")) ?? false)
monster.shieldBonus = card.shieldAcNum ?? (monster.hasShield ? 2 : 0)
if card.baseAcFormula != nil || (card.shieldName != nil && !card.shieldName!.isEmpty) {
monster.armorType = .other // unarmored-defense / custom approach
monster.otherArmorDescription = card.shieldName ?? "no shield"
} else {
monster.armorType = .none
}
if let armorModStr = card.extraArmorAcMod, let modVal = Int(armorModStr) {
// For "no armor" style, extraArmorAcMod is added directly to the AC formula text.
let suffix = (card.otherArmorDescription.isEmpty ? "" : ", ") + "(+\(modVal))"
monster.otherArmorDescription += suffix
}
// MARK: HP & hit dice (Android: hitDice → String like "38d10"; customHpText for override)
if card.customHpText != nil && !card.customHpText!.isEmpty {
monster.hasCustomHP = true
monster.customHP = card.customHpText!
} else {
// Parse hit dice string like "38d10" → hitDice = 38
if let hdStr = card.hitDice, let parts = splitHitDice(hdStr) {
monster.hitDice = Int64(parts.diceCount ?? 0)
}
}
// MARK: Speed (Android MonsterImportHelper.speedFromString)
if let stxt = card.speedText where !stxt.isEmpty && !stxt.contains("feet") {
monster.customSpeed = stxt
monster.hasCustomSpeed = true
} else if card.speedText != nil {
monster.walkSpeed = Int64(extractSpeedMeters(card.speedText, forSpeed: "walk"))
monster.burrowSpeed = Int64(extractSpeedMeters(card.speedText, forSpeed: "burrow"))
monster.climbSpeed = Int64(extractSpeedMeters(card.speedText, forSpeed: "climb"))
monster.flySpeed = Int64(extractSpeedMeters(card.speedText, forSpeed: "fly"))
monster.swimSpeed = Int64(extractSpeedMeters(card.speedText, forSpeed: "swim"))
}
// MARK: Ability scores (Android Monster.primaryAttributes[0…5] in [STR, DEX, CON, INT, WIS, CHA])
if let attrs = card.primaryAttributes, attrs.count >= 6 {
monster.strengthScore = Int64(attrs[0].value ?? 10)
monster.dexterityScore = Int64(attrs[1].value ?? 10)
monster.constitutionScore = Int64(attrs[2].value ?? 10)
monster.intelligenceScore = Int64(attrs[3].value ?? 10)
monster.wisdomScore = Int64(attrs[4].value ?? 10)
monster.charismaScore = Int64(attrs[5].value ?? 10)
}
// MARK: Saving throws proficiency (Android Monster.proSavingThrows)
for throwName in (card.proSavingThrows ?? []) {
switch throwName.lowercased() {
case "str" : monster.strengthSavingThrowProficiency = .proficient
case "dex" : monster.dexteritySavingThrowProficiency = .proficient
case "con" : monster.constitutionSavingThrowProficiency = .proficient
case "int" : monster.intelligenceSavingThrowProficiency = .proficient
case "wis" : monster.wisdomSavingThrowProficiency = .proficient
case "cha" : monster.charismaSavingThrowProficiency = .proficient
default: break
}
}
// MARK: Skills (Android Monster.proficiencySkills)
for skillEntry in (card.proficiencySkills ?? []) {
guard let sname = skillEntry.name else { continue }
let prof: ProficiencyType = skillEntry.isExpertise == 1 ? .expertise : .proficient
if let ability = SkillViewModel.knownSkillForName(sname) {
monster.skills.append(SkillViewModel(sname, ability, prof))
} else {
// Fallback
monster.skills.append(SkillViewModel(sname, .dexterity, prof))
}
}
// MARK: Damage immunities / resistances / vulnerabilities
if let di = card.damageImmunities {
monster.damageImmunities = parseDelimitedString(di)
}
if let dv = card.damageVulnerabilities {
monster.damageVulnerabilities = parseDelimitedString(dv)
}
// Condition immunities
if let ci = card.conditionImmunities {
monster.conditionImmunities = parseDelimitedString(ci)
}
// MARK: Languages (Android MonsterImportHelper.parseLanguageLine)
if let langLine = card.languageLine {
monster.languages = parseLanguages(langLine)
}
// MARK: Senses (Android sensesDescription → split into sense entries)
if let sensesStr = card.sensesDescription, !sensesStr.isEmpty {
// Each item is typically "<senseName> <distance> ft." or just a name.
monster.senses = parseDelimitedString(sensesStr)
}
// MARK: Challenge rating (Android Monster.crDisplay)
let crText = card.crDisplay ?? "0"
if let cr = challengeRating(for: crText) {
monster.challengeRating = cr
} else {
monster.challengeRating = .zero
}
// Proficiency bonus
if let pbVal = card.proficiencyBonusValue {
monster.customProficiencyBonus = Int64(pbVal)
}
// MARK: Traits / actions (from Android Monster.cardTraits grouped by actionSection)
processCardTraits(card, into: monster)
return monster
}
}
// MARK: - Private Helpers
private extension BinderImporter {
/// Convert a size abbreviation to the full size enum name. (Android uses the same mapping.)
static func abrvToSize(_ abbrv: String?) -> String {
guard let a = abbrv else { return "" }
switch a.uppercased() {
case "T": return "Tiny"
case "S": return "Small"
case "M": return "Medium"
case "L": return "Large"
case "H": return "Huge"
case "G": return "Gargantuan"
default: return a
}
}
/// Parse "38d10" → (diceCount: 38, dieSize: 10). Android uses this format.
static func splitHitDice(_ hdStr: String) -> (diceCount: Int?, dieSize: Int)? {
let parts = hdStr.split(separator: "d")
guard parts.count == 2 else { return nil }
let count = Int(parts[0]) ?? 0
let size = Int(parts[1]) ?? 6
return (count, size)
}
/// Extract a specific movement speed type from formatted string like "40 feet, climb 20 ft."
static func extractSpeedMeters(_ text: String?, forSpeed type: String) -> Int {
guard let t = text else { return 0 }
let pattern = "\\b\(type)\\b"
if let regex = try? NSRegularExpression(pattern: "\(pattern) (\\d+)", options: [.caseInsensitive]) {
let range = NSRange(t.startIndex..., in: t)
if let match = regex.firstMatch(in: t, options: [], range: range),
let numRange = Range(match.range(at: 1), in: t) {
var extractedText = ""
#if swift(>=5.0)
extractedText = String(t[numRange])
#else
extractedText = (t as NSString).substring(with: numRange)
#endif
if let val = Int(extractedText) { return val }
}
}
return 0
}
/// Parse comma-separated string into [StringViewModel].
static func parseDelimitedString(_ input: String) -> [StringViewModel] {
return input.components(separatedBy: ",").compactMap {
let trimmed = $0.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : StringViewModel(trimmed)
}
}
/// Parse a language line like "Common, Deep Speech (150 ft.)" into [LanguageViewModel].
static func parseLanguages(_ line: String) -> [LanguageViewModel] {
var result: [LanguageViewModel] = []
let components = line.components(separatedBy: ",")
for comp in components {
let trimmed = comp.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
var hasSpeaks = true
// Check for understands-only patterns
if trimmed.lowercased().contains("understand") || trimmed.lowercased().contains("(silently)") {
hasSpeaks = false
}
// Remove trailing range annotation like " (150 ft.)" → keep just the name
let parts = trimmed.split(separator: " (")
var nameOnly = trimmed
if parts.count >= 2 {
nameOnly = String(parts.first ?? "")
}
result.append(LanguageViewModel(nameOnly, hasSpeaks))
}
return result
}
/// Determine ChallengeRating from text (Android Monster.crDisplay → challengeRating conversion).
static func challengeRating(for text: String?) -> ChallengeRating? {
guard let crText = text else { return .zero }
// Handle common text aliases
switch crText.lowercased() {
case "half": return .oneHalf
case "quarter", "one quarter": return .oneQuarter
case "eighth": return .oneEighth
case "three halves": return .two
default: break
}
// Try numeric parse first (e.g. "0" → zero, "3" → three)
if let val = Int(crText), let cr = ChallengeRating(rawValue: String(val)) {
return cr
}
// Handle fractions like "1/2", "5/2"
if let rangeIdx = crText.range(of: "/") {
let numStr = crText[..<rangeIdx.lowerBound]
let denStr = crText[rangeIdx.upperBound...]
if let num = Double(numStr), let den = Double(denStr), den != 0 {
let ratio = num / den
switch ratio {
case 0.125: return .oneEighth
case 0.25: return .oneQuarter
case 0.5: return .oneHalf
case 1.5: return .two
default: break
}
}
}
return .zero
}
/// Process cardTraits from Android Monster.exportCards (grouped by actionSection).
static func processCardTraits(_ card: MonsterCard, into monster: MonsterViewModel) {
guard let traits = card.cardTraits else { return }
for trait in traits {
guard let displayName = trait.displayName, !displayName.isEmpty else { continue }
if let isHeader = trait.isActionHeader, isHeader == 1 {
// Section header like "Spellcasting", "Legendary Actions" etc.
if displayName.lowercased().contains("legendary") {
monster.legendaryActions.append(AbilityViewModel(displayName, trait.desc ?? ""))
} else if displayName.lowercased().contains("lair") || displayName.lowercased().contains("mythic") {
monster.lairActions.append(AbilityViewModel(displayName, trait.desc ?? ""))
} else {
// Action section header gets added to actions as a category label.
monster.actions.append(AbilityViewModel(displayName, trait.desc ?? ""))
}
} else if let section = trait.actionSection {
switch section {
case 0: monster.bonusActions .append(AbilityViewModel(displayName, trait.desc ?? ""))
case 1: monster.actions .append(AbilityViewModel(displayName, trait.desc ?? ""))
case 2: monster.reactions .append(AbilityViewModel(displayName, trait.desc ?? ""))
default: break
}
} else {
// No section key — add to actions by default (standard Android behavior)
monster.actions.append(AbilityViewModel(displayName, trait.desc ?? ""))
}
}
}
}
// MARK: - Error type
enum BinderParseError: LocalizedError {
case unsupportedEncoding
case invalidFormat(String)
var errorDescription: String? {
switch self {
case .unsupportedEncoding: return "Binder import requires UTF-8 JSON"
case .invalidFormat(let msg): return "Invalid Binder format: \(msg)"
}
}
}

View File

@@ -0,0 +1,200 @@
//
// BinderImporterTest.swift
// MonsterCardsTests
//
// Unit tests for BinderImporter — validates that canImport correctly detects
// Binder JSON in both collection and single-card formats, and that all monster
// fields round-trip through the importer's mapping logic.
import XCTest
@testable import MonsterCards
final class BinderImporterTest: XCTestCase {
// MARK: - canImport tests
func testCanImportWithSchemaDetection() {
let json = """
{"$schema": "https://majinnaibu.com/schemas/binder.schema.json", "collections": []}
"""
XCTAssertTrue(BinderImporter.canImport(json), "Should detect $schema binder field")
}
func testCanImportWithCollectionsArray() {
let json = """
{"collections": [{"name": "Test", "cards": []}]}
"""
XCTAssertTrue(BinderImporter.canImport(json), "Should detect collections array")
}
func testCannotImportOtherSchema() {
let json = """
{"$schema": "https://example.com/other.schema.json", "collections": []}
{"name": "Goblin"}
"""
// Note: the second part doesn't have collections key but is not a binder file
XCTAssertFalse(BinderImporter.canImport("{\"foo\":\"bar\"}"), "Should not detect random JSON")
}
func testCanImportEmptyJson() {
XCTAssertFalse(BinderImporter.canImport(""), "Should not detect empty string")
XCTAssertFalse(BinderImporter.canImport("{not json}"), "Should not detect invalid JSON")
}
// MARK: - parse tests
func testParseSingleCardFullRoundTrip() {
let json = """
{
"name": "Dragons of the Night",
"displayName": "Acererak the Destiler",
"displayType": "Lich",
"alignment": "Lawful Evil",
"sizeAbbrv": "M",
"hitDice": "138d10",
"shieldAcNum": 2,
"baseAcFormula": "Unarmored Defense (+1)",
"shieldName": "Ring of Protection",
"speedText": "30 feet, fly 60 ft.",
"primaryAttributes": [
{"abbrv": "str", "value": 27, "modifier": "+8"},
{"abbrv": "dex", "value": 16, "modifier": "+3"},
{"abbrv": "con", "value": 25, "modifier": "+7"},
{"abbrv": "int", "value": 29, "modifier": "+9"},
{"abbrv": "wis", "value": 24, "modifier": "+7"},
{"abbrv": "cha", "value": 28, "modifier": "+9"}
],
"proSavingThrows": ["str", "dex"],
"proficiencySkills": [
{"name": "Arcana", "expertise": 1},
{"name": "Perception", "expertise": 0}
],
"specialdamage": [{"source": "Radiant", "value": "+9 hit", "target": "targets of your feature"}],
"proPerception": 0,
"languageLine": "Common, Deep Speech (150 ft.)",
"sensesDescription": "Blindsight 60ft., Darkvision 30ft.",
"crDisplay": "Half"
}
"""
let monster = try? BinderImporter.parse(json)
XCTAssertNotNil(monster, "Should successfully parse valid single-card JSON")
guard let m = monster else { return XCTFail("Expected non-nil parsed result") }
// Verify name fallback to displayName
XCTAssertEqual(m.name, "Acererak the Destiler", "Name should use displayName")
XCTAssertEqual(m.type, "Lich", "displayType maps to type")
XCTAssertEqual(m.alignment, "Lawful Evil", "alignment preserved exactly")
XCTAssertEqual(m.size, "Medium", "M → Medium")
// HP - hitDice string parse: "138d10" → 138
XCTAssertEqual(m.hitDice, 138)
XCTAssertFalse(m.hasCustomHP, "Should not have custom HP when no customHpText field present")
// AC / armor
XCTAssertEqual(m.shieldBonus, 2)
XCTAssertTrue(m.hasShield, "shieldAcNum is 2 → hasShield = true")
// Speed breakdown: "30 feet, fly 60 ft."
XCTAssertEqual(m.walkSpeed, 30, "Should extract walk speed of 30")
XCTAssertEqual(m.flySpeed, 60, "Should extract fly speed of 60")
// Ability scores (primaryAttributes[0…5])
XCTAssertEqual(m.strengthScore, 27)
XCTAssertEqual(m.dexterityScore, 16)
XCTAssertEqual(m.constitutionScore, 25)
XCTAssertEqual(m.intelligenceScore, 29)
XCTAssertEqual(m.wisdomScore, 24)
XCTAssertEqual(m.charismaScore, 28)
// Saving throws proficiency (str, dex → .proficient)
XCTAssertEqual(m.strengthSavingThrowProficiency, .proficient)
XCTAssertEqual(m.dexteritySavingThrowProficiency, .proficient)
XCTAssertEqual(m.constitutionSavingThrowProficiency, .none)
// Skills: Arcana expertise=1 → expertise, Perception expertise=0 → proficient
XCTAssertNotNil(m.skills.first(where: { $0.name == "Arcana" }))
let arcanaSkill = m.skills.first { $0.abilityScore? == .intelligence }
XCTAssertEqual(arcanaSkill?.proficiency, .expertise)
// Languages
XCTAssertEqual(m.languages.count, 2)
XCTAssertTrue(m.languages.contains(where: { $0.name == "Common" && $0.speaks }))
XCTAssertFalse(m.languages.contains(where: { $0.name == "Deep Speech" && $0.speaks }))
// Senses
XCTAssertTrue(m.senses.count >= 2)
XCTAssertTrue(m.senses.first(where: { $0.name.contains("Blindness") }) != nil)
// Challenge rating from text: "Half" → oneHalf
XCTAssertEqual(m.challengeRating, .oneHalf, "Should parse 'Half' as one_half CR")
}
func testParseCollectionFormat() {
let json = """
{
"$schema": "https://majinnaibu.com/schemas/binder.schema.json",
"schemaVersion": 1,
"collections": [
{
"name": "Villains of the Realm",
"cards": [
{
"name": "Balrog",
"displayName": "Balrog of Moria",
"displayType": "Demon",
"alignment": "Neutral Evil",
"sizeAbbrv": "L",
"hitDice": "104d10",
"proSavingThrows": ["con"],
"primaryAttributes": [
{"abbrv": "str", "value": 30},
{"abbrv": "dex", "value": 18},
{"abbrv": "con", "value": 25},
{"abbrv": "int", "value": 18},
{"abbrv": "wis", "value": 16},
{"abbrv": "cha", "value": 20}
]
}
]
}
]
}
"""
let monster = try? BinderImporter.parse(json)
XCTAssertNotNil(monster, "Should successfully parse valid collection JSON")
guard let m = monster else { return XCTFail("Expected non-nil parsed result") }
XCTAssertEqual(m.name, "Balrog of Moria", "Name should use displayName from first card in first collection")
XCTAssertEqual(m.type, "Demon", "displayType preserved")
XCTAssertEqual(m.size, "Large", "L maps to Large")
}
func testParseInvalidJsonThrows() {
XCTAssertThrowsError(try BinderImporter.parse("not valid json string")) { error in
XCTAssertTrue(error is BinderParseError)
if let e = error as? BinderParseError {
switch e {
case .invalidFormat: break // expected
default: XCTFail("Expected invalidFormat error")
}
}
}
}
func testDefaultAbilityScoresFailsafe() {
let json = """
{"collections": [{"name": "X", "cards": [{"name": "No Stats"}]}]}
"""
guard let m = try? BinderImporter.parse(json) else {
return XCTFail("Should succeed even with minimal MonsterCard data")
}
// Default ability score is 10 per MonsterViewModel init convention
XCTAssertEqual(m.strengthScore, 10)
}
}