Zum Inhalt

apple/PhysarumApp/Tests/SlotStoreApplyTests.swift

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() {}
}