Zum Inhalt

apple/PhysarumApp/Tests/AppStateSlotsTests.swift

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