Add tank catalog with USDZ models and tank selection
Introduce TankDefinition/TankCatalog loaded from Tanks.json, bundle the Drachengas USDZ tank models, add tank selection and preview views, and update the AR experience, editor and project views to use the catalog. Includes tests for catalog loading and model selection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
134
GasTankARTests/TankCatalogTests.swift
Normal file
134
GasTankARTests/TankCatalogTests.swift
Normal file
@ -0,0 +1,134 @@
|
||||
//
|
||||
// TankCatalogTests.swift
|
||||
// GasTankARTests
|
||||
//
|
||||
// Created by Dennis Nemec on 14.09.26.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import GasTankAR
|
||||
|
||||
/// Prüft das Laden/Decodieren des Tank-Katalogs aus JSON sowie die abgeleiteten
|
||||
/// Abfragen (verfügbare Größen/Farben, Auflösung & Normalisierung der Auswahl).
|
||||
struct TankCatalogTests {
|
||||
|
||||
// Kleiner, in sich konsistenter Beispielkatalog für Query-Tests.
|
||||
private static let sampleJSON = """
|
||||
{
|
||||
"sizeDisplayNames": { "t1_2": "1,2 t", "t2_9": "2,9 t" },
|
||||
"colorDisplayNames": { "white": "Weiß", "green": "Hellgrün", "olive": "Olivgrün" },
|
||||
"tanks": [
|
||||
{ "size": "t1_2", "color": "white", "placement": "aboveGround", "modelPath": "DG-2700_wh.usdz" },
|
||||
{ "size": "t1_2", "color": "green", "placement": "aboveGround", "modelPath": "DG-2700_mi.usdz" },
|
||||
{ "size": "t1_2", "color": "olive", "placement": "belowGround", "modelPath": "DG-2700_ol.usdz" },
|
||||
{ "size": "t2_9", "color": "white", "placement": "aboveGround", "modelPath": "DG-6400_wh.usdz" },
|
||||
{ "size": "t2_9", "color": "olive", "placement": "belowGround", "modelPath": "DG-6400_ol.usdz" }
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
private static func sampleCatalog() throws -> TankCatalog {
|
||||
let loader = DataTankCatalogLoader(data: Data(sampleJSON.utf8))
|
||||
return TankCatalog(file: try loader.load())
|
||||
}
|
||||
|
||||
// MARK: - Decoding
|
||||
|
||||
@Test func decodesAllFieldsFromJSON() throws {
|
||||
let catalog = try Self.sampleCatalog()
|
||||
let def = try #require(catalog.definitions.first)
|
||||
|
||||
#expect(def.size == .t1_2)
|
||||
#expect(def.color == .white)
|
||||
#expect(def.placement == .aboveGround)
|
||||
#expect(def.modelPath == "DG-2700_wh.usdz")
|
||||
#expect(def.modelResourceName == "DG-2700_wh")
|
||||
#expect(def.modelFileExtension == "usdz")
|
||||
}
|
||||
|
||||
@Test func decodesDisplayNameMappings() throws {
|
||||
let catalog = try Self.sampleCatalog()
|
||||
#expect(catalog.sizeDisplayNames["t1_2"] == "1,2 t")
|
||||
#expect(catalog.colorDisplayNames["olive"] == "Olivgrün")
|
||||
}
|
||||
|
||||
@Test func displayNameFallsBackToRawValueWhenMissing() {
|
||||
// Katalog ohne Mappings → Anzeigename ist der rawValue.
|
||||
let catalog = TankCatalog(definitions: [])
|
||||
#expect(catalog.displayName(for: TankSize.t2_1) == "t2_1")
|
||||
#expect(catalog.displayName(for: TankColor.green) == "green")
|
||||
}
|
||||
|
||||
@Test func missingResourceThrows() {
|
||||
let loader = BundleTankCatalogLoader(
|
||||
bundle: .main,
|
||||
resourceName: "Datei-die-nicht-existiert",
|
||||
resourceExtension: "json"
|
||||
)
|
||||
#expect(throws: TankCatalogLoaderError.self) {
|
||||
_ = try loader.load()
|
||||
}
|
||||
}
|
||||
|
||||
@Test func emptyCatalogWhenLoaderFails() {
|
||||
let loader = BundleTankCatalogLoader(resourceName: "gibt-es-nicht")
|
||||
let catalog = TankCatalog(loader: loader)
|
||||
#expect(catalog.definitions.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Abgeleitete Abfragen
|
||||
|
||||
@Test func availableColorsDependOnPlacement() throws {
|
||||
let catalog = try Self.sampleCatalog()
|
||||
#expect(catalog.colors(for: .t1_2, placement: .aboveGround) == [.white, .green])
|
||||
#expect(catalog.colors(for: .t1_2, placement: .belowGround) == [.olive])
|
||||
}
|
||||
|
||||
@Test func availableSizesPerPlacement() throws {
|
||||
let catalog = try Self.sampleCatalog()
|
||||
#expect(catalog.sizes(for: .aboveGround) == [.t1_2, .t2_9])
|
||||
#expect(catalog.sizes(for: .belowGround) == [.t1_2, .t2_9])
|
||||
}
|
||||
|
||||
@Test func placementsPreserveOrder() throws {
|
||||
let catalog = try Self.sampleCatalog()
|
||||
#expect(catalog.placements == [.aboveGround, .belowGround])
|
||||
}
|
||||
|
||||
@Test func displayNamesResolveFromCatalog() throws {
|
||||
let catalog = try Self.sampleCatalog()
|
||||
#expect(catalog.displayName(for: TankSize.t2_9) == "2,9 t")
|
||||
#expect(catalog.displayName(for: TankColor.olive) == "Olivgrün")
|
||||
}
|
||||
|
||||
// MARK: - Auflösung & Normalisierung
|
||||
|
||||
@Test func definitionMatchesExactSelection() throws {
|
||||
let catalog = try Self.sampleCatalog()
|
||||
let tank = Tank(size: .t2_9, color: .olive, placement: .belowGround)
|
||||
#expect(catalog.definition(for: tank)?.modelPath == "DG-6400_ol.usdz")
|
||||
}
|
||||
|
||||
@Test func normalizesInconsistentColorForPlacement() throws {
|
||||
let catalog = try Self.sampleCatalog()
|
||||
// Weiß gibt es unterirdisch nicht → sollte auf Olivgrün korrigiert werden.
|
||||
let tank = Tank(size: .t2_9, color: .white, placement: .belowGround)
|
||||
let normalized = catalog.normalized(tank)
|
||||
#expect(normalized.color == .olive)
|
||||
#expect(catalog.definition(for: normalized) != nil)
|
||||
}
|
||||
|
||||
@Test func resolvedDefinitionHealsLegacySelection() throws {
|
||||
let catalog = try Self.sampleCatalog()
|
||||
// Altdaten: unterirdisch + weiß → muss trotzdem das Olivgrün-Modell liefern.
|
||||
let legacy = Tank(size: .t2_9, color: .white, placement: .belowGround)
|
||||
#expect(catalog.resolvedDefinition(for: legacy)?.modelPath == "DG-6400_ol.usdz")
|
||||
}
|
||||
|
||||
@Test func validSelectionStaysUnchanged() throws {
|
||||
let catalog = try Self.sampleCatalog()
|
||||
let tank = Tank(size: .t1_2, color: .green, placement: .aboveGround)
|
||||
#expect(catalog.normalized(tank) == tank)
|
||||
}
|
||||
}
|
||||
74
GasTankARTests/TankModelSelectionTests.swift
Normal file
74
GasTankARTests/TankModelSelectionTests.swift
Normal file
@ -0,0 +1,74 @@
|
||||
//
|
||||
// TankModelSelectionTests.swift
|
||||
// GasTankARTests
|
||||
//
|
||||
// Created by Dennis Nemec on 14.09.26.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
import RealityKit
|
||||
@testable import GasTankAR
|
||||
|
||||
/// Prüft, dass für eine Tank-Auswahl im AR-Modus die richtige Datei geladen wird:
|
||||
/// Der Katalog aus dem echten App-Bundle (`Tanks.json`) muss vollständig sein,
|
||||
/// jede referenzierte Datei muss existieren und die AR-Factory muss das echte
|
||||
/// Modell (statt des Ersatzkörpers) erzeugen.
|
||||
struct TankModelSelectionTests {
|
||||
|
||||
/// Katalog, geladen aus dem realen App-Bundle (dem Test-Host).
|
||||
private static func bundleCatalog() -> TankCatalog {
|
||||
let bundle = Bundle(for: TankEntityFactory.self)
|
||||
return TankCatalog(loader: BundleTankCatalogLoader(bundle: bundle))
|
||||
}
|
||||
|
||||
// MARK: - Katalog aus dem Bundle
|
||||
|
||||
@Test func bundleCatalogContainsAllNineVariants() {
|
||||
let catalog = Self.bundleCatalog()
|
||||
#expect(catalog.definitions.count == 9)
|
||||
// 3 Größen, jeweils oberirdisch weiß/grün + unterirdisch oliv.
|
||||
#expect(catalog.sizes(for: .aboveGround).count == 3)
|
||||
#expect(catalog.sizes(for: .belowGround).count == 3)
|
||||
}
|
||||
|
||||
@Test func everyModelFileExistsInBundle() {
|
||||
let bundle = Bundle(for: TankEntityFactory.self)
|
||||
let catalog = Self.bundleCatalog()
|
||||
|
||||
for def in catalog.definitions {
|
||||
let url = bundle.url(forResource: def.modelResourceName, withExtension: def.modelFileExtension)
|
||||
#expect(url != nil, "Modell-Datei fehlt im Bundle: \(def.modelPath)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auswahl → richtige Datei
|
||||
|
||||
@Test func aboveGroundColorsMapToExpectedFiles() {
|
||||
let catalog = Self.bundleCatalog()
|
||||
let white = catalog.resolvedDefinition(for: Tank(size: .t1_2, color: .white, placement: .aboveGround))
|
||||
let green = catalog.resolvedDefinition(for: Tank(size: .t1_2, color: .green, placement: .aboveGround))
|
||||
#expect(white?.modelPath == "DG-2700_wh.usdz")
|
||||
#expect(green?.modelPath == "DG-2700_mi.usdz")
|
||||
}
|
||||
|
||||
@Test func belowGroundMapsToUndergroundFile() {
|
||||
let catalog = Self.bundleCatalog()
|
||||
let def = catalog.resolvedDefinition(for: Tank(size: .t2_9, color: .olive, placement: .belowGround))
|
||||
#expect(def?.modelPath == "DG-6400_ol.usdz")
|
||||
}
|
||||
|
||||
// MARK: - AR-Factory lädt das echte Modell (kein Ersatzkörper)
|
||||
|
||||
@MainActor
|
||||
@Test func factoryLoadsRealModelNotFallbackCube() {
|
||||
// Repräsentative oberirdische Konfiguration: der Tank ist deutlich länger
|
||||
// als der 0,3 m große Ersatzwürfel, der nur bei fehlendem Modell entsteht.
|
||||
let tank = Tank(size: .t2_9, color: .white, placement: .aboveGround)
|
||||
let factory = TankEntityFactory(catalog: Self.bundleCatalog())
|
||||
let container = factory.makeContainer(for: tank)
|
||||
|
||||
let bounds = container.visualBounds(relativeTo: nil)
|
||||
#expect(bounds.extents.x > 0.5, "Es wurde vermutlich der Ersatzwürfel geladen statt des echten Modells.")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user