Zum Inhalt

apple/PhysarumApp/Tests/AppStateMigrationTests.swift

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