diff --git a/engine/Sources/ODHubViewerApp/ViewerMain.swift b/engine/Sources/ODHubViewerApp/ViewerMain.swift index b5ed001..2b8bfd9 100644 --- a/engine/Sources/ODHubViewerApp/ViewerMain.swift +++ b/engine/Sources/ODHubViewerApp/ViewerMain.swift @@ -346,6 +346,24 @@ struct ODHubViewer: ParsableCommand { } } }, + newSimulator: { + let simctl = SimctlService() + NewSimulatorPanel.show(actions: NewSimulatorActions( + deviceTypes: { (try? simctl.listDeviceTypes()) ?? [] }, + runtimeSupport: { (try? simctl.listRuntimeSupport()) ?? [] }, + existingNames: { ((try? adapter.devices()) ?? []).map(\.name) }, + create: { name, type, runtime in + try simctl.createDevice( + name: name, + deviceType: type.identifier, + runtime: runtime.identifier + ) + }, + // A simulator created and then not shown would be a puzzle, so it opens, + // which boots it the same way the chooser does. + created: { udid in bootThenShow(udid) } + ), settings: settings) + }, setOrientation: { orientation in for udid in manager.openUDIDs { guard let controller = manager.controller(for: udid) else { continue } diff --git a/engine/Sources/OpenDeviceHubEngine/Simctl/SimctlModels.swift b/engine/Sources/OpenDeviceHubEngine/Simctl/SimctlModels.swift index 2fa82bf..1871fdf 100644 --- a/engine/Sources/OpenDeviceHubEngine/Simctl/SimctlModels.swift +++ b/engine/Sources/OpenDeviceHubEngine/Simctl/SimctlModels.swift @@ -22,6 +22,27 @@ enum SimctlModels { let devices: [String: [SimctlDevice]] } + struct DeviceTypeList: Decodable { + let devicetypes: [SimctlDeviceType] + } + + /// The same list as `RuntimeList` with the supported device types kept, which the plain runtime + /// model drops. + struct RuntimeSupportList: Decodable { + struct Runtime: Decodable { + struct DeviceType: Decodable { + let identifier: String + } + let identifier: String + let name: String + let version: String + let buildversion: String + let isAvailable: Bool + let supportedDeviceTypes: [DeviceType]? + } + let runtimes: [Runtime] + } + struct RuntimeList: Decodable { let runtimes: [SimctlRuntime] } diff --git a/engine/Sources/OpenDeviceHubEngine/Simctl/SimctlService.swift b/engine/Sources/OpenDeviceHubEngine/Simctl/SimctlService.swift index e52f854..b1b2a32 100644 --- a/engine/Sources/OpenDeviceHubEngine/Simctl/SimctlService.swift +++ b/engine/Sources/OpenDeviceHubEngine/Simctl/SimctlService.swift @@ -402,3 +402,53 @@ extension SimctlService { } } } + +/// Creating a simulator, and the lists a person picks from to do it. +extension SimctlService { + static func listDeviceTypesArguments() -> [String] { + ["simctl", "list", "devicetypes", "-j"] + } + + static func createArguments(name: String, deviceType: String, runtime: String) -> [String] { + ["simctl", "create", name, deviceType, runtime] + } + + public func listDeviceTypes() throws -> [SimctlDeviceType] { + let data = try run(Self.listDeviceTypesArguments()) + return try JSONDecoder().decode(SimctlModels.DeviceTypeList.self, from: data).devicetypes + } + + /// Which device types each runtime can run, which `simctl` only reports here rather than on the + /// device types themselves. + public func listRuntimeSupport() throws -> [SimctlRuntimeSupport] { + let data = try run(Self.listRuntimesArguments()) + let decoded = try JSONDecoder().decode(SimctlModels.RuntimeSupportList.self, from: data) + return decoded.runtimes.map { runtime in + SimctlRuntimeSupport( + runtime: SimctlRuntime( + identifier: runtime.identifier, + name: runtime.name, + version: runtime.version, + buildversion: runtime.buildversion, + isAvailable: runtime.isAvailable + ), + deviceTypeIdentifiers: Set((runtime.supportedDeviceTypes ?? []).map(\.identifier)) + ) + } + } + + /// Returns the new device's UDID, which is all `simctl create` prints. + @discardableResult + public func createDevice(name: String, deviceType: String, runtime: String) throws -> String { + let arguments = Self.createArguments(name: name, deviceType: deviceType, runtime: runtime) + let result = try ProcessRunner.run("/usr/bin/xcrun", arguments) + guard result.status == 0 else { + throw EngineError.simctl( + args: Array(arguments.dropFirst()), + code: result.status, + stderr: result.standardError + ) + } + return result.standardOutput.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/engine/Sources/OpenDeviceHubEngine/Simctl/SimulatorCreation.swift b/engine/Sources/OpenDeviceHubEngine/Simctl/SimulatorCreation.swift new file mode 100644 index 0000000..bfffcb7 --- /dev/null +++ b/engine/Sources/OpenDeviceHubEngine/Simctl/SimulatorCreation.swift @@ -0,0 +1,72 @@ +import Foundation + +/// A device type a simulator can be created as. +public struct SimctlDeviceType: Sendable, Hashable, Codable { + public let identifier: String + public let name: String + + public init(identifier: String, name: String) { + self.identifier = identifier + self.name = name + } +} + +/// What a runtime can run. `simctl` reports this per runtime, and it matters: a newly released +/// runtime can support a single device type while an older one supports sixty. +public struct SimctlRuntimeSupport: Sendable, Hashable { + public let runtime: SimctlRuntime + public let deviceTypeIdentifiers: Set + + public init(runtime: SimctlRuntime, deviceTypeIdentifiers: Set) { + self.runtime = runtime + self.deviceTypeIdentifiers = deviceTypeIdentifiers + } +} + +/// Which device types and runtimes go together, and what to call a new simulator. +/// +/// Pure, because the pairing is the part that is easy to get wrong and impossible to notice: an +/// unsupported pair is refused by `simctl` with a message about the runtime, long after the person +/// chose the device. +public enum SimulatorCreation { + /// iOS only. The other platforms are out of scope for this app, and offering a watchOS device + /// that it cannot then show would be a promise it does not keep. + public static func isSupported(_ type: SimctlDeviceType) -> Bool { + type.identifier.contains(".iPhone-") || type.identifier.contains(".iPad") + } + + public static func runtimes( + for type: SimctlDeviceType, + in support: [SimctlRuntimeSupport] + ) -> [SimctlRuntime] { + support + .filter { $0.runtime.isAvailable && $0.deviceTypeIdentifiers.contains(type.identifier) } + .map(\.runtime) + .sorted { $0.version.compare($1.version, options: .numeric) == .orderedDescending } + } + + public static func deviceTypes( + in support: [SimctlRuntimeSupport], + from types: [SimctlDeviceType] + ) -> [SimctlDeviceType] { + let runnable = support + .filter(\.runtime.isAvailable) + .reduce(into: Set()) { $0.formUnion($1.deviceTypeIdentifiers) } + return types + .filter { isSupported($0) && runnable.contains($0.identifier) } + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + } + + /// The name a person would have typed, so the field is never empty. A name already taken gets a + /// number, because `simctl` allows duplicates and two identical rows in the chooser help nobody. + public static func suggestedName( + for type: SimctlDeviceType, + existing: [String] + ) -> String { + guard existing.contains(type.name) else { return type.name } + for suffix in 2...99 where !existing.contains("\(type.name) \(suffix)") { + return "\(type.name) \(suffix)" + } + return type.name + } +} diff --git a/engine/Sources/OpenDeviceHubViewer/NewSimulatorPanel.swift b/engine/Sources/OpenDeviceHubViewer/NewSimulatorPanel.swift new file mode 100644 index 0000000..4528d37 --- /dev/null +++ b/engine/Sources/OpenDeviceHubViewer/NewSimulatorPanel.swift @@ -0,0 +1,196 @@ +import AppKit +import OpenDeviceHubEngine +import SwiftUI + +/// What the panel needs to do its job, passed in so it never reaches for `simctl` itself. +@MainActor +public struct NewSimulatorActions { + public var deviceTypes: () -> [SimctlDeviceType] + public var runtimeSupport: () -> [SimctlRuntimeSupport] + public var existingNames: () -> [String] + /// Returns the new device's UDID, or throws with something worth showing. + public var create: (String, SimctlDeviceType, SimctlRuntime) throws -> String + public var created: (String) -> Void + + public init( + deviceTypes: @escaping () -> [SimctlDeviceType], + runtimeSupport: @escaping () -> [SimctlRuntimeSupport], + existingNames: @escaping () -> [String], + create: @escaping (String, SimctlDeviceType, SimctlRuntime) throws -> String, + created: @escaping (String) -> Void + ) { + self.deviceTypes = deviceTypes + self.runtimeSupport = runtimeSupport + self.existingNames = existingNames + self.create = create + self.created = created + } +} + +@MainActor +public enum NewSimulatorPanel { + private static var controller: NewSimulatorWindowController? + + public static func show(actions: NewSimulatorActions, settings: ViewerSettings = ViewerSettings()) { + // Rebuilt each time rather than reused: the runtimes and device types on the machine can + // change between openings, and a stale list would offer something that is no longer there. + let controller = NewSimulatorWindowController(actions: actions, settings: settings) + Self.controller = controller + NSApplication.shared.activate(ignoringOtherApps: true) + controller.showWindow(nil) + controller.window?.makeKeyAndOrderFront(nil) + } + + static func dismiss() { + controller?.close() + controller = nil + } +} + +@MainActor +final class NewSimulatorWindowController: NSWindowController { + init(actions: NewSimulatorActions, settings: ViewerSettings) { + let view = NewSimulatorView(actions: actions, settings: settings) + let window = NSWindow(contentViewController: NSHostingController(rootView: view)) + window.title = "New Simulator" + window.styleMask = [.titled, .closable] + window.isReleasedWhenClosed = false + super.init(window: window) + window.center() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) is unsupported") } +} + +@MainActor +private struct NewSimulatorView: View { + private let actions: NewSimulatorActions + private let settings: ViewerSettings + private let types: [SimctlDeviceType] + private let support: [SimctlRuntimeSupport] + + @State private var name: String + @State private var type: SimctlDeviceType? + @State private var runtime: SimctlRuntime? + @State private var failure: String? + /// True once the name has been edited, so the suggestion stops following the device type. + @State private var nameIsMine = false + + init(actions: NewSimulatorActions, settings: ViewerSettings) { + self.actions = actions + self.settings = settings + let support = actions.runtimeSupport() + let types = SimulatorCreation.deviceTypes(in: support, from: actions.deviceTypes()) + self.support = support + self.types = types + let first = types.first + _type = State(initialValue: first) + _runtime = State(initialValue: first.flatMap { + SimulatorCreation.runtimes(for: $0, in: support).first + }) + _name = State(initialValue: first.map { + SimulatorCreation.suggestedName(for: $0, existing: actions.existingNames()) + } ?? "") + } + + private var runtimes: [SimctlRuntime] { + type.map { SimulatorCreation.runtimes(for: $0, in: support) } ?? [] + } + + private var canCreate: Bool { + !name.trimmingCharacters(in: .whitespaces).isEmpty && type != nil && runtime != nil + } + + var body: some View { + Form { + Section { + TextField("Name", text: $name) + .onChange(of: name) { _, _ in nameIsMine = true } + + Picker("Device Type", selection: $type) { + ForEach(types, id: \.identifier) { candidate in + Text(candidate.name).tag(Optional(candidate)) + } + } + .onChange(of: type) { _, _ in deviceTypeChanged() } + + Picker("OS Version", selection: $runtime) { + ForEach(runtimes, id: \.identifier) { candidate in + Text(candidate.name).tag(Optional(candidate)) + } + } + .disabled(runtimes.isEmpty) + } footer: { + if let failure { + Text(failure).font(.footnote).foregroundStyle(Color.red) + } else if runtimes.count == 1, let only = runtimes.first { + // Worth saying: one runtime is normal for a device only just added. + Text("\(only.name) is the only version that runs this device.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + .formStyle(.grouped) + .safeAreaInset(edge: .bottom) { + HStack { + Button("Cancel") { NewSimulatorPanel.dismiss() } + .keyboardShortcut(.cancelAction) + Spacer() + Button("Previous") { fillFromPrevious() } + .disabled(settings.lastCreatedSimulator == nil) + .help("Fill this in with the last simulator you created.") + Button("Create") { create() } + .keyboardShortcut(.defaultAction) + .disabled(!canCreate) + } + .padding(.horizontal, 20) + .padding(.bottom, 16) + } + .frame(width: 460) + .fixedSize(horizontal: false, vertical: true) + } + + private func deviceTypeChanged() { + // The chosen runtime may not run the new device, so it follows rather than going stale. + if let runtime, !runtimes.contains(runtime) { + self.runtime = runtimes.first + } else if runtime == nil { + runtime = runtimes.first + } + if !nameIsMine, let type { + name = SimulatorCreation.suggestedName(for: type, existing: actions.existingNames()) + // Assigning the field fires onChange, which would otherwise look like the person typing. + nameIsMine = false + } + } + + private func fillFromPrevious() { + guard let previous = settings.lastCreatedSimulator else { return } + if let match = types.first(where: { $0.identifier == previous.deviceType }) { + type = match + runtime = SimulatorCreation.runtimes(for: match, in: support) + .first { $0.identifier == previous.runtime } + ?? SimulatorCreation.runtimes(for: match, in: support).first + } + name = previous.name + nameIsMine = true + } + + private func create() { + guard let type, let runtime else { return } + let wanted = name.trimmingCharacters(in: .whitespaces) + do { + let udid = try actions.create(wanted, type, runtime) + settings.lastCreatedSimulator = LastCreatedSimulator( + name: wanted, + deviceType: type.identifier, + runtime: runtime.identifier + ) + NewSimulatorPanel.dismiss() + actions.created(udid) + } catch { + failure = error.localizedDescription + } + } +} diff --git a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift index 659c2a5..0d0382e 100644 --- a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift +++ b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift @@ -49,6 +49,7 @@ public enum ViewerMenu { public var toggleKeyboardInput: (Bool) -> Void public var toggleHardwareKeyboard: (Bool) -> Void public var matchKeyboardLanguage: (Bool) -> Void + public var newSimulator: (() -> Void)? public var setOrientation: (DeviceOrientation) -> Void public var appSwitcher: () -> Void public var stopRecording: () -> Void @@ -83,6 +84,7 @@ public enum ViewerMenu { toggleKeyboardInput: @escaping (Bool) -> Void, toggleHardwareKeyboard: @escaping (Bool) -> Void, matchKeyboardLanguage: @escaping (Bool) -> Void, + newSimulator: (() -> Void)? = nil, setOrientation: @escaping (DeviceOrientation) -> Void, appSwitcher: @escaping () -> Void, stopRecording: @escaping () -> Void, @@ -116,6 +118,7 @@ public enum ViewerMenu { self.toggleKeyboardInput = toggleKeyboardInput self.toggleHardwareKeyboard = toggleHardwareKeyboard self.matchKeyboardLanguage = matchKeyboardLanguage + self.newSimulator = newSimulator self.setOrientation = setOrientation self.appSwitcher = appSwitcher self.stopRecording = stopRecording @@ -192,6 +195,9 @@ public enum ViewerMenu { if let openSimulatorMenu { let fileItem = NSMenuItem() let fileMenu = NSMenu(title: "File") + if actions.newSimulator != nil { + fileMenu.addItem(target.item("New Simulator\u{2026}", #selector(MenuTarget.newSimulator), "n", [])) + } let open = NSMenuItem(title: "Open Simulator", action: nil, keyEquivalent: "") open.submenu = openSimulatorMenu fileMenu.addItem(open) @@ -546,6 +552,7 @@ public final class MenuTarget: NSObject, NSMenuDelegate, NSMenuItemValidation { @objc func iCloudSync() { actions.triggerICloudSync() } @objc func clearLocation() { actions.setLocation(nil) } @objc func customLocation() { actions.setCustomLocation() } + @objc func newSimulator() { actions.newSimulator?() } /// Both start on, because that is what the app does before anyone touches the menu. @objc func keyboardInput(_ sender: NSMenuItem) { diff --git a/engine/Sources/OpenDeviceHubViewer/ViewerSettings.swift b/engine/Sources/OpenDeviceHubViewer/ViewerSettings.swift index da67c89..7b8471e 100644 --- a/engine/Sources/OpenDeviceHubViewer/ViewerSettings.swift +++ b/engine/Sources/OpenDeviceHubViewer/ViewerSettings.swift @@ -54,6 +54,21 @@ public struct ViewerSettings: Sendable { } } + /// What Previous fills the New Simulator panel with. + public var lastCreatedSimulator: LastCreatedSimulator? { + get { + guard let stored = storage.text(forKey: prefix + "lastCreatedSimulator") else { return nil } + return LastCreatedSimulator(stored: stored) + } + nonmutating set { + guard let newValue else { + storage.removeText(forKey: prefix + "lastCreatedSimulator") + return + } + storage.setText(newValue.stored, forKey: prefix + "lastCreatedSimulator") + } + } + private func flag(_ name: String, default fallback: Bool) -> Bool { switch storage.text(forKey: prefix + name) { case "true": true @@ -66,3 +81,28 @@ public struct ViewerSettings: Sendable { storage.setText(value ? "true" : "false", forKey: prefix + name) } } + + +/// The last simulator created here, so the panel can offer it again. +/// +/// Stored as one tab separated line rather than JSON, because the storage seam is strings and a +/// name cannot contain a tab. +public struct LastCreatedSimulator: Equatable, Sendable { + public let name: String + public let deviceType: String + public let runtime: String + + public init(name: String, deviceType: String, runtime: String) { + self.name = name + self.deviceType = deviceType + self.runtime = runtime + } + + init?(stored: String) { + let parts = stored.split(separator: "\t", omittingEmptySubsequences: false) + guard parts.count == 3, !parts.allSatisfy(\.isEmpty) else { return nil } + self.init(name: String(parts[0]), deviceType: String(parts[1]), runtime: String(parts[2])) + } + + var stored: String { [name, deviceType, runtime].joined(separator: "\t") } +} diff --git a/engine/Tests/OpenDeviceHubEngineTests/SimctlDecodingTests.swift b/engine/Tests/OpenDeviceHubEngineTests/SimctlDecodingTests.swift new file mode 100644 index 0000000..1f30126 --- /dev/null +++ b/engine/Tests/OpenDeviceHubEngineTests/SimctlDecodingTests.swift @@ -0,0 +1,86 @@ +import XCTest +@testable import OpenDeviceHubEngine + +/// The JSON here is real `simctl` output, trimmed but not reshaped. Hand written models drift from +/// the tool they decode, and the failure is a runtime that silently supports nothing. +final class SimctlDecodingTests: XCTestCase { + /// Captured from `simctl list runtimes -j` on Xcode 27. Note the extra keys: the model has to + /// ignore what it does not want rather than fail on it. + private let runtimesJSON = """ + { + "runtimes": [ + { + "isAvailable": true, + "version": "27.1", + "buildversion": "24A94401", + "supportedDeviceTypes": [ + { + "bundlePath": "/Library/Developer/CoreSimulator/Profiles/DeviceTypes/iPhone Duo.simdevicetype", + "name": "iPhone Duo", + "productFamily": "iPhone", + "identifier": "com.apple.CoreSimulator.SimDeviceType.iPhone-Duo" + } + ], + "identifier": "com.apple.CoreSimulator.SimRuntime.iOS-27-1", + "name": "iOS 27.1" + } + ] + } + """ + + private let deviceTypesJSON = """ + { + "devicetypes": [ + { + "productFamily": "iPhone", + "bundlePath": "/Library/Developer/CoreSimulator/Profiles/DeviceTypes/iPhone 17.simdevicetype", + "maxRuntimeVersion": 4294967295, + "name": "iPhone 17", + "identifier": "com.apple.CoreSimulator.SimDeviceType.iPhone-17", + "modelIdentifier": "iPhone18,1", + "minRuntimeVersion": 1769472 + } + ] + } + """ + + func testRuntimeSupportKeepsTheDeviceTypesTheRuntimeCanRun() throws { + let decoded = try JSONDecoder().decode( + SimctlModels.RuntimeSupportList.self, + from: Data(runtimesJSON.utf8) + ) + XCTAssertEqual(decoded.runtimes.count, 1) + let runtime = try XCTUnwrap(decoded.runtimes.first) + XCTAssertEqual(runtime.name, "iOS 27.1") + XCTAssertTrue(runtime.isAvailable) + XCTAssertEqual( + runtime.supportedDeviceTypes?.map(\.identifier), + ["com.apple.CoreSimulator.SimDeviceType.iPhone-Duo"] + ) + } + + func testDeviceTypesDecodeDespiteTheKeysWeIgnore() throws { + let decoded = try JSONDecoder().decode( + SimctlModels.DeviceTypeList.self, + from: Data(deviceTypesJSON.utf8) + ) + XCTAssertEqual(decoded.devicetypes.map(\.name), ["iPhone 17"]) + XCTAssertEqual( + decoded.devicetypes.map(\.identifier), + ["com.apple.CoreSimulator.SimDeviceType.iPhone-17"] + ) + } + + /// A runtime with no supported device types at all must decode rather than throw, because the + /// key is absent in some simctl output. + func testAMissingSupportedDeviceTypesKeyIsNotAFailure() throws { + let json = """ + {"runtimes":[{"isAvailable":false,"version":"18.1","buildversion":"22B5","identifier":"x","name":"iOS 18.1"}]} + """ + let decoded = try JSONDecoder().decode( + SimctlModels.RuntimeSupportList.self, + from: Data(json.utf8) + ) + XCTAssertNil(decoded.runtimes.first?.supportedDeviceTypes) + } +} diff --git a/engine/Tests/OpenDeviceHubEngineTests/SimulatorCreationTests.swift b/engine/Tests/OpenDeviceHubEngineTests/SimulatorCreationTests.swift new file mode 100644 index 0000000..5e4379d --- /dev/null +++ b/engine/Tests/OpenDeviceHubEngineTests/SimulatorCreationTests.swift @@ -0,0 +1,99 @@ +import XCTest +@testable import OpenDeviceHubEngine + +/// The pairing is the part that fails late and confusingly: an unsupported combination is refused by +/// simctl with a message about the runtime, long after the person chose the device. +final class SimulatorCreationTests: XCTestCase { + private func runtime(_ name: String, _ version: String, available: Bool = true) -> SimctlRuntime { + SimctlRuntime( + identifier: "com.apple.CoreSimulator.SimRuntime.\(name.replacingOccurrences(of: " ", with: "-"))", + name: name, + version: version, + buildversion: "1", + isAvailable: available + ) + } + + private func type(_ name: String, _ identifier: String) -> SimctlDeviceType { + SimctlDeviceType(identifier: identifier, name: name) + } + + private let iPhone17 = SimctlDeviceType( + identifier: "com.apple.CoreSimulator.SimDeviceType.iPhone-17", + name: "iPhone 17" + ) + private let iPhoneDuo = SimctlDeviceType( + identifier: "com.apple.CoreSimulator.SimDeviceType.iPhone-Duo", + name: "iPhone Duo" + ) + + /// The real case that makes this matter: one runtime here supports a single device. + func testARuntimeIsOfferedOnlyForDevicesItSupports() { + let support = [ + SimctlRuntimeSupport(runtime: runtime("iOS 27.0", "27.0"), deviceTypeIdentifiers: [iPhone17.identifier]), + SimctlRuntimeSupport(runtime: runtime("iOS 27.1", "27.1"), deviceTypeIdentifiers: [iPhoneDuo.identifier]), + ] + XCTAssertEqual(SimulatorCreation.runtimes(for: iPhone17, in: support).map(\.name), ["iOS 27.0"]) + XCTAssertEqual(SimulatorCreation.runtimes(for: iPhoneDuo, in: support).map(\.name), ["iOS 27.1"]) + } + + func testNewestRuntimeComesFirst() { + let both: Set = [iPhone17.identifier] + let support = [ + SimctlRuntimeSupport(runtime: runtime("iOS 26.5", "26.5"), deviceTypeIdentifiers: both), + SimctlRuntimeSupport(runtime: runtime("iOS 27.0", "27.0"), deviceTypeIdentifiers: both), + SimctlRuntimeSupport(runtime: runtime("iOS 9.0", "9.0"), deviceTypeIdentifiers: both), + ] + XCTAssertEqual( + SimulatorCreation.runtimes(for: iPhone17, in: support).map(\.name), + ["iOS 27.0", "iOS 26.5", "iOS 9.0"] + ) + } + + func testAnUnavailableRuntimeIsNeverOffered() { + let support = [ + SimctlRuntimeSupport( + runtime: runtime("iOS 18.1", "18.1", available: false), + deviceTypeIdentifiers: [iPhone17.identifier] + ) + ] + XCTAssertTrue(SimulatorCreation.runtimes(for: iPhone17, in: support).isEmpty) + } + + func testOnlyIPhonesAndIPadsAreOffered() { + XCTAssertTrue(SimulatorCreation.isSupported(iPhone17)) + XCTAssertTrue(SimulatorCreation.isSupported( + type("iPad Pro 13-inch (M5)", "com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13") + )) + XCTAssertFalse(SimulatorCreation.isSupported( + type("Apple Watch Series 11", "com.apple.CoreSimulator.SimDeviceType.Apple-Watch-Series-11") + )) + XCTAssertFalse(SimulatorCreation.isSupported( + type("Apple TV 4K", "com.apple.CoreSimulator.SimDeviceType.Apple-TV-4K") + )) + } + + /// A device type no installed runtime can run would be a dead row in the picker. + func testADeviceNoRuntimeCanRunIsNotOffered() { + let support = [ + SimctlRuntimeSupport(runtime: runtime("iOS 27.0", "27.0"), deviceTypeIdentifiers: [iPhone17.identifier]) + ] + let offered = SimulatorCreation.deviceTypes(in: support, from: [iPhone17, iPhoneDuo]) + XCTAssertEqual(offered.map(\.name), ["iPhone 17"]) + } + + func testTheSuggestedNameIsTheDeviceName() { + XCTAssertEqual(SimulatorCreation.suggestedName(for: iPhone17, existing: []), "iPhone 17") + } + + func testATakenNameGetsANumber() { + XCTAssertEqual( + SimulatorCreation.suggestedName(for: iPhone17, existing: ["iPhone 17"]), + "iPhone 17 2" + ) + XCTAssertEqual( + SimulatorCreation.suggestedName(for: iPhone17, existing: ["iPhone 17", "iPhone 17 2"]), + "iPhone 17 3" + ) + } +}