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:
Dennis Nemec
2026-09-26 19:12:39 +02:00
parent 992bb32025
commit b8536d4182
63 changed files with 2317 additions and 188 deletions

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

View 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.")
}
}