import XCTest
import PhysarumEngine
@testable import Physarum
/// Task 10: the one-shot migration of pre-slot custom presets (old PresetStore JSONs) into empty
/// slots, run once per device, with overflow exported rather than dropped.
final class AppStateMigrationTests: XCTestCase {
/// A factory preset (its id is rewritten to its slot id on fill, so a shared id is fine here).
private func preset(_ name: String) -> Preset {
var p = PresetCatalog.fallbackPreset; p.name = name; p.builtIn = false; return p
}
/// A legacy custom written to the PresetStore — needs a DISTINCT id or the JSON files collide.
private func legacy(_ name: String) -> Preset {
var p = PresetCatalog.fallbackPreset
p.id = "custom-\(name.lowercased().replacingOccurrences(of: " ", with: "-"))"
p.name = name; p.builtIn = false
return p
}
private func freshDefaults() -> UserDefaults {
UserDefaults(suiteName: "phys-migrate-\(UUID().uuidString)")!
}
private func tempStore() -> PresetStore {
PresetStore(baseDir: URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("phys-mig-\(UUID().uuidString)", isDirectory: true))
}
private func state(store: PresetStore, factory names: [String]) -> AppState {
AppState(slotStore: SlotStore(backend: InMemorySlotBackend(),
factory: .init(presets: names.map { preset($0) }, version: 1)),
store: store)
}
func testMigratesLegacyCustomsIntoEmptySlotsOnce() throws {
let store = tempStore()
try store.save(legacy("Legacy A"))
try store.save(legacy("Legacy B"))
let s = state(store: store, factory: ["Factory0"])
let d = freshDefaults()
s.migrateLegacyCustomsIfNeeded(defaults: d)
XCTAssertEqual(s.presets.map { $0.name }, ["Factory0", "Legacy A", "Legacy B"])
XCTAssertEqual(s.presets[1].id, "slot-01") // re-id'd to their slot ids
XCTAssertTrue(s.slotStore.slots.isUserOwned(1)) // migrated customs are the user's
XCTAssertTrue(s.slotStore.slots.isUserOwned(2))
XCTAssertFalse(s.slotStore.slots.isUserOwned(0)) // the factory slot is not
// Idempotent: a later launch is a no-op even though a new legacy custom now exists.
try store.save(legacy("Legacy C"))
s.migrateLegacyCustomsIfNeeded(defaults: d)
XCTAssertEqual(s.presets.count, 3) // "Legacy C" NOT migrated — flag set
}
func testNoLegacyCustomsStillSetsFlag() {
let s = state(store: tempStore(), factory: ["F0"])
let d = freshDefaults()
s.migrateLegacyCustomsIfNeeded(defaults: d)
XCTAssertTrue(d.bool(forKey: "slotMigrationDone"))
XCTAssertEqual(s.presets.count, 1) // nothing to migrate
}
func testMigrationIntoFullFactoryDisplacesFactorySlots() {
let store = tempStore()
try? store.save(legacy("Mine"))
// The factory ships all 24 slots — the real v1.x→v2.0 upgrade situation.
let s = state(store: store, factory: (0..<PresetSlots.count).map { "F\($0)" })
XCTAssertNil(s.firstEmptySlotIndex)
s.migrateLegacyCustomsIfNeeded(defaults: freshDefaults())
XCTAssertEqual(s.presets[0].name, "Mine") // the custom is visible (displaced F0)
XCTAssertTrue(s.slotStore.slots.isUserOwned(0))
XCTAssertEqual(s.presets[1].name, "F1") // higher factory slots survive
XCTAssertFalse(FileManager.default.fileExists(atPath: store.legacyExportURL.path)) // nothing exported
}
func testOverflowExportedWhenMoreCustomsThanSlots() throws {
let store = tempStore()
for i in 0..<(PresetSlots.count + 1) { try store.save(legacy("C\(i)")) } // 25 customs
let s = state(store: store, factory: []) // empty factory → 24 fill, 1 overflows
s.migrateLegacyCustomsIfNeeded(defaults: freshDefaults())
XCTAssertEqual(s.presets.count, PresetSlots.count) // all 24 slots filled
let exported = try JSONDecoder().decode([Preset].self, from: Data(contentsOf: store.legacyExportURL))
XCTAssertEqual(exported.count, 1) // the 25th kept, not dropped
}
}