import XCTest
import PhysarumEngine
@testable import Physarum
final class PresetSlotsTests: XCTestCase {
private func preset(_ name: String) -> Preset {
var p = PresetCatalog.fallbackPreset; p.name = name; p.builtIn = false; return p
}
func testFillAssignsSlotIdAndIsNotUserOwned() {
var s = PresetSlots()
XCTAssertEqual(s.count, 24)
s.fill(index: 3, preset: preset("Aurora"), userOwned: false)
XCTAssertEqual(s.preset(at: 3)?.id, "slot-03")
XCTAssertEqual(s.preset(at: 3)?.name, "Aurora")
XCTAssertFalse(s.isUserOwned(3))
XCTAssertEqual(s.filled.map { $0.id }, ["slot-03"])
}
func testOverwriteMarksUserOwnedAndKeepsSlotId() {
var s = PresetSlots()
s.fill(index: 3, preset: preset("Aurora"), userOwned: false)
s.overwrite(slotID: "slot-03", preset: preset("Mine"))
XCTAssertEqual(s.preset(at: 3)?.id, "slot-03")
XCTAssertEqual(s.preset(at: 3)?.name, "Mine")
XCTAssertTrue(s.isUserOwned(3))
}
func testClearEmptiesSlot() {
var s = PresetSlots()
s.fill(index: 3, preset: preset("Aurora"), userOwned: true)
s.clear(slotID: "slot-03")
XCTAssertNil(s.preset(at: 3))
XCTAssertFalse(s.isUserOwned(3))
XCTAssertTrue(s.filled.isEmpty)
}
func testFirstEmptyIndexFindsLowestGap() {
var s = PresetSlots()
s.fill(index: 0, preset: preset("A"), userOwned: false)
s.fill(index: 1, preset: preset("B"), userOwned: false)
XCTAssertEqual(s.firstEmptyIndex, 2)
}
}
extension PresetSlotsTests {
func testPinUnpinAndRotation() {
var s = PresetSlots()
s.fill(index: 0, preset: preset("A"), userOwned: false)
s.fill(index: 1, preset: preset("B"), userOwned: false)
s.fill(index: 2, preset: preset("C"), userOwned: false)
XCTAssertEqual(s.rotation.map { $0.id }, ["slot-00", "slot-01", "slot-02"]) // nothing pinned → all filled
s.pin("slot-02"); s.pin("slot-00")
XCTAssertTrue(s.isPinned("slot-00"))
XCTAssertEqual(s.rotation.map { $0.id }, ["slot-00", "slot-02"]) // pinned subset, in slot order
s.unpin("slot-00")
XCTAssertEqual(s.rotation.map { $0.id }, ["slot-02"])
}
func testPinIsIdempotentAndClearingRemovesPin() {
var s = PresetSlots()
s.fill(index: 0, preset: preset("A"), userOwned: true)
s.pin("slot-00"); s.pin("slot-00")
XCTAssertEqual(s.pinned.count, 1)
s.clear(slotID: "slot-00")
XCTAssertFalse(s.isPinned("slot-00")) // clearing a slot drops its pin
XCTAssertTrue(s.rotation.isEmpty)
}
}
extension PresetSlotsTests {
private func factory(_ names: [String], version: Int, pins: Set<String> = []) -> PresetSlots.Factory {
.init(presets: names.map { preset($0) }, version: version, pinnedSlotIDs: pins)
}
func testSeedFillsEmptyOnFirstRun() {
var s = PresetSlots()
s.seed(factory(["A", "B"], version: 1))
XCTAssertEqual(s.filled.map { $0.name }, ["A", "B"])
XCTAssertEqual(s.seedVersion, 1)
XCTAssertFalse(s.isUserOwned(0)) // seeded slots track the default
}
func testSeedUpdateSkipsUserOwnedButFillsUntouched() {
var s = PresetSlots()
s.seed(factory(["A", "B"], version: 1))
s.overwrite(slotID: "slot-00", preset: preset("Mine")) // user edits slot 0 → owned
s.seed(factory(["A2", "B2", "C2"], version: 2)) // newer factory
XCTAssertEqual(s.preset(at: 0)?.name, "Mine") // user-owned slot untouched
XCTAssertEqual(s.preset(at: 1)?.name, "B2") // untouched slot takes new default
XCTAssertEqual(s.preset(at: 2)?.name, "C2") // new default fills empty slot
XCTAssertEqual(s.seedVersion, 2)
}
func testSeedNoOpWhenVersionNotNewer() {
var s = PresetSlots()
s.seed(factory(["A"], version: 2))
s.seed(factory(["X"], version: 2)) // same version → no change
XCTAssertEqual(s.preset(at: 0)?.name, "A")
}
func testResetToFactoryClearsUserOwnedAndPins() {
var s = PresetSlots()
s.seed(factory(["A", "B"], version: 1, pins: ["slot-00"]))
s.overwrite(slotID: "slot-00", preset: preset("Mine"))
s.pin("slot-00")
s.resetToFactory(factory(["A", "B"], version: 1, pins: ["slot-01"]))
XCTAssertEqual(s.preset(at: 0)?.name, "A") // back to factory
XCTAssertFalse(s.isUserOwned(0)) // ownership cleared
XCTAssertEqual(s.pinned, ["slot-01"]) // pins reset to factory pins
}
}
extension PresetSlotsTests {
func testKeepOnlyPinnedCompactsAndRemapsPins() {
var s = PresetSlots()
s.fill(index: 0, preset: preset("A"), userOwned: false)
s.fill(index: 1, preset: preset("B"), userOwned: true)
s.fill(index: 2, preset: preset("C"), userOwned: false)
s.fill(index: 5, preset: preset("D"), userOwned: true)
s.pin("slot-01"); s.pin("slot-05") // keep B and D
s.keepOnlyPinned()
XCTAssertEqual(s.filled.map { $0.name }, ["B", "D"]) // compacted to the front, in slot order
XCTAssertEqual(s.preset(at: 0)?.id, "slot-00") // re-id'd to their new slot ids
XCTAssertEqual(s.preset(at: 1)?.id, "slot-01")
XCTAssertNil(s.preset(at: 2)) // the rest are emptied
XCTAssertTrue(s.isUserOwned(0)) // userOwned preserved (B, D were owned)
XCTAssertTrue(s.isUserOwned(1))
XCTAssertEqual(s.pinned, ["slot-00", "slot-01"]) // pins remapped to the new ids
XCTAssertEqual(s.rotation.map { $0.name }, ["B", "D"]) // rotation = the kept set
}
func testKeepOnlyPinnedIsNoOpWhenNothingPinned() {
var s = PresetSlots()
s.fill(index: 0, preset: preset("A"), userOwned: false)
s.fill(index: 1, preset: preset("B"), userOwned: false)
s.keepOnlyPinned()
XCTAssertEqual(s.filled.map { $0.name }, ["A", "B"]) // untouched — would otherwise wipe everything
}
}