import XCTest
import PhysarumEngine
@testable import Physarum
/// AppState routed through the slot model (Task 9 cutover): `presets` = filled slots, save/clear/
/// import/reset/pin go through `SlotStore`, and `advance` cycles the pinned rotation.
final class AppStateSlotsTests: XCTestCase {
private func preset(_ name: String) -> Preset {
var p = PresetCatalog.fallbackPreset; p.name = name; p.builtIn = false; return p
}
private func state(_ names: [String] = ["A", "B", "C"]) -> AppState {
let factory = PresetSlots.Factory(presets: names.map { preset($0) }, version: 1)
return AppState(slotStore: SlotStore(backend: InMemorySlotBackend(), factory: factory))
}
func testPresetsAreFilledSlotsInOrderAndSelectionSeeded() {
let s = state(["A", "B"])
XCTAssertEqual(s.presets.map { $0.id }, ["slot-00", "slot-01"])
XCTAssertEqual(s.presets.map { $0.name }, ["A", "B"])
XCTAssertEqual(s.selectedID, "slot-00") // seeded selection = first filled slot
XCTAssertEqual(s.selectedSlotIndex, 0)
}
func testSaveToSlotOverwritesSelectsAndClearsDirty() {
let s = state(["A", "B"])
s.select(id: "slot-00")
s.edited.bloomIntensity = 0.42
XCTAssertTrue(s.isDirty)
s.saveToSlot(index: 0, name: "Mine", tags: ["x"], tagSignal: .pearl)
XCTAssertEqual(s.selectedID, "slot-00")
XCTAssertEqual(s.presets[0].name, "Mine")
XCTAssertEqual(s.presets[0].params.bloomIntensity, 0.42, accuracy: 1e-6)
XCTAssertTrue(s.slotStore.slots.isUserOwned(0))
XCTAssertFalse(s.isDirty) // baseline reset to edited
}
func testSaveToEmptySlotFills() {
let s = state(["A"])
XCTAssertEqual(s.firstEmptySlotIndex, 1)
s.saveToSlot(index: 1, name: "New", tags: [], tagSignal: .pearl)
XCTAssertEqual(s.presets.map { $0.id }, ["slot-00", "slot-01"])
XCTAssertEqual(s.presets[1].name, "New")
}
func testClearSlotEmptiesAndReselects() {
let s = state(["A", "B"])
s.select(id: "slot-00")
s.clearSlot(slotID: "slot-00")
XCTAssertNil(s.slotStore.slots.preset(at: 0))
XCTAssertEqual(s.selectedID, "slot-01") // re-selected the first remaining filled slot
}
func testImportIntoSlotFillsFirstEmptyAndReIds() {
let s = state(["A"])
var incoming = preset("Imported"); incoming.id = "foreign"; incoming.builtIn = true
XCTAssertTrue(s.importIntoSlot(incoming))
XCTAssertEqual(s.presets.last?.id, "slot-01") // re-id'd to its slot id
XCTAssertEqual(s.presets.last?.name, "Imported")
XCTAssertEqual(s.presets.last?.builtIn, false) // forced non-builtin
XCTAssertTrue(s.slotStore.slots.isUserOwned(1)) // imports are the user's
}
func testImportIntoSlotDisplacesFactoryWhenFull() {
let s = state((0..<PresetSlots.count).map { "P\($0)" }) // 24 factory slots, none user-owned
XCTAssertNil(s.firstEmptySlotIndex)
XCTAssertTrue(s.importIntoSlot(preset("Imported")))
XCTAssertEqual(s.presets[0].name, "Imported") // displaced the lowest factory default
XCTAssertTrue(s.slotStore.slots.isUserOwned(0)) // and is now the user's
}
func testImportIntoSlotReturnsFalseWhenEverySlotUserOwned() {
let s = state(["A"])
for i in 0..<PresetSlots.count { s.saveToSlot(index: i, name: "Mine\(i)", tags: [], tagSignal: .pearl) }
XCTAssertNil(s.firstEmptySlotIndex)
XCTAssertFalse(s.importIntoSlot(preset("Homeless"))) // no empty + no factory to displace → fail
}
func testTogglePin() {
let s = state(["A", "B"])
XCTAssertFalse(s.isPinned("slot-01"))
s.togglePin("slot-01")
XCTAssertTrue(s.isPinned("slot-01"))
XCTAssertEqual(s.pinned, ["slot-01"])
s.togglePin("slot-01")
XCTAssertFalse(s.isPinned("slot-01"))
}
func testAdvanceCyclesPinnedRotationSkippingUnpinned() {
let s = state(["A", "B", "C"])
s.togglePin("slot-00"); s.togglePin("slot-02") // rotation = [slot-00, slot-02]
s.select(id: "slot-00")
s.advance(by: 1)
XCTAssertEqual(s.selectedID, "slot-02") // skips unpinned slot-01
s.advance(by: 1)
XCTAssertEqual(s.selectedID, "slot-00") // wraps within the rotation
}
func testAdvanceCyclesAllWhenNothingPinned() {
let s = state(["A", "B"])
s.select(id: "slot-00")
s.advance(by: 1); XCTAssertEqual(s.selectedID, "slot-01")
s.advance(by: 1); XCTAssertEqual(s.selectedID, "slot-00")
}
func testAdvanceCyclesAllWhenEverythingPinned() {
let s = state(["A", "B", "C"])
s.togglePin("slot-00"); s.togglePin("slot-01"); s.togglePin("slot-02") // all pinned → rotation = all
s.select(id: "slot-00")
s.advance(by: 1); XCTAssertEqual(s.selectedID, "slot-01")
s.advance(by: 1); XCTAssertEqual(s.selectedID, "slot-02")
s.advance(by: 1); XCTAssertEqual(s.selectedID, "slot-00") // wraps over the full set
}
func testAdvanceFromUnpinnedSelectionEntersRotationAtStart() {
let s = state(["A", "B", "C"])
s.togglePin("slot-01"); s.togglePin("slot-02") // rotation = [slot-01, slot-02]
s.select(id: "slot-00") // selection is NOT in the rotation
s.advance(by: 1); XCTAssertEqual(s.selectedID, "slot-01") // enters at the rotation's start (not slot-02)
}
func testResetAllToFactoryRestoresAndReselects() {
let s = state(["A", "B"])
s.saveToSlot(index: 0, name: "Mine", tags: [], tagSignal: .pearl)
XCTAssertTrue(s.slotStore.slots.isUserOwned(0))
s.resetAllToFactory()
XCTAssertEqual(s.presets[0].name, "A") // back to factory default
XCTAssertFalse(s.slotStore.slots.isUserOwned(0)) // ownership cleared
XCTAssertEqual(s.selectedID, "slot-00")
}
// MARK: - external (iCloud) change reconcile
func testExternalReloadReselectsWhenSelectedSlotCleared() {
let backend = InMemorySlotBackend()
let s = AppState(slotStore: SlotStore(backend: backend,
factory: .init(presets: [preset("A"), preset("B")], version: 1)))
s.select(id: "slot-01")
var other = PresetSlots(payload: backend.read())
other.clear(slotID: "slot-01") // another device cleared the selected slot
backend.write(other.payload)
s.slotStore.reloadFromBackend()
XCTAssertNil(s.slotStore.slots.preset(at: 1))
XCTAssertEqual(s.selectedID, "slot-00") // reconciled to the first filled slot
}
func testExternalReloadRefreshesSelectedParamsWhenNotDirty() {
let backend = InMemorySlotBackend()
let s = AppState(slotStore: SlotStore(backend: backend,
factory: .init(presets: [preset("A")], version: 1)))
s.select(id: "slot-00")
XCTAssertFalse(s.isDirty)
let bumped = s.baseline.bloomIntensity + 0.5
var other = PresetSlots(payload: backend.read())
var edited = preset("A"); edited.params.bloomIntensity = bumped
other.overwrite(slotID: "slot-00", preset: edited) // remote edit to the selected slot
backend.write(other.payload)
s.slotStore.reloadFromBackend()
XCTAssertEqual(s.edited.bloomIntensity, bumped, accuracy: 1e-6) // working copy refreshed
XCTAssertEqual(s.baseline.bloomIntensity, bumped, accuracy: 1e-6)
}
func testExternalReloadKeepsDirtyLocalEdits() {
let backend = InMemorySlotBackend()
let s = AppState(slotStore: SlotStore(backend: backend,
factory: .init(presets: [preset("A")], version: 1)))
s.select(id: "slot-00")
s.edited.bloomIntensity = 99 // a pending local edit
XCTAssertTrue(s.isDirty)
var other = PresetSlots(payload: backend.read())
var edited = preset("A"); edited.params.bloomIntensity = 5
other.overwrite(slotID: "slot-00", preset: edited)
backend.write(other.payload)
s.slotStore.reloadFromBackend()
XCTAssertEqual(s.edited.bloomIntensity, 99, accuracy: 1e-6) // last-writer-wins: keep the in-progress edit
}
// MARK: - keep only pinned
func testKeepOnlyPinnedCompactsAndReselects() {
let s = state(["A", "B", "C"])
s.togglePin("slot-01") // keep B
s.select(id: "slot-02") // selection on a slot that will be removed
s.keepOnlyPinned()
XCTAssertEqual(s.presets.map { $0.name }, ["B"])
XCTAssertEqual(s.selectedID, "slot-00") // re-selected the surviving slot
XCTAssertTrue(s.isPinned("slot-00")) // kept set stays pinned
}
func testKeepOnlyPinnedNoOpWhenNothingPinned() {
let s = state(["A", "B"])
s.keepOnlyPinned()
XCTAssertEqual(s.presets.map { $0.name }, ["A", "B"]) // guarded — nothing wiped
}
// A synced change must refresh thumbnails (content-addressed cards re-resolve) — the fix for
// "SYNC-OK arrived but no preview was generated".
func testExternalReloadBumpsThumbnailRevision() {
let backend = InMemorySlotBackend()
let s = AppState(slotStore: SlotStore(backend: backend, factory: .init(presets: [preset("A")], version: 1)))
let before = s.thumbnailRevision
var other = PresetSlots(payload: backend.read())
other.fill(index: 1, preset: preset("B"), userOwned: true) // another device adds a slot
backend.write(other.payload)
s.slotStore.reloadFromBackend()
XCTAssertGreaterThan(s.thumbnailRevision, before) // reload refreshed thumbnails
}
}