import XCTest
import PhysarumEngine
@testable import Physarum
final class SlotStoreApplyTests: 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)
}
func testInitSeedsFactoryIntoBackend() {
let backend = InMemorySlotBackend()
let store = SlotStore(backend: backend, factory: factory(["A", "B"], 1))
XCTAssertEqual(store.slots.filled.map { $0.name }, ["A", "B"])
XCTAssertEqual(PresetSlots(payload: backend.read()).filled.count, 2) // persisted
}
func testSaveOverwritePersistsAndMarksOwned() {
let backend = InMemorySlotBackend()
let store = SlotStore(backend: backend, factory: factory(["A", "B"], 1))
store.save(preset("Mine"), toIndex: 0)
XCTAssertEqual(store.slots.preset(at: 0)?.name, "Mine")
XCTAssertTrue(store.slots.isUserOwned(0))
XCTAssertEqual(PresetSlots(payload: backend.read()).preset(at: 0)?.name, "Mine")
}
func testExternalChangeReloadsFromBackend() {
let backend = InMemorySlotBackend()
let store = SlotStore(backend: backend, factory: factory(["A"], 1))
var other = PresetSlots(payload: backend.read())
other.fill(index: 5, preset: preset("FromPhone"), userOwned: true)
backend.write(other.payload)
store.reloadFromBackend()
XCTAssertEqual(store.slots.preset(at: 5)?.name, "FromPhone")
}
func testResetToFactory() {
let backend = InMemorySlotBackend()
let store = SlotStore(backend: backend, factory: factory(["A", "B"], 1))
store.save(preset("Mine"), toIndex: 0)
store.resetToFactory()
XCTAssertEqual(store.slots.preset(at: 0)?.name, "A")
XCTAssertFalse(store.slots.isUserOwned(0))
}
func testInitWritesOnFirstSeedButNotOnUnchangedRelaunch() {
let backend = CountingSlotBackend()
_ = SlotStore(backend: backend, factory: factory(["A", "B"], 1)) // first seed → one write
XCTAssertEqual(backend.writes, 1)
_ = SlotStore(backend: backend, factory: factory(["A", "B"], 1)) // already seeded, same version
XCTAssertEqual(backend.writes, 1) // no write-on-every-launch
}
func testReloadDoesNotWriteBack() {
let backend = CountingSlotBackend()
let store = SlotStore(backend: backend, factory: factory(["A"], 1))
let writesAfterInit = backend.writes
var other = PresetSlots(payload: backend.stored)
other.fill(index: 5, preset: preset("FromPhone"), userOwned: true)
backend.stored = other.payload // simulate another device's write (bypasses the counter)
store.reloadFromBackend()
XCTAssertEqual(store.slots.preset(at: 5)?.name, "FromPhone")
XCTAssertEqual(backend.writes, writesAfterInit) // reload adopted the change without echoing a write
}
func testSaveClearsThumbnailBeforePersist() {
let backend = InMemorySlotBackend()
let store = SlotStore(backend: backend, factory: factory(["A"], 1))
var p = preset("Mine"); p.thumbnail = "should-not-sync"
store.save(p, toIndex: 0)
XCTAssertNil(store.slots.preset(at: 0)?.thumbnail)
XCTAssertNil(backend.read().entries.first { $0.index == 0 }?.preset.thumbnail)
}
// The actual app-update relaunch path: a SlotStore re-created over an already-seeded backend
// with a NEWER factory version must skip user-owned slots, refresh untouched ones, and fill
// newly-added empty slots. (PresetSlots proves this at the value level; this pins it through the
// SlotStore + backend wiring.)
func testRelaunchWithNewerFactorySkipsUserOwnedFillsUntouched() {
let backend = InMemorySlotBackend()
let store1 = SlotStore(backend: backend, factory: factory(["A", "B"], 1))
store1.save(preset("Mine"), toIndex: 0) // slot 0 → user-owned
// Relaunch over the SAME backend with a newer factory version (an extra preset).
let store2 = SlotStore(backend: backend, factory: factory(["A2", "B2", "C2"], 2))
XCTAssertEqual(store2.slots.preset(at: 0)?.name, "Mine") // user-owned slot untouched by the update
XCTAssertEqual(store2.slots.preset(at: 1)?.name, "B2") // untouched slot takes the new default
XCTAssertEqual(store2.slots.preset(at: 2)?.name, "C2") // new default fills the now-available slot
XCTAssertEqual(store2.slots.seedVersion, 2)
}
func testKeepOnlyPinnedCompactsAndPersists() {
let backend = InMemorySlotBackend()
let store = SlotStore(backend: backend, factory: factory(["A", "B", "C"], 1))
store.pin("slot-02")
store.keepOnlyPinned()
XCTAssertEqual(store.slots.filled.map { $0.name }, ["C"]) // only the pinned one
XCTAssertEqual(store.slots.pinned, ["slot-00"]) // remapped to its new id
XCTAssertEqual(PresetSlots(payload: backend.read()).filled.map { $0.name }, ["C"]) // persisted
}
}
/// A `SlotBackend` that counts writes, to prove the store doesn't amplify iCloud-KVS writes.
private final class CountingSlotBackend: SlotBackend {
var stored = SlotPayload()
private(set) var writes = 0
var onExternalChange: (() -> Void)?
var onLoaded: ((Bool) -> Void)?
var isLoaded: Bool { true }
func read() -> SlotPayload { stored }
func write(_ payload: SlotPayload) { stored = payload; writes += 1 }
func flush() {}
}