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