Zum Inhalt

apple/PhysarumApp/Tests/SlotStoreLoadGatingTests.swift

import XCTest
import PhysarumEngine
@testable import Physarum

/// The fix for the iCloud-KVS initial-sync race: SlotStore must NOT seed factory over / persist factory
/// on top of a not-yet-downloaded cloud (which clobbered slots + wiped pins). It seeds in memory for the
/// UI, then reconciles authoritatively once the backend reports it is loaded.
final class SlotStoreLoadGatingTests: XCTestCase {
    private func preset(_ name: String) -> Preset {
        var p = PresetCatalog.fallbackPreset; p.name = name; p.builtIn = false; return p
    }
    private func factory(_ names: [String], _ v: Int) -> PresetSlots.Factory {
        .init(presets: names.map { preset($0) }, version: v)
    }

    /// A backend whose initial load is deferred (like KVS's async download). read() returns the local
    /// view; completeLoad simulates the download arriving.
    private final class DeferredSlotBackend: SlotBackend {
        private var stored = SlotPayload()
        private(set) var writes = 0
        var onExternalChange: (() -> Void)?
        var onLoaded: ((Bool) -> Void)?
        private(set) var isLoaded = false
        func read() -> SlotPayload { stored }
        func write(_ payload: SlotPayload) { stored = payload; writes += 1 }
        func flush() {}
        func completeLoad(with payload: SlotPayload, authoritative: Bool) {
            stored = payload
            guard !isLoaded else { return }
            isLoaded = true
            onLoaded?(authoritative)
        }
        func simulateExternalChange(with payload: SlotPayload) { stored = payload; onExternalChange?() }
    }

    // THE bug regression: before load, the store must not persist, and on load it must ADOPT the cloud
    // (preserving the user's pins) rather than seed-clobber it with factory.
    func testDeferredLoadAdoptsCloudWithoutClobberOrPinWipe() {
        let backend = DeferredSlotBackend()
        let store = SlotStore(backend: backend, factory: factory(["F0", "F1"], 1))
        XCTAssertEqual(backend.writes, 0)                       // nothing persisted before load → no clobber

        var cloud = PresetSlots()
        cloud.seed(factory(["A", "B"], 1))
        cloud.overwrite(slotID: "slot-00", preset: preset("Mine"))   // a user-owned slot
        cloud.pin("slot-01")                                          // and a pin
        backend.completeLoad(with: cloud.payload, authoritative: true)

        XCTAssertEqual(store.slots.preset(at: 0)?.name, "Mine")  // adopted the cloud content
        XCTAssertEqual(store.slots.pinned, ["slot-01"])          // pins NOT wiped
        XCTAssertEqual(backend.writes, 0)                        // synced (seedVersion 1) → no write-back
    }

    func testTrueFirstRunSeedsAndPersistsOnAuthoritativeLoad() {
        let backend = DeferredSlotBackend()
        let store = SlotStore(backend: backend, factory: factory(["A", "B"], 1))
        XCTAssertEqual(backend.writes, 0)
        backend.completeLoad(with: SlotPayload(), authoritative: true)   // cloud genuinely empty
        XCTAssertEqual(store.slots.filled.map { $0.name }, ["A", "B"])
        XCTAssertEqual(backend.writes, 1)                        // established the cloud once
    }

    func testFallbackTimeoutSeedsInMemoryWithoutPersisting() {
        let backend = DeferredSlotBackend()
        let store = SlotStore(backend: backend, factory: factory(["A"], 1))
        backend.completeLoad(with: SlotPayload(), authoritative: false)   // timeout, not confirmed
        XCTAssertEqual(store.slots.filled.map { $0.name }, ["A"])  // UI seeded
        XCTAssertEqual(backend.writes, 0)                          // but NOT pushed upward (no clobber)
    }

    func testReloadIgnoresDegenerateEmptyRead() {
        let backend = DeferredSlotBackend()
        var seeded = PresetSlots(); seeded.seed(factory(["A", "B"], 1))
        let store = SlotStore(backend: backend, factory: factory(["A", "B"], 1))
        backend.completeLoad(with: seeded.payload, authoritative: true)
        XCTAssertEqual(store.slots.filled.count, 2)
        backend.simulateExternalChange(with: SlotPayload())        // a partial-sync empty read
        XCTAssertEqual(store.slots.filled.count, 2)                // ignored — not clobbered to empty
    }

    // The legacy migration must wait for load, so it never persists local customs over un-synced cloud.
    func testMigrationDeferredUntilLoaded() throws {
        let backend = DeferredSlotBackend()
        let presetStore = PresetStore(baseDir: URL(fileURLWithPath: NSTemporaryDirectory())
            .appendingPathComponent("phys-mig-gate-\(UUID().uuidString)", isDirectory: true))
        var legacy = PresetCatalog.fallbackPreset
        legacy.id = "custom-old"; legacy.name = "Old"; legacy.builtIn = false
        try presetStore.save(legacy)

        let s = AppState(slotStore: SlotStore(backend: backend, factory: factory([], 1)), store: presetStore)
        let defaults = UserDefaults(suiteName: "phys-mig-gate-\(UUID().uuidString)")!
        s.migrateLegacyCustomsIfNeeded(defaults: defaults)
        XCTAssertFalse(defaults.bool(forKey: "slotMigrationDone"))   // not yet — backend not loaded
        XCTAssertFalse(s.presets.contains { $0.name == "Old" })

        backend.completeLoad(with: SlotPayload(), authoritative: true)
        XCTAssertTrue(defaults.bool(forKey: "slotMigrationDone"))    // ran once loaded
        XCTAssertTrue(s.presets.contains { $0.name == "Old" })       // migrated into a slot
    }
}