361 lines
12 KiB
Swift
361 lines
12 KiB
Swift
import XCTest
|
|
import MacToolsPluginKit
|
|
@testable import MacTools
|
|
@testable import XcodeCleanPlugin
|
|
|
|
@MainActor
|
|
final class XcodeCleanPluginTests: XCTestCase {
|
|
func testMetadataIdentifiesXcodeCleanPlugin() {
|
|
let plugin = makePlugin()
|
|
|
|
XCTAssertEqual(plugin.metadata.id, "xcode-clean")
|
|
XCTAssertEqual(plugin.metadata.title, "Xcode 清理")
|
|
}
|
|
|
|
func testControlStyleIsDisclosure() {
|
|
let plugin = makePlugin()
|
|
|
|
XCTAssertEqual(plugin.primaryPanelDescriptor.controlStyle, .disclosure)
|
|
}
|
|
|
|
func testExpandedPanelExposesScanAndCleanControls() throws {
|
|
let plugin = makePlugin()
|
|
|
|
plugin.handleAction(.setDisclosureExpanded(true))
|
|
|
|
let controls = try XCTUnwrap(plugin.primaryPanelState.detail?.primaryControls)
|
|
XCTAssertEqual(
|
|
controls.map(\.id),
|
|
[XcodeCleanPlugin.ControlID.scan, XcodeCleanPlugin.ControlID.clean]
|
|
)
|
|
}
|
|
|
|
func testInvokingScanForwardsToController() {
|
|
let controller = FakeXcodeCleanController()
|
|
let plugin = makePlugin(controller: controller)
|
|
|
|
plugin.handleAction(.invokeAction(controlID: XcodeCleanPlugin.ControlID.scan))
|
|
|
|
XCTAssertEqual(controller.scanCallCount, 1)
|
|
}
|
|
|
|
func testInvokingCleanPresentsConfirmationWithCleanableCandidates() {
|
|
let controller = FakeXcodeCleanController()
|
|
let allowedCandidate = XcodeCleanCandidate(
|
|
id: "allowed",
|
|
category: .derivedData,
|
|
path: "/tmp/a",
|
|
sizeBytes: 10,
|
|
safety: .allowed
|
|
)
|
|
let outsideCandidate = XcodeCleanCandidate(
|
|
id: "outside",
|
|
category: .derivedData,
|
|
path: "/tmp/b",
|
|
sizeBytes: 10,
|
|
safety: .outsideAllowedRoot
|
|
)
|
|
controller.snapshot = XcodeCleanSnapshot(
|
|
phase: .scanned,
|
|
selectedCategories: Set(XcodeCleanCategory.allCases),
|
|
scanResult: XcodeCleanScanResult(
|
|
categories: Set(XcodeCleanCategory.allCases),
|
|
candidates: [allowedCandidate, outsideCandidate],
|
|
scannedAt: Date(timeIntervalSince1970: 0)
|
|
),
|
|
executionResult: nil,
|
|
isResultStale: false,
|
|
isXcodeRunning: false,
|
|
errorMessage: nil
|
|
)
|
|
let presenter = FakeConfirmationPresenter()
|
|
let plugin = makePlugin(controller: controller, confirmationPresenter: presenter)
|
|
|
|
plugin.handleAction(.invokeAction(controlID: XcodeCleanPlugin.ControlID.clean))
|
|
|
|
XCTAssertEqual(presenter.presentCalls.count, 1)
|
|
XCTAssertEqual(presenter.presentCalls.first?.candidates, [allowedCandidate])
|
|
XCTAssertEqual(controller.cleanSelectedCalls, [])
|
|
}
|
|
|
|
func testConfirmationConfirmCallbackForwardsSelectedIDs() {
|
|
let controller = FakeXcodeCleanController()
|
|
controller.snapshot = XcodeCleanSnapshot(
|
|
phase: .scanned,
|
|
selectedCategories: Set(XcodeCleanCategory.allCases),
|
|
scanResult: XcodeCleanScanResult(
|
|
categories: Set(XcodeCleanCategory.allCases),
|
|
candidates: [
|
|
XcodeCleanCandidate(id: "a", category: .derivedData, path: "/tmp/a", sizeBytes: 1, safety: .allowed),
|
|
XcodeCleanCandidate(id: "b", category: .archives, path: "/tmp/b", sizeBytes: 2, safety: .allowed)
|
|
],
|
|
scannedAt: Date(timeIntervalSince1970: 0)
|
|
),
|
|
executionResult: nil,
|
|
isResultStale: false,
|
|
isXcodeRunning: false,
|
|
errorMessage: nil
|
|
)
|
|
let presenter = FakeConfirmationPresenter()
|
|
let plugin = makePlugin(controller: controller, confirmationPresenter: presenter)
|
|
|
|
plugin.handleAction(.invokeAction(controlID: XcodeCleanPlugin.ControlID.clean))
|
|
presenter.presentCalls.first?.onConfirm(["a"])
|
|
|
|
XCTAssertEqual(controller.cleanSelectedCalls, [["a"]])
|
|
}
|
|
|
|
func testInvokingCleanWithoutScanResultDoesNotPresentConfirmation() {
|
|
let controller = FakeXcodeCleanController()
|
|
let presenter = FakeConfirmationPresenter()
|
|
let plugin = makePlugin(controller: controller, confirmationPresenter: presenter)
|
|
|
|
plugin.handleAction(.invokeAction(controlID: XcodeCleanPlugin.ControlID.clean))
|
|
|
|
XCTAssertEqual(presenter.presentCalls.count, 0)
|
|
XCTAssertEqual(controller.cleanSelectedCalls, [])
|
|
}
|
|
|
|
func testCleanActionDismissesMenuBeforeHandling() throws {
|
|
let plugin = makePlugin()
|
|
|
|
plugin.handleAction(.setDisclosureExpanded(true))
|
|
|
|
let controls = try XCTUnwrap(plugin.primaryPanelState.detail?.primaryControls)
|
|
let clean = try XCTUnwrap(controls.first { $0.id == XcodeCleanPlugin.ControlID.clean })
|
|
switch clean.actionBehavior {
|
|
case .dismissBeforeHandling: break
|
|
case .keepPresented:
|
|
XCTFail("Clean action should dismiss the menu before opening the confirmation window")
|
|
}
|
|
}
|
|
|
|
func testDeactivateDismissesAnyActiveConfirmation() {
|
|
let presenter = FakeConfirmationPresenter()
|
|
let plugin = makePlugin(confirmationPresenter: presenter)
|
|
|
|
plugin.deactivate(reason: .disabled)
|
|
|
|
XCTAssertEqual(presenter.dismissCallCount, 1)
|
|
}
|
|
|
|
func testSubtitleAnnouncesXcodeRunning() {
|
|
let controller = FakeXcodeCleanController()
|
|
controller.snapshot = XcodeCleanSnapshot(
|
|
phase: .idle,
|
|
selectedCategories: Set(XcodeCleanCategory.allCases),
|
|
scanResult: nil,
|
|
executionResult: nil,
|
|
isResultStale: false,
|
|
isXcodeRunning: true,
|
|
errorMessage: nil
|
|
)
|
|
let plugin = makePlugin(controller: controller)
|
|
|
|
XCTAssertEqual(plugin.primaryPanelState.subtitle, "请先退出 Xcode")
|
|
}
|
|
|
|
func testScanButtonDisabledWhenXcodeRunning() throws {
|
|
let controller = FakeXcodeCleanController()
|
|
controller.snapshot = XcodeCleanSnapshot(
|
|
phase: .idle,
|
|
selectedCategories: Set(XcodeCleanCategory.allCases),
|
|
scanResult: nil,
|
|
executionResult: nil,
|
|
isResultStale: false,
|
|
isXcodeRunning: true,
|
|
errorMessage: nil
|
|
)
|
|
let plugin = makePlugin(controller: controller)
|
|
plugin.handleAction(.setDisclosureExpanded(true))
|
|
|
|
let scan = try XCTUnwrap(
|
|
plugin.primaryPanelState.detail?.primaryControls.first { $0.id == XcodeCleanPlugin.ControlID.scan }
|
|
)
|
|
XCTAssertFalse(scan.isEnabled)
|
|
}
|
|
|
|
func testPluginHostIncludesXcodeCleanWhenProvided() {
|
|
let host = makePluginHostForTests(plugins: [makePlugin()])
|
|
|
|
XCTAssertTrue(host.featureManagementItems.contains { $0.id == "xcode-clean" })
|
|
}
|
|
|
|
func testPluginHostExposesConfiguration() {
|
|
let host = makePluginHostForTests(plugins: [
|
|
makePlugin(controller: XcodeCleanController(scanner: NoOpScanner(), executor: NoOpExecutor()))
|
|
])
|
|
|
|
XCTAssertTrue(host.pluginConfigurationItems.contains { $0.id == "xcode-clean" })
|
|
}
|
|
|
|
// MARK: - Formatter
|
|
|
|
func testByteFormatterWritesZeroAsNumeric() {
|
|
XCTAssertFalse(XcodeCleanByteFormatter.string(fromByteCount: 0).contains("Zero"))
|
|
}
|
|
|
|
// MARK: - Confirm View Model
|
|
|
|
func testConfirmViewModelSelectsAllCleanableByDefault() {
|
|
let candidates = [
|
|
XcodeCleanCandidate(id: "a", category: .derivedData, path: "/tmp/a", sizeBytes: 10, safety: .allowed),
|
|
XcodeCleanCandidate(id: "b", category: .archives, path: "/tmp/b", sizeBytes: 20, safety: .allowed),
|
|
XcodeCleanCandidate(id: "c", category: .derivedData, path: "/tmp/c", sizeBytes: 30, safety: .outsideAllowedRoot)
|
|
]
|
|
let vm = XcodeCleanConfirmViewModel(candidates: candidates)
|
|
|
|
XCTAssertEqual(vm.selectedIDs, ["a", "b"])
|
|
XCTAssertEqual(vm.totalCount, 2)
|
|
XCTAssertEqual(vm.selectedSizeBytes, 30)
|
|
XCTAssertTrue(vm.allSelected)
|
|
}
|
|
|
|
func testConfirmViewModelToggleAndSectionState() {
|
|
let candidates = [
|
|
XcodeCleanCandidate(id: "a", category: .derivedData, path: "/tmp/a", sizeBytes: 10, safety: .allowed),
|
|
XcodeCleanCandidate(id: "b", category: .derivedData, path: "/tmp/b", sizeBytes: 20, safety: .allowed)
|
|
]
|
|
let vm = XcodeCleanConfirmViewModel(candidates: candidates)
|
|
|
|
vm.toggle(id: "a")
|
|
XCTAssertEqual(vm.sectionState(.derivedData), .partial)
|
|
|
|
vm.setSection(.derivedData, selected: false)
|
|
XCTAssertEqual(vm.sectionState(.derivedData), .none)
|
|
XCTAssertEqual(vm.selectedCount, 0)
|
|
|
|
vm.toggleAll()
|
|
XCTAssertEqual(vm.sectionState(.derivedData), .all)
|
|
XCTAssertTrue(vm.allSelected)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func makePlugin(
|
|
controller: XcodeCleanControlling? = nil,
|
|
runningMonitor: XcodeCleanRunningMonitoring? = nil,
|
|
confirmationPresenter: XcodeCleanConfirmationPresenting? = nil
|
|
) -> XcodeCleanPlugin {
|
|
XcodeCleanPlugin(
|
|
controller: controller ?? FakeXcodeCleanController(),
|
|
runningMonitor: runningMonitor ?? FakeXcodeCleanRunningMonitor(),
|
|
confirmationPresenter: confirmationPresenter ?? FakeConfirmationPresenter()
|
|
)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class FakeXcodeCleanController: XcodeCleanControlling {
|
|
var onStateChange: (() -> Void)?
|
|
var snapshot = XcodeCleanSnapshot.initial
|
|
private(set) var scanCallCount = 0
|
|
private(set) var cancelCallCount = 0
|
|
private(set) var cleanSelectedCalls: [Set<XcodeCleanCandidate.ID>] = []
|
|
private(set) var categoryChanges: [(category: XcodeCleanCategory, isSelected: Bool)] = []
|
|
|
|
func setCategory(_ category: XcodeCleanCategory, isSelected: Bool) {
|
|
categoryChanges.append((category, isSelected))
|
|
onStateChange?()
|
|
}
|
|
|
|
func scan() {
|
|
scanCallCount += 1
|
|
onStateChange?()
|
|
}
|
|
|
|
func cleanSelected(candidateIDs: Set<XcodeCleanCandidate.ID>) {
|
|
cleanSelectedCalls.append(candidateIDs)
|
|
onStateChange?()
|
|
}
|
|
|
|
func cancelCurrentOperation() {
|
|
cancelCallCount += 1
|
|
onStateChange?()
|
|
}
|
|
|
|
func updateXcodeRunningState(_ isRunning: Bool) {
|
|
snapshot = XcodeCleanSnapshot(
|
|
phase: snapshot.phase,
|
|
selectedCategories: snapshot.selectedCategories,
|
|
scanResult: snapshot.scanResult,
|
|
executionResult: snapshot.executionResult,
|
|
isResultStale: snapshot.isResultStale,
|
|
isXcodeRunning: isRunning,
|
|
errorMessage: snapshot.errorMessage
|
|
)
|
|
onStateChange?()
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class FakeXcodeCleanRunningMonitor: XcodeCleanRunningMonitoring {
|
|
var isXcodeRunning: Bool = false
|
|
var onStateChange: (() -> Void)?
|
|
private(set) var startCount = 0
|
|
private(set) var stopCount = 0
|
|
private(set) var refreshCount = 0
|
|
|
|
func start() { startCount += 1 }
|
|
func stop() { stopCount += 1 }
|
|
func refresh() { refreshCount += 1 }
|
|
}
|
|
|
|
private struct NoOpScanner: XcodeCleanScanning {
|
|
func scan(
|
|
categories: Set<XcodeCleanCategory>,
|
|
progress: XcodeCleanScanProgressHandler
|
|
) async throws -> XcodeCleanScanResult {
|
|
XcodeCleanScanResult(
|
|
categories: categories,
|
|
candidates: [],
|
|
scannedAt: Date(timeIntervalSince1970: 0)
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct NoOpExecutor: XcodeCleanExecuting {
|
|
func clean(
|
|
candidates: [XcodeCleanCandidate],
|
|
selectedCandidateIDs: Set<XcodeCleanCandidate.ID>
|
|
) async throws -> XcodeCleanExecutionResult {
|
|
XcodeCleanExecutionResult(itemResults: [])
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class FakeConfirmationPresenter: XcodeCleanConfirmationPresenting {
|
|
struct PresentCall {
|
|
let candidates: [XcodeCleanCandidate]
|
|
let anchorRect: NSRect?
|
|
let onConfirm: (Set<XcodeCleanCandidate.ID>) -> Void
|
|
let onCancel: () -> Void
|
|
}
|
|
|
|
var isPresenting: Bool = false
|
|
private(set) var presentCalls: [PresentCall] = []
|
|
private(set) var dismissCallCount = 0
|
|
|
|
func present(
|
|
candidates: [XcodeCleanCandidate],
|
|
anchorRect: NSRect?,
|
|
onConfirm: @escaping (Set<XcodeCleanCandidate.ID>) -> Void,
|
|
onCancel: @escaping () -> Void
|
|
) {
|
|
presentCalls.append(
|
|
PresentCall(
|
|
candidates: candidates,
|
|
anchorRect: anchorRect,
|
|
onConfirm: onConfirm,
|
|
onCancel: onCancel
|
|
)
|
|
)
|
|
isPresenting = true
|
|
}
|
|
|
|
func dismiss() {
|
|
dismissCallCount += 1
|
|
isPresenting = false
|
|
}
|
|
}
|