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:
200
iOS/MonsterCardsTests/BinderImporterTest.swift
Normal file
200
iOS/MonsterCardsTests/BinderImporterTest.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user