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

@@ -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)
}
}