diff --git a/.gitignore b/.gitignore index c854aa1..c315cdb 100644 --- a/.gitignore +++ b/.gitignore @@ -14,5 +14,13 @@ build/ .build/ .derivedData/ +## fastlane (generated screenshots and run artifacts) +## per https://docs.fastlane.tools/best-practices/source-control/ +fastlane/screenshots +fastlane/report.xml +fastlane/test_output +fastlane/Preview.html +fastlane/derived_data + ## macOS custom folder icon Icon[^a-zA-Z] diff --git a/ComputerSolitaire.xcodeproj/project.pbxproj b/ComputerSolitaire.xcodeproj/project.pbxproj index e0f30de..7c6fcc6 100644 --- a/ComputerSolitaire.xcodeproj/project.pbxproj +++ b/ComputerSolitaire.xcodeproj/project.pbxproj @@ -14,11 +14,19 @@ remoteGlobalIDString = 4B75C1D02F1A233400761A12; remoteInfo = ComputerSolitaire; }; + 4B75C1F02F1A233600761A12 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 4B75C1C92F1A233400761A12 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 4B75C1D02F1A233400761A12; + remoteInfo = ComputerSolitaire; + }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ 4B75C1D12F1A233400761A12 /* Computer Solitaire.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Computer Solitaire.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 4B75C1E42F1A233500761A12 /* ComputerSolitaireTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ComputerSolitaireTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 4B75C1F12F1A233600761A12 /* ComputerSolitaireUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ComputerSolitaireUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -45,6 +53,11 @@ path = ComputerSolitaireTests; sourceTree = ""; }; + 4B75C1F22F1A233600761A12 /* ComputerSolitaireUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = ComputerSolitaireUITests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -62,6 +75,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4B75C1F32F1A233600761A12 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -70,6 +90,7 @@ children = ( 4B75C1D32F1A233400761A12 /* ComputerSolitaire */, 4B75C1E52F1A233500761A12 /* ComputerSolitaireTests */, + 4B75C1F22F1A233600761A12 /* ComputerSolitaireUITests */, 4B75C1D22F1A233400761A12 /* Products */, ); sourceTree = ""; @@ -79,6 +100,7 @@ children = ( 4B75C1D12F1A233400761A12 /* Computer Solitaire.app */, 4B75C1E42F1A233500761A12 /* ComputerSolitaireTests.xctest */, + 4B75C1F12F1A233600761A12 /* ComputerSolitaireUITests.xctest */, ); name = Products; sourceTree = ""; @@ -131,6 +153,29 @@ productReference = 4B75C1E42F1A233500761A12 /* ComputerSolitaireTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + 4B75C1F62F1A233600761A12 /* ComputerSolitaireUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4B75C1FA2F1A233600761A12 /* Build configuration list for PBXNativeTarget "ComputerSolitaireUITests" */; + buildPhases = ( + 4B75C1F42F1A233600761A12 /* Sources */, + 4B75C1F32F1A233600761A12 /* Frameworks */, + 4B75C1F52F1A233600761A12 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 4B75C1F72F1A233600761A12 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 4B75C1F22F1A233600761A12 /* ComputerSolitaireUITests */, + ); + name = ComputerSolitaireUITests; + packageProductDependencies = ( + ); + productName = ComputerSolitaireUITests; + productReference = 4B75C1F12F1A233600761A12 /* ComputerSolitaireUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -148,6 +193,10 @@ CreatedOnToolsVersion = 26.2; TestTargetID = 4B75C1D02F1A233400761A12; }; + 4B75C1F62F1A233600761A12 = { + CreatedOnToolsVersion = 26.2; + TestTargetID = 4B75C1D02F1A233400761A12; + }; }; }; buildConfigurationList = 4B75C1CC2F1A233400761A12 /* Build configuration list for PBXProject "ComputerSolitaire" */; @@ -166,6 +215,7 @@ targets = ( 4B75C1D02F1A233400761A12 /* ComputerSolitaire */, 4B75C1E92F1A233500761A12 /* ComputerSolitaireTests */, + 4B75C1F62F1A233600761A12 /* ComputerSolitaireUITests */, ); }; /* End PBXProject section */ @@ -185,6 +235,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4B75C1F52F1A233600761A12 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -202,6 +259,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4B75C1F42F1A233600761A12 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -210,6 +274,11 @@ target = 4B75C1D02F1A233400761A12 /* ComputerSolitaire */; targetProxy = 4B75C1EA2F1A233500761A12 /* PBXContainerItemProxy */; }; + 4B75C1F72F1A233600761A12 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 4B75C1D02F1A233400761A12 /* ComputerSolitaire */; + targetProxy = 4B75C1F02F1A233600761A12 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -469,6 +538,44 @@ }; name = Release; }; + 4B75C1F82F1A233600761A12 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = T778QMSML9; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + MACOSX_DEPLOYMENT_TARGET = 26.2; + PRODUCT_BUNDLE_IDENTIFIER = com.crapshack.ComputerSolitaireUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = ComputerSolitaire; + }; + name = Debug; + }; + 4B75C1F92F1A233600761A12 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = T778QMSML9; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + MACOSX_DEPLOYMENT_TARGET = 26.2; + PRODUCT_BUNDLE_IDENTIFIER = com.crapshack.ComputerSolitaireUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = ComputerSolitaire; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -499,6 +606,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 4B75C1FA2F1A233600761A12 /* Build configuration list for PBXNativeTarget "ComputerSolitaireUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4B75C1F82F1A233600761A12 /* Debug */, + 4B75C1F92F1A233600761A12 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 4B75C1C92F1A233400761A12 /* Project object */; diff --git a/ComputerSolitaire.xcodeproj/xcshareddata/xcschemes/ComputerSolitaire.xcscheme b/ComputerSolitaire.xcodeproj/xcshareddata/xcschemes/ComputerSolitaire.xcscheme new file mode 100644 index 0000000..780d47a --- /dev/null +++ b/ComputerSolitaire.xcodeproj/xcshareddata/xcschemes/ComputerSolitaire.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ComputerSolitaire.xcodeproj/xcshareddata/xcschemes/ComputerSolitaireScreenshots.xcscheme b/ComputerSolitaire.xcodeproj/xcshareddata/xcschemes/ComputerSolitaireScreenshots.xcscheme new file mode 100644 index 0000000..787b12c --- /dev/null +++ b/ComputerSolitaire.xcodeproj/xcshareddata/xcschemes/ComputerSolitaireScreenshots.xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ComputerSolitaire/ComputerSolitaireApp.swift b/ComputerSolitaire/ComputerSolitaireApp.swift index 58c7142..e5b2de4 100644 --- a/ComputerSolitaire/ComputerSolitaireApp.swift +++ b/ComputerSolitaire/ComputerSolitaireApp.swift @@ -17,9 +17,22 @@ struct ComputerSolitaireApp: App { var body: some Scene { #if os(macOS) WindowGroup { - ContentView() - .frame(minWidth: MainWindowMetrics.minWidth, minHeight: MainWindowMetrics.minHeight) +#if DEBUG + // Screenshot capture (`-screenshotWindowSize`): pin the content + // area to the exact requested size; windowResizability below + // makes the window conform, so the capture UI test gets exact + // App Store pixels without any window manipulation. + if let size = ScreenshotFixtures.requestedWindowSize { + ContentView() + .frame(width: size.width, height: size.height) + } else { + mainWindowContent + } +#else + mainWindowContent +#endif } + .windowResizability(mainWindowResizability) .modelContainer(for: SavedGameRecord.self, isAutosaveEnabled: true, isUndoEnabled: false) .commands { appCommands @@ -42,6 +55,21 @@ struct ComputerSolitaireApp: App { #endif } +#if os(macOS) + private var mainWindowContent: some View { + ContentView() + .frame(minWidth: MainWindowMetrics.minWidth, minHeight: MainWindowMetrics.minHeight) + } + + private var mainWindowResizability: WindowResizability { +#if DEBUG + ScreenshotFixtures.requestedWindowSize != nil ? .contentSize : .automatic +#else + .automatic +#endif + } +#endif + @CommandsBuilder private var appCommands: some Commands { #if os(macOS) diff --git a/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift b/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift new file mode 100644 index 0000000..86735af --- /dev/null +++ b/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift @@ -0,0 +1,72 @@ +import Foundation + +/// A staged mid-game board for App Store screenshots. +struct ScreenshotFixture: Identifiable, Hashable { + /// Bundle resource name of the `SavedGamePayload` JSON, and the screenshot + /// file name the capture UI test emits. + let name: String + /// Human-readable description of the board. + let title: String + + var id: String { name } +} + +/// Staged boards for App Store screenshots. +/// +/// Launching with `-screenshotFixture ` restores the named board instead +/// of the saved game (DEBUG builds only). `ScreenshotCaptureUITests` does this +/// for every bundled board and captures the screenshots; run it via +/// `fastlane screenshots`. Loading suppresses autosave for the rest of the +/// session so a screenshot run can't clobber a real in-progress game, and +/// payloads carry `hasStartedTrackedGame: false` so statistics stay untouched. +/// +/// Fixtures are generated — not hand-edited — by `ScreenshotFixtureTests`, which +/// plays a seeded deal forward with the hint planner; hand-edited payloads risk +/// failing the persistence validity gate and silently falling back to a new deal. +enum ScreenshotFixtures { + static let launchArgument = "-screenshotFixture" + + /// Boards shipped in the app bundle; validated by `ScreenshotFixtureTests`. + /// One entry per App Store screenshot, in store order. + static let bundled: [ScreenshotFixture] = [ + ScreenshotFixture(name: "klondike-draw3", title: "Klondike – Draw 3"), + ScreenshotFixture(name: "freecell", title: "FreeCell – fresh deal") + ] + + static func payloadFromLaunchArguments() -> SavedGamePayload? { + guard let name = value(after: launchArgument) else { return nil } + return payload(named: name, in: .main) + } + + /// Content-area size requested via `-screenshotWindowSize x` + /// (in points). The app scene pins its content to this size (see + /// `ComputerSolitaireApp`); the Mac capture uses 1440x900 so the content + /// screenshot comes out at 2880x1800 pixels on a Retina display — an + /// exact Mac App Store size. + static var requestedWindowSize: CGSize? { + size(after: "-screenshotWindowSize") + } + + private static func size(after flag: String) -> CGSize? { + guard let raw = value(after: flag) else { return nil } + let parts = raw.split(separator: "x").compactMap { Double($0) } + guard parts.count == 2, parts[0] > 0, parts[1] > 0 else { return nil } + return CGSize(width: parts[0], height: parts[1]) + } + + private static func value(after flag: String) -> String? { + let arguments = ProcessInfo.processInfo.arguments + guard let flagIndex = arguments.firstIndex(of: flag), + arguments.indices.contains(flagIndex + 1) else { + return nil + } + return arguments[flagIndex + 1] + } + + static func payload(named name: String, in bundle: Bundle) -> SavedGamePayload? { + let url = bundle.url(forResource: name, withExtension: "json") + ?? bundle.url(forResource: name, withExtension: "json", subdirectory: "Fixtures") + guard let url, let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(SavedGamePayload.self, from: data) + } +} diff --git a/ComputerSolitaire/Fixtures/freecell.json b/ComputerSolitaire/Fixtures/freecell.json new file mode 100644 index 0000000..bfcb98f --- /dev/null +++ b/ComputerSolitaire/Fixtures/freecell.json @@ -0,0 +1,586 @@ +{ + "gameStartedAt" : 721692797, + "hasAppliedTimeBonus" : false, + "hasStartedTrackedGame" : false, + "hintRequestsInCurrentGame" : 0, + "history" : [ + + ], + "isCurrentGameFinalized" : false, + "movesCount" : 0, + "savedAt" : 721692800, + "schemaVersion" : 1, + "score" : 0, + "scoringDrawCount" : 3, + "state" : { + "foundations" : [ + [ + + ], + [ + + ], + [ + + ], + [ + + ] + ], + "freeCells" : [ + null, + null, + null, + null + ], + "stock" : [ + + ], + "tableau" : [ + [ + { + "id" : "770EBE86-F35F-49CF-BB45-9C1FA312B000", + "isFaceUp" : true, + "rank" : 2, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "20A79B7C-7B85-4EC1-BF2D-94C6E5F3A8AF", + "isFaceUp" : true, + "rank" : 12, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "6B275C78-EB7D-4B91-BA7A-7DEB680D2DC3", + "isFaceUp" : true, + "rank" : 5, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "602BC229-CD32-40E9-8EE0-C39DD8FA8D8C", + "isFaceUp" : true, + "rank" : 3, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "16244118-3B96-48E5-8010-1CC2A9D92075", + "isFaceUp" : true, + "rank" : 5, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "65FEC4B0-2EFB-4841-8D13-C8C572470C2D", + "isFaceUp" : true, + "rank" : 8, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "EAADC5B9-7361-4BE9-8348-301A40329400", + "isFaceUp" : true, + "rank" : 6, + "suit" : { + "hearts" : { + + } + } + } + ], + [ + { + "id" : "68CE18A1-7ACD-4A62-8D52-930F0DB7DA9C", + "isFaceUp" : true, + "rank" : 8, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "B9C6A254-E971-4B29-9FED-16B582ABCF97", + "isFaceUp" : true, + "rank" : 1, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "AC47B3B5-9A94-4988-ADA8-0CEAFF24BBE1", + "isFaceUp" : true, + "rank" : 9, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "3F70C117-9466-4BF9-B155-0D5F64C05235", + "isFaceUp" : true, + "rank" : 13, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "540052CF-2DD3-4261-8250-5B8F103F9E2E", + "isFaceUp" : true, + "rank" : 3, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "2A8BD432-E0A9-43AC-92F0-15A44198C9B5", + "isFaceUp" : true, + "rank" : 10, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "877700D7-CEC0-4988-914B-DF08B3ACD1C9", + "isFaceUp" : true, + "rank" : 4, + "suit" : { + "diamonds" : { + + } + } + } + ], + [ + { + "id" : "DFF83308-9B74-4169-BB58-B5B543416B19", + "isFaceUp" : true, + "rank" : 11, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "FEB24713-DF71-4936-85BA-E6B53D73612E", + "isFaceUp" : true, + "rank" : 3, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "5C5F1127-77F0-4572-A9EA-77D0509A64E9", + "isFaceUp" : true, + "rank" : 12, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "E0BB7B8D-5B6C-4E53-964F-6CEB63470BB8", + "isFaceUp" : true, + "rank" : 10, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "2CD7045C-61E6-46EF-9882-3F5E5CAB47D5", + "isFaceUp" : true, + "rank" : 6, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "B4388856-69DA-42BC-91F2-0495C8956981", + "isFaceUp" : true, + "rank" : 7, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "59A50F9D-81DD-4F90-9AD1-0AEC59FA6797", + "isFaceUp" : true, + "rank" : 11, + "suit" : { + "clubs" : { + + } + } + } + ], + [ + { + "id" : "983D442E-2C8E-483B-91AF-2B43BC4A58AD", + "isFaceUp" : true, + "rank" : 8, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "E34295E7-BD97-490B-9E21-449D95E5CD20", + "isFaceUp" : true, + "rank" : 10, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "36226DF0-B8A0-4125-AF9A-90B923E9C443", + "isFaceUp" : true, + "rank" : 1, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "7C1F501A-C6BF-44DE-8F88-EDD4B159C0DD", + "isFaceUp" : true, + "rank" : 11, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "67F020C8-03B6-48D6-8CAD-8CC664FECFA3", + "isFaceUp" : true, + "rank" : 7, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "03F54880-16DE-439D-82D8-68ACE174192A", + "isFaceUp" : true, + "rank" : 8, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "AB89B32E-039B-47B6-ACDD-0FFDDA1D1D98", + "isFaceUp" : true, + "rank" : 7, + "suit" : { + "diamonds" : { + + } + } + } + ], + [ + { + "id" : "AC810F6F-CCBB-41C6-ACC5-685E5199534D", + "isFaceUp" : true, + "rank" : 9, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "C9C6D163-C0AB-41BB-B204-E085466FEF45", + "isFaceUp" : true, + "rank" : 12, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "0CDDAE1A-12B1-43E8-A2FB-5EF3D27BAF37", + "isFaceUp" : true, + "rank" : 2, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "FBE0092D-9F12-4F6E-9C72-352DBCA9CF79", + "isFaceUp" : true, + "rank" : 4, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "5A15B4B4-2612-42A8-81E8-C05421E65C51", + "isFaceUp" : true, + "rank" : 3, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "673EEDD9-990E-4D8C-BD30-5F593A75CF46", + "isFaceUp" : true, + "rank" : 1, + "suit" : { + "spades" : { + + } + } + } + ], + [ + { + "id" : "BE38EE98-58D7-43C2-886E-EFB5D00A51D6", + "isFaceUp" : true, + "rank" : 1, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "D1B266F6-6CCF-4B76-9339-FC8B1BC3F9F4", + "isFaceUp" : true, + "rank" : 9, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "06FDCA9B-21B5-40D6-9B1A-ED30BB50677B", + "isFaceUp" : true, + "rank" : 4, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "D9A866FA-02B3-459C-B937-2EED4D3406CB", + "isFaceUp" : true, + "rank" : 12, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "FAADCB0D-56F9-4C80-9AC0-467590900B4A", + "isFaceUp" : true, + "rank" : 9, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "35B22E97-672B-412D-8525-96F4965DEDFE", + "isFaceUp" : true, + "rank" : 5, + "suit" : { + "clubs" : { + + } + } + } + ], + [ + { + "id" : "B9A76854-F082-46AA-89DB-336CA54175C6", + "isFaceUp" : true, + "rank" : 10, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "13149C1E-70C9-42F9-B070-22048FF2C1D2", + "isFaceUp" : true, + "rank" : 5, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "231F0678-C6C5-46D9-B9C1-AD5F0A5CD14A", + "isFaceUp" : true, + "rank" : 4, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "7A92F288-EB6F-4AFC-958A-588B3B9265F6", + "isFaceUp" : true, + "rank" : 13, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "7242381F-E5F7-4ECF-AA68-03B72789130B", + "isFaceUp" : true, + "rank" : 2, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "4340F546-BB5C-4D3C-A18A-3E15FE01B379", + "isFaceUp" : true, + "rank" : 7, + "suit" : { + "clubs" : { + + } + } + } + ], + [ + { + "id" : "EDEFDC9D-C859-4CBE-A617-FA6114DCD996", + "isFaceUp" : true, + "rank" : 6, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "6A7526FA-69AD-49E7-AC3B-06943E359DA1", + "isFaceUp" : true, + "rank" : 2, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "6606B154-DBE8-4D12-876B-0E141AF6F78E", + "isFaceUp" : true, + "rank" : 11, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "A4F23547-4ADE-4AAC-B57D-488540B90A96", + "isFaceUp" : true, + "rank" : 6, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "73554120-5211-4F63-BF05-E74B799C1CDD", + "isFaceUp" : true, + "rank" : 13, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "7ADA6C71-51B1-47FE-B49E-39E960041762", + "isFaceUp" : true, + "rank" : 13, + "suit" : { + "clubs" : { + + } + } + } + ] + ], + "variant" : "freecell", + "waste" : [ + + ], + "wasteDrawCount" : 0 + }, + "stockDrawCount" : 3, + "undosUsedInCurrentGame" : 0, + "usedRedealInCurrentGame" : false +} \ No newline at end of file diff --git a/ComputerSolitaire/Fixtures/klondike-draw3.json b/ComputerSolitaire/Fixtures/klondike-draw3.json new file mode 100644 index 0000000..77f746f --- /dev/null +++ b/ComputerSolitaire/Fixtures/klondike-draw3.json @@ -0,0 +1,582 @@ +{ + "gameStartedAt" : 721692797, + "hasAppliedTimeBonus" : false, + "hasStartedTrackedGame" : false, + "hintRequestsInCurrentGame" : 0, + "history" : [ + + ], + "isCurrentGameFinalized" : false, + "movesCount" : 1, + "savedAt" : 721692800, + "schemaVersion" : 1, + "score" : 0, + "scoringDrawCount" : 3, + "state" : { + "foundations" : [ + [ + + ], + [ + + ], + [ + + ], + [ + + ] + ], + "freeCells" : [ + null, + null, + null, + null + ], + "stock" : [ + { + "id" : "A7B2B354-8A50-47BF-9E16-32757AE8824A", + "isFaceUp" : false, + "rank" : 3, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "12C730ED-39BD-4012-BC4E-472A00061721", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "23A49861-FEBF-487A-8E34-208F3F7BF2B0", + "isFaceUp" : false, + "rank" : 6, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "895B73D5-8288-4663-A474-19096F53C1CB", + "isFaceUp" : false, + "rank" : 5, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "EC28F4CB-6D69-4331-B082-185BA7501961", + "isFaceUp" : false, + "rank" : 5, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "5D1A6C64-90FE-461B-A4CE-94DE38360659", + "isFaceUp" : false, + "rank" : 8, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "C8C06A10-AC30-48B2-8C4A-D4D7F09BAF43", + "isFaceUp" : false, + "rank" : 11, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "9E2F7439-9148-4F0D-8CC0-AC85FC15A99F", + "isFaceUp" : false, + "rank" : 12, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "CBEF0B02-2211-42CE-9F51-11E96C815A4E", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "0F7CBF57-3E32-4114-A7D3-8990F6E8ED38", + "isFaceUp" : false, + "rank" : 2, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "4A12F13F-9682-4177-A10D-B38E31E85193", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "6CE32D4A-094E-405C-BC13-26C97A3E6EDD", + "isFaceUp" : false, + "rank" : 11, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "DDBE7A7E-D341-425D-B2F4-87844299FB13", + "isFaceUp" : false, + "rank" : 13, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "28922C8C-AA76-43D0-B98E-8C3B11E43DF4", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "02330A27-293A-41C6-B388-CC337CCAE75B", + "isFaceUp" : false, + "rank" : 8, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "ABB490D7-A93E-4BE2-9701-3897298A4438", + "isFaceUp" : false, + "rank" : 4, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "9E4B4CD4-643B-4138-8B89-CCE7FC631984", + "isFaceUp" : false, + "rank" : 3, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "15E387DC-0472-4758-A55C-C5FE4653A5DE", + "isFaceUp" : false, + "rank" : 6, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "1FD2E586-C1F0-434A-9393-71238FB9DB81", + "isFaceUp" : false, + "rank" : 6, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "CC6B9853-9CF0-4E5F-AC1C-C7246DDBA133", + "isFaceUp" : false, + "rank" : 13, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "8C1BF1CE-F69C-493C-8597-16C3ED19B2CA", + "isFaceUp" : false, + "rank" : 13, + "suit" : { + "diamonds" : { + + } + } + } + ], + "tableau" : [ + [ + { + "id" : "DE7DEC58-03F9-45EE-8322-BD992FF7786F", + "isFaceUp" : true, + "rank" : 12, + "suit" : { + "spades" : { + + } + } + } + ], + [ + { + "id" : "8709412C-9FFC-4ED7-BE4B-B095698DE90A", + "isFaceUp" : false, + "rank" : 13, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "334267D0-5363-4E79-AF5F-8B10948706EA", + "isFaceUp" : true, + "rank" : 1, + "suit" : { + "clubs" : { + + } + } + } + ], + [ + { + "id" : "B551E4E0-0896-4AE1-85AC-F26967953AA1", + "isFaceUp" : false, + "rank" : 1, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "8D5130B5-0749-4BE9-B4C3-9EECBD1050CB", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "19E0F2F7-31E4-486A-83C2-6C7E1E58B015", + "isFaceUp" : true, + "rank" : 9, + "suit" : { + "spades" : { + + } + } + } + ], + [ + { + "id" : "6DA29C70-E54C-492A-A02B-B42A681DE877", + "isFaceUp" : false, + "rank" : 12, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "1D678A9F-F904-44F8-94AE-26131A0F9EDA", + "isFaceUp" : false, + "rank" : 3, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "E58D8D4D-585F-4482-A0E0-1BE9F4322B7C", + "isFaceUp" : false, + "rank" : 3, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "CC88FE47-FAD1-41DA-8EF0-E8FE2420ECD0", + "isFaceUp" : true, + "rank" : 10, + "suit" : { + "diamonds" : { + + } + } + } + ], + [ + { + "id" : "F9683E1B-EBD2-4137-A338-BA25766289C8", + "isFaceUp" : false, + "rank" : 4, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "F46225A2-F19C-4162-B609-5F878FD5AD1F", + "isFaceUp" : false, + "rank" : 2, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "4B99EB68-D846-4C3F-87AB-B3AF13DE94D9", + "isFaceUp" : false, + "rank" : 4, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "02F70D16-04CF-4FCA-AE13-F011AE060F91", + "isFaceUp" : false, + "rank" : 4, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "0663AF04-156A-49E5-905F-78A00561492F", + "isFaceUp" : true, + "rank" : 2, + "suit" : { + "spades" : { + + } + } + } + ], + [ + { + "id" : "D50AF663-6A1F-42C1-8FCF-CBB5C76AC1C2", + "isFaceUp" : false, + "rank" : 9, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "A0A96C6B-39F4-4534-A8FC-038A2EFD3E62", + "isFaceUp" : false, + "rank" : 12, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "D72FB1FB-5430-485F-81EE-4734F530D02B", + "isFaceUp" : false, + "rank" : 8, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "A784E6D2-4783-4904-A2FD-96B02F6677BB", + "isFaceUp" : false, + "rank" : 11, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "37C0BBDD-9959-4D98-A0AB-C2063C3D7CF8", + "isFaceUp" : false, + "rank" : 1, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "A61F8F33-FFEB-44C3-818C-5134587A031B", + "isFaceUp" : true, + "rank" : 6, + "suit" : { + "hearts" : { + + } + } + } + ], + [ + { + "id" : "BEEE2065-AF03-4876-A1A9-D84D48FB4F46", + "isFaceUp" : false, + "rank" : 1, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "B6D1ACE0-7C81-4EEE-A34D-326DBDAEB8FD", + "isFaceUp" : false, + "rank" : 9, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "07BF44AE-675D-4811-BB15-9CE87DC02CD3", + "isFaceUp" : false, + "rank" : 9, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "EAC8DCAA-6772-491E-BC5F-591797880AF9", + "isFaceUp" : false, + "rank" : 5, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "9B7B6664-0AC0-43DC-B203-E0B4C3A42C49", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "EF8530F2-BB1E-48DF-96D7-D5DB5BFE9A0C", + "isFaceUp" : false, + "rank" : 2, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "A39E62C4-D650-4D1D-AA90-35B9449B003E", + "isFaceUp" : true, + "rank" : 5, + "suit" : { + "diamonds" : { + + } + } + } + ] + ], + "variant" : "klondike", + "waste" : [ + { + "id" : "F49CDACD-1ECF-4803-ABF7-7995897B00CA", + "isFaceUp" : true, + "rank" : 7, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "B7166781-1412-4CA9-90CC-2FE488BFB91B", + "isFaceUp" : true, + "rank" : 8, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "314B102C-8713-4689-8820-6FD272C30C99", + "isFaceUp" : true, + "rank" : 11, + "suit" : { + "clubs" : { + + } + } + } + ], + "wasteDrawCount" : 3 + }, + "stockDrawCount" : 3, + "undosUsedInCurrentGame" : 0, + "usedRedealInCurrentGame" : false +} \ No newline at end of file diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index 14a4a8d..fd7c181 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -118,6 +118,9 @@ struct ContentView: View { @State private var previousStockCount: Int = 0 @State private var hasLoadedGame = false @State private var isHydratingGame = false + // True while a screenshot board is on screen; suppresses autosave so the + // staged game never overwrites the real one. Only set in DEBUG builds. + @State private var isScreenshotSession = false @State private var autosaveTask: Task? @State private var isAutoFinishing = false @State private var isShowingRulesAndScoring = false @@ -341,6 +344,7 @@ struct ContentView: View { let variant = GameVariant(rawValue: newValue) ?? .klondike stopAutoFinish() winCelebration.reset(to: .idle) + isScreenshotSession = false viewModel.newGame(variant: variant, drawMode: drawMode) persistGameNow() } @@ -740,6 +744,7 @@ struct ContentView: View { private func startNewGameFromUI(variant: GameVariant? = nil) { stopAutoFinish() winCelebration.reset(to: .idle) + isScreenshotSession = false let selectedVariant = variant ?? gameVariant viewModel.newGame(variant: selectedVariant, drawMode: drawMode) persistGameNow() @@ -1353,7 +1358,9 @@ struct ContentView: View { previousStockCount = viewModel.state.stock.count } - if let payload = GamePersistence.load(from: modelContext), viewModel.restore(from: payload) { + if restoreScreenshotFixtureIfRequested() { + // Staged board loaded; shared post-load setup below still applies. + } else if let payload = GamePersistence.load(from: modelContext), viewModel.restore(from: payload) { if gameVariantRawValue != viewModel.gameVariant.rawValue { gameVariantRawValue = viewModel.gameVariant.rawValue } @@ -1443,8 +1450,29 @@ struct ContentView: View { return min(1, widthScale, heightScale) } + private func restoreScreenshotFixtureIfRequested() -> Bool { +#if DEBUG + guard let payload = ScreenshotFixtures.payloadFromLaunchArguments(), + viewModel.restore(from: payload) else { + return false + } + isScreenshotSession = true + if gameVariantRawValue != viewModel.gameVariant.rawValue { + gameVariantRawValue = viewModel.gameVariant.rawValue + } + if viewModel.supportsDrawMode, drawModeRawValue != viewModel.stockDrawCount { + drawModeRawValue = viewModel.stockDrawCount + } + return true +#else + return false +#endif + } + private func persistGameNow() { guard hasLoadedGame else { return } + // A screenshot session must never overwrite the real saved game. + guard !isScreenshotSession else { return } autosaveTask?.cancel() autosaveTask = nil do { diff --git a/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift b/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift new file mode 100644 index 0000000..41cdac8 --- /dev/null +++ b/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift @@ -0,0 +1,238 @@ +import Foundation +import XCTest +@testable import Computer_Solitaire + +/// Validates the screenshot fixtures shipped in the app bundle (see `ScreenshotFixtures`). +@MainActor +final class ScreenshotFixtureBundleTests: XCTestCase { + func testBundledFixturesDecodeAndRestore() throws { + let bundle = Bundle(for: SolitaireViewModel.self) + for board in ScreenshotFixtures.bundled { + let name = board.name + let payload = try XCTUnwrap( + ScreenshotFixtures.payload(named: name, in: bundle), + "\(name).json is missing from the app bundle or failed to decode" + ) + XCTAssertFalse( + payload.hasStartedTrackedGame, + "\(name): fixtures must not contribute to statistics" + ) + XCTAssertNotNil( + payload.sanitizedForRestore(), + "\(name): fixture failed the persistence validity gate" + ) + let viewModel = SolitaireViewModel() + XCTAssertTrue(viewModel.restore(from: payload), "\(name): restore failed") + XCTAssertFalse(viewModel.isWin, "\(name): a screenshot fixture should be mid-game") + } + } +} + +/// Generates the staged `SavedGamePayload` JSON behind the `-screenshotFixture` +/// launch argument. Skipped in normal runs; to regenerate: +/// +/// TEST_RUNNER_GENERATE_SCREENSHOT_FIXTURES=1 xcodebuild test \ +/// -project ComputerSolitaire.xcodeproj -scheme ComputerSolitaire \ +/// -destination 'platform=macOS' \ +/// -only-testing:ComputerSolitaireTests/ScreenshotFixtureGeneratorTests +/// +/// then copy the file printed after `SCREENSHOT-FIXTURE-OUTPUT:` into +/// `ComputerSolitaire/Fixtures/` and add an entry to +/// `ScreenshotFixtures.bundled`. +@MainActor +final class ScreenshotFixtureGeneratorTests: XCTestCase { + private static let candidateSeeds: ClosedRange = 1...500 + /// Elapsed time the HUD timer shows when the fixture loads: a freshly + /// dealt board a few seconds in. + private static let stagedElapsedSeconds: TimeInterval = 3 + + /// The staged board is a clean new deal with the first three-card draw + /// fanned in the waste. Seeds are scanned for the most photogenic spread + /// of visible cards; the winner is then played through the real view model + /// so the HUD numbers are authentic. + func testGenerateKlondikeDrawThreeFixture() throws { + try skipUnlessGenerating() + + var bestSeed: UInt64? + var bestScore = Int.min + for seed in Self.candidateSeeds { + let deal = GameStateFixtures.seededKlondikeDeal(seed: seed) + let score = freshDealScore(of: deal) + if score > bestScore { + bestScore = score + bestSeed = seed + } + } + let seed = try XCTUnwrap(bestSeed) + + let viewModel = SolitaireViewModel() + viewModel.state = GameStateFixtures.seededKlondikeDeal(seed: seed) + viewModel.configureKlondikeNewGame(drawMode: .three) + viewModel.handleStockTap() + let winner = Candidate( + seed: seed, + state: viewModel.state, + movesCount: viewModel.movesCount, + gameScore: viewModel.score, + photogenicScore: bestScore + ) + + let savedAt = DateFixtures.reference + let payload = SavedGamePayload( + savedAt: savedAt, + state: winner.state, + movesCount: winner.movesCount, + score: winner.gameScore, + gameStartedAt: savedAt.addingTimeInterval(-Self.stagedElapsedSeconds), + stockDrawCount: DrawMode.three.rawValue, + history: [], + hasStartedTrackedGame: false + ) + + XCTAssertNotNil(payload.sanitizedForRestore(), "Generated fixture failed the validity gate") + let restoredViewModel = SolitaireViewModel() + XCTAssertTrue(restoredViewModel.restore(from: payload), "Generated fixture failed to restore") + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(payload) + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("klondike-draw3.json") + try data.write(to: outputURL) + + print("SCREENSHOT-FIXTURE-OUTPUT: \(outputURL.path)") + print(summary(of: winner)) + } + + /// The staged FreeCell board is a fresh deal — every card face up, free + /// cells and foundations empty. Seeds are scanned for the most photogenic + /// spread across the cascade tails (the cards the eye lands on). + func testGenerateFreeCellFixture() throws { + try skipUnlessGenerating() + + var bestSeed: UInt64? + var bestScore = Int.min + for seed in Self.candidateSeeds { + let deal = GameStateFixtures.seededFreeCellDeal(seed: seed) + let score = freeCellDealScore(of: deal) + if score > bestScore { + bestScore = score + bestSeed = seed + } + } + let seed = try XCTUnwrap(bestSeed) + + let viewModel = SolitaireViewModel() + viewModel.state = GameStateFixtures.seededFreeCellDeal(seed: seed) + viewModel.configureFreeCellNewGame() + + let savedAt = DateFixtures.reference + let payload = SavedGamePayload( + savedAt: savedAt, + state: viewModel.state, + movesCount: viewModel.movesCount, + score: viewModel.score, + gameStartedAt: savedAt.addingTimeInterval(-Self.stagedElapsedSeconds), + stockDrawCount: DrawMode.three.rawValue, + history: [], + hasStartedTrackedGame: false + ) + + XCTAssertNotNil(payload.sanitizedForRestore(), "Generated fixture failed the validity gate") + let restoredViewModel = SolitaireViewModel() + XCTAssertTrue(restoredViewModel.restore(from: payload), "Generated fixture failed to restore") + XCTAssertEqual(restoredViewModel.gameVariant, .freecell, "Fixture did not restore as FreeCell") + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(payload) + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("freecell.json") + try data.write(to: outputURL) + + print("SCREENSHOT-FIXTURE-OUTPUT: \(outputURL.path)") + print("FreeCell fixture — seed \(seed), photogenic \(bestScore)") + } + + // MARK: - Photogenic scoring + + private struct Candidate { + let seed: UInt64 + let state: GameState + let movesCount: Int + let gameScore: Int + let photogenicScore: Int + } + + /// Scores a fresh FreeCell deal by the sixteen cards on the cascade tails + /// (the last two rows): rank variety, red/black balance, all four suits, + /// a few face cards, and an ace on a tail read well. + private func freeCellDealScore(of deal: GameState) -> Int { + let visible = deal.tableau.flatMap { $0.suffix(2) } + var score = 0 + score += Set(visible.map(\.rank)).count * 6 + let redCount = visible.count(where: { $0.suit.isRed }) + score -= abs(redCount * 2 - visible.count) * 4 + score += Set(visible.map(\.suit)).count == Suit.allCases.count ? 8 : 0 + score += visible.count(where: { $0.rank >= .jack }) >= 3 ? 6 : 0 + score += deal.tableau.compactMap { $0.last }.contains(where: { $0.rank == .ace }) ? 6 : 0 + return score + } + + /// Scores a fresh deal by the ten cards a first draw makes visible: the + /// seven tableau tops plus the three stock cards that land in the waste. + /// Rank variety, red/black balance, all four suits, and a couple of face + /// cards make the spread read well. + private func freshDealScore(of deal: GameState) -> Int { + let tableauTops = deal.tableau.compactMap { $0.last } + let drawnCards = Array(deal.stock.suffix(DrawMode.three.rawValue)) + let visible = tableauTops + drawnCards + + var score = 0 + score += Set(visible.map(\.rank)).count * 6 + let redCount = visible.count(where: { $0.suit.isRed }) + score -= abs(redCount * 2 - visible.count) * 4 + score += Set(visible.map(\.suit)).count == Suit.allCases.count ? 8 : 0 + score += visible.count(where: { $0.rank >= .jack }) >= 2 ? 6 : 0 + score += visible.contains(where: { $0.rank == .ace }) ? 4 : 0 + return score + } + + // MARK: - Reporting + + private func summary(of candidate: Candidate) -> String { + func label(_ card: Card) -> String { + let suits: [Suit: String] = [.spades: "♠", .hearts: "♥", .diamonds: "♦", .clubs: "♣"] + return card.isFaceUp ? "\(card.rank.label)\(suits[card.suit] ?? "?")" : "··" + } + var lines = [ + "Fixture candidate — seed \(candidate.seed), " + + "moves \(candidate.movesCount), score \(candidate.gameScore), " + + "photogenic \(candidate.photogenicScore)" + ] + let foundations = candidate.state.foundations + .map { $0.last.map(label) ?? "—" } + .joined(separator: " ") + lines.append("Foundations: \(foundations)") + let visibleWaste = candidate.state.waste + .suffix(candidate.state.wasteDrawCount) + .map(label) + .joined(separator: " ") + lines.append("Stock: \(candidate.state.stock.count) cards, waste fan: \(visibleWaste)") + for (index, pile) in candidate.state.tableau.enumerated() { + lines.append("Pile \(index + 1): \(pile.map(label).joined(separator: " "))") + } + return lines.joined(separator: "\n") + } + + private func skipUnlessGenerating() throws { + // The documented command sets TEST_RUNNER_GENERATE_SCREENSHOT_FIXTURES; + // xcodebuild strips the TEST_RUNNER_ prefix when passing environment + // variables into the test process, so the name checked here is correct. + guard ProcessInfo.processInfo.environment["GENERATE_SCREENSHOT_FIXTURES"] == "1" else { + throw XCTSkip( + "Fixture generator; set TEST_RUNNER_GENERATE_SCREENSHOT_FIXTURES=1 to regenerate." + ) + } + } +} diff --git a/ComputerSolitaireUITests/ScreenshotCaptureUITests.swift b/ComputerSolitaireUITests/ScreenshotCaptureUITests.swift new file mode 100644 index 0000000..77ddaae --- /dev/null +++ b/ComputerSolitaireUITests/ScreenshotCaptureUITests.swift @@ -0,0 +1,174 @@ +import XCTest +#if canImport(UIKit) +import UIKit +#endif +#if canImport(AppKit) +import AppKit +#endif + +/// Captures App Store screenshots: launches the app once per staged board +/// (see `ScreenshotFixtures` in the app target) and captures the screen. +/// Run `fastlane screenshots` to drive this across every device size; images +/// land in `fastlane/screenshots/`. On iOS runs fastlane's `snapshot()` does +/// the capture; the macOS leg (which snapshot doesn't support) runs this same +/// test via xcodebuild and collects the attachment instead. +final class ScreenshotCaptureUITests: XCTestCase { + /// Kept in sync with `ScreenshotFixtures.bundled` by hand: UI tests run in + /// a separate process and cannot import the app module. One board per App + /// Store screenshot, in store order. + private static let boards = [ + "klondike-draw3", + "freecell" + ] + + /// Appearance for every screenshot, pinned via UserDefaults launch + /// arguments so simulator state can't change the look between runs. + private static let appearance = [ + "-settings.tableBackgroundColor", "#67a3d9", // sky + "-settings.cardStyle", "classic", + "-settings.feltEffectEnabled", "YES" + ] + +#if os(macOS) + /// Full window size in points — title bar and toolbar included, since + /// the toolbar holds the app's controls and belongs in the screenshot. + /// On a 2x display the capture comes out at 2880x1800 pixels — an exact + /// Mac App Store screenshot size. + private static let windowSize = CGSize(width: 1440, height: 900) +#endif + + @MainActor + func testCaptureScreenshots() throws { +#if os(macOS) + // The app pins its *content* size (pure SwiftUI; the app target has + // no AppKit), but the capture needs the *window* to be exactly + // `windowSize`. Probe once to measure the title-bar height, then pin + // the content that much shorter for the real captures. + let titleBarHeight = try measureTitleBarHeight() + let contentSize = CGSize( + width: Self.windowSize.width, + height: Self.windowSize.height - titleBarHeight + ) +#endif + for board in Self.boards { + let app = XCUIApplication() +#if os(macOS) + // Ignore any persisted window state so the app-side pin always wins. + app.launchArguments += [ + "-screenshotWindowSize", + "\(Int(contentSize.width))x\(Int(contentSize.height))", + "-ApplePersistenceIgnoreState", "YES" + ] +#else + setupSnapshot(app) + // iPad ships landscape App Store screenshots (solitaire is played + // landscape there); iPhone is portrait-only. + if UIDevice.current.userInterfaceIdiom == .pad { + XCUIDevice.shared.orientation = .landscapeLeft + } +#endif + app.launchArguments += Self.appearance + ["-screenshotFixture", board] + app.launch() + XCTAssertTrue( + app.windows.firstMatch.waitForExistence(timeout: 10), + "\(board): app window never appeared" + ) + // Let load animations and the initial layout settle. + Thread.sleep(forTimeInterval: 2) + +#if os(macOS) + try captureMacWindow(of: app, named: board) +#else + snapshot(board) +#endif + // No explicit terminate: launch() relaunches a running app, and the + // session tears down the last instance. terminate() flakes on macOS. + } + } + +#if os(macOS) + /// Measures the window title-bar height: launches the app with its + /// content pinned to the reference size and returns how much taller the + /// window frame is. The probe instance is replaced by the next launch. + @MainActor + private func measureTitleBarHeight() throws -> CGFloat { + let probe = XCUIApplication() + probe.launchArguments += [ + "-screenshotWindowSize", + "\(Int(Self.windowSize.width))x\(Int(Self.windowSize.height))", + "-ApplePersistenceIgnoreState", "YES", + "-screenshotFixture", Self.boards[0] + ] + probe.launch() + let window = probe.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 10), "probe window never appeared") + + let deadline = Date().addingTimeInterval(10) + while Date() < deadline, window.frame.height < Self.windowSize.height { + Thread.sleep(forTimeInterval: 0.2) + } + let titleBarHeight = window.frame.height - Self.windowSize.height + XCTAssertGreaterThan(titleBarHeight, 0, "probe: no title bar measured") + XCTAssertLessThan(titleBarHeight, 100, "probe: implausible title-bar height") + return titleBarHeight + } + + /// Captures the full app window (title bar and toolbar included) as an + /// exact-size PNG attachment. + /// + /// `XCUIElement.screenshot()` is unreliable for macOS windows (it can + /// return the entire desktop), so this takes a full-screen capture and + /// crops it to the window's frame. The window must be frontmost — + /// anything overlapping it would end up in the crop. + @MainActor + private func captureMacWindow(of app: XCUIApplication, named name: String) throws { + app.activate() + let window = app.windows.firstMatch + + // Wait for the pinned content size plus measured title bar to land. + let deadline = Date().addingTimeInterval(10) + while Date() < deadline, + abs(window.frame.width - Self.windowSize.width) > 1 + || abs(window.frame.height - Self.windowSize.height) > 1 { + Thread.sleep(forTimeInterval: 0.2) + } + let frame = window.frame + XCTAssertEqual(frame.width, Self.windowSize.width, accuracy: 1, "\(name): window never took the pinned size") + XCTAssertEqual(frame.height, Self.windowSize.height, accuracy: 1, "\(name): window never took the pinned size") + + let screen = try XCTUnwrap(NSScreen.screens.first, "no screen") + let fullImage = XCUIScreen.main.screenshot().image + let fullCG = try XCTUnwrap( + fullImage.cgImage(forProposedRect: nil, context: nil, hints: nil), + "\(name): screen capture failed" + ) + + // Window frame is in points with a top-left origin — the same + // orientation as CGImage rows — so only the display scale applies. + let scale = CGFloat(fullCG.width) / screen.frame.width + let crop = CGRect( + x: frame.minX * scale, + y: frame.minY * scale, + width: frame.width * scale, + height: frame.height * scale + ).integral + let cropped = try XCTUnwrap(fullCG.cropping(to: crop), "\(name): crop failed") + + let expected = CGSize( + width: Self.windowSize.width * screen.backingScaleFactor, + height: Self.windowSize.height * screen.backingScaleFactor + ) + XCTAssertEqual(CGFloat(cropped.width), expected.width, "\(name): capture is not an exact App Store size") + XCTAssertEqual(CGFloat(cropped.height), expected.height, "\(name): capture is not an exact App Store size") + + let pngData = try XCTUnwrap( + NSBitmapImageRep(cgImage: cropped).representation(using: .png, properties: [:]), + "\(name): PNG encoding failed" + ) + let attachment = XCTAttachment(data: pngData, uniformTypeIdentifier: "public.png") + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } +#endif +} diff --git a/ComputerSolitaireUITests/SnapshotHelper.swift b/ComputerSolitaireUITests/SnapshotHelper.swift new file mode 100644 index 0000000..eef2cfe --- /dev/null +++ b/ComputerSolitaireUITests/SnapshotHelper.swift @@ -0,0 +1,318 @@ +// +// SnapshotHelper.swift +// Example +// +// Created by Felix Krause on 10/8/15. +// + +// ----------------------------------------------------- +// IMPORTANT: When modifying this file, make sure to +// increment the version number at the very +// bottom of the file to notify users about +// the new SnapshotHelper.swift +// ----------------------------------------------------- + +import Foundation +import XCTest + +@MainActor +func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) { + Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations) +} + +@MainActor +func snapshot(_ name: String, waitForLoadingIndicator: Bool) { + if waitForLoadingIndicator { + Snapshot.snapshot(name) + } else { + Snapshot.snapshot(name, timeWaitingForIdle: 0) + } +} + +/// - Parameters: +/// - name: The name of the snapshot +/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait. +@MainActor +func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) { + Snapshot.snapshot(name, timeWaitingForIdle: timeout) +} + +enum SnapshotError: Error, CustomDebugStringConvertible { + case cannotFindSimulatorHomeDirectory + case cannotRunOnPhysicalDevice + + var debugDescription: String { + switch self { + case .cannotFindSimulatorHomeDirectory: + return "Couldn't find simulator home location. Please, check SIMULATOR_HOST_HOME env variable." + case .cannotRunOnPhysicalDevice: + return "Can't use Snapshot on a physical device." + } + } +} + +@objcMembers +@MainActor +open class Snapshot: NSObject { + static var app: XCUIApplication? + static var waitForAnimations = true + static var cacheDirectory: URL? + static var screenshotsDirectory: URL? { + return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true) + } + static var deviceLanguage = "" + static var currentLocale = "" + + open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) { + + Snapshot.app = app + Snapshot.waitForAnimations = waitForAnimations + + do { + let cacheDir = try getCacheDirectory() + Snapshot.cacheDirectory = cacheDir + setLanguage(app) + setLocale(app) + setLaunchArguments(app) + } catch let error { + NSLog(error.localizedDescription) + } + } + + class func setLanguage(_ app: XCUIApplication) { + guard let cacheDirectory = self.cacheDirectory else { + NSLog("CacheDirectory is not set - probably running on a physical device?") + return + } + + let path = cacheDirectory.appendingPathComponent("language.txt") + + do { + let trimCharacterSet = CharacterSet.whitespacesAndNewlines + deviceLanguage = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet) + app.launchArguments += ["-AppleLanguages", "(\(deviceLanguage))"] + } catch { + NSLog("Couldn't detect/set language...") + } + } + + class func setLocale(_ app: XCUIApplication) { + guard let cacheDirectory = self.cacheDirectory else { + NSLog("CacheDirectory is not set - probably running on a physical device?") + return + } + + let path = cacheDirectory.appendingPathComponent("locale.txt") + + do { + let trimCharacterSet = CharacterSet.whitespacesAndNewlines + currentLocale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet) + } catch { + NSLog("Couldn't detect/set locale...") + } + + if currentLocale.isEmpty && !deviceLanguage.isEmpty { + currentLocale = Locale(identifier: deviceLanguage).identifier + } + + if !currentLocale.isEmpty { + app.launchArguments += ["-AppleLocale", "\"\(currentLocale)\""] + } + } + + class func setLaunchArguments(_ app: XCUIApplication) { + guard let cacheDirectory = self.cacheDirectory else { + NSLog("CacheDirectory is not set - probably running on a physical device?") + return + } + + let path = cacheDirectory.appendingPathComponent("snapshot-launch_arguments.txt") + app.launchArguments += ["-FASTLANE_SNAPSHOT", "YES", "-ui_testing"] + + do { + let launchArguments = try String(contentsOf: path, encoding: String.Encoding.utf8) + let regex = try NSRegularExpression(pattern: "(\\\".+?\\\"|\\S+)", options: []) + let matches = regex.matches(in: launchArguments, options: [], range: NSRange(location: 0, length: launchArguments.count)) + let results = matches.map { result -> String in + (launchArguments as NSString).substring(with: result.range) + } + app.launchArguments += results + } catch { + NSLog("Couldn't detect/set launch_arguments...") + } + } + + open class func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) { + if timeout > 0 { + waitForLoadingIndicatorToDisappear(within: timeout) + } + + NSLog("snapshot: \(name)") // more information about this, check out https://docs.fastlane.tools/actions/snapshot/#how-does-it-work + + if Snapshot.waitForAnimations { + sleep(1) // Waiting for the animation to be finished (kind of) + } + + #if os(OSX) + guard let app = self.app else { + NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") + return + } + + // Local fix: XCUIKeyboardKeySecondaryFn was renamed in current SDKs. + app.typeKey(XCUIKeyboardKey.secondaryFn, modifierFlags: []) + #else + + guard self.app != nil else { + NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") + return + } + + let screenshot = XCUIScreen.main.screenshot() + #if os(iOS) && !targetEnvironment(macCatalyst) + let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image + #else + let image = screenshot.image + #endif + + guard var simulator = ProcessInfo().environment["SIMULATOR_DEVICE_NAME"], let screenshotsDir = screenshotsDirectory else { return } + + do { + // The simulator name contains "Clone X of " inside the screenshot file when running parallelized UI Tests on concurrent devices + let regex = try NSRegularExpression(pattern: "Clone [0-9]+ of ") + let range = NSRange(location: 0, length: simulator.count) + simulator = regex.stringByReplacingMatches(in: simulator, range: range, withTemplate: "") + + let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png") + #if swift(<5.0) + try UIImagePNGRepresentation(image)?.write(to: path, options: .atomic) + #else + try image.pngData()?.write(to: path, options: .atomic) + #endif + } catch let error { + NSLog("Problem writing screenshot: \(name) to \(screenshotsDir)/\(simulator)-\(name).png") + NSLog(error.localizedDescription) + } + #endif + } + + // Local fix: guarded for macOS (UIImage doesn't exist there). The only + // call site is already inside `#if os(iOS) && !targetEnvironment(macCatalyst)`. + #if !os(OSX) + class func fixLandscapeOrientation(image: UIImage) -> UIImage { + #if os(watchOS) + return image + #else + if #available(iOS 10.0, *) { + let format = UIGraphicsImageRendererFormat() + format.scale = image.scale + let renderer = UIGraphicsImageRenderer(size: image.size, format: format) + return renderer.image { context in + image.draw(in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height)) + } + } else { + return image + } + #endif + } + #endif + + class func waitForLoadingIndicatorToDisappear(within timeout: TimeInterval) { + #if os(tvOS) + return + #endif + + guard let app = self.app else { + NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") + return + } + + let networkLoadingIndicator = app.otherElements.deviceStatusBars.networkLoadingIndicators.element + let networkLoadingIndicatorDisappeared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: networkLoadingIndicator) + _ = XCTWaiter.wait(for: [networkLoadingIndicatorDisappeared], timeout: timeout) + } + + class func getCacheDirectory() throws -> URL { + let cachePath = "Library/Caches/tools.fastlane" + // on OSX config is stored in /Users//Library + // and on iOS/tvOS/WatchOS it's in simulator's home dir + #if os(OSX) + let homeDir = URL(fileURLWithPath: NSHomeDirectory()) + return homeDir.appendingPathComponent(cachePath) + #elseif arch(i386) || arch(x86_64) || arch(arm64) + guard let simulatorHostHome = ProcessInfo().environment["SIMULATOR_HOST_HOME"] else { + throw SnapshotError.cannotFindSimulatorHomeDirectory + } + let homeDir = URL(fileURLWithPath: simulatorHostHome) + return homeDir.appendingPathComponent(cachePath) + #else + throw SnapshotError.cannotRunOnPhysicalDevice + #endif + } +} + +private extension XCUIElementAttributes { + var isNetworkLoadingIndicator: Bool { + if hasAllowListedIdentifier { return false } + + let hasOldLoadingIndicatorSize = frame.size == CGSize(width: 10, height: 20) + let hasNewLoadingIndicatorSize = frame.size.width.isBetween(46, and: 47) && frame.size.height.isBetween(2, and: 3) + + return hasOldLoadingIndicatorSize || hasNewLoadingIndicatorSize + } + + var hasAllowListedIdentifier: Bool { + let allowListedIdentifiers = ["GeofenceLocationTrackingOn", "StandardLocationTrackingOn"] + + return allowListedIdentifiers.contains(identifier) + } + + func isStatusBar(_ deviceWidth: CGFloat) -> Bool { + if elementType == .statusBar { return true } + guard frame.origin == .zero else { return false } + + let oldStatusBarSize = CGSize(width: deviceWidth, height: 20) + let newStatusBarSize = CGSize(width: deviceWidth, height: 44) + + return [oldStatusBarSize, newStatusBarSize].contains(frame.size) + } +} + +private extension XCUIElementQuery { + var networkLoadingIndicators: XCUIElementQuery { + let isNetworkLoadingIndicator = NSPredicate { (evaluatedObject, _) in + guard let element = evaluatedObject as? XCUIElementAttributes else { return false } + + return element.isNetworkLoadingIndicator + } + + return self.containing(isNetworkLoadingIndicator) + } + + @MainActor + var deviceStatusBars: XCUIElementQuery { + guard let app = Snapshot.app else { + fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") + } + + let deviceWidth = app.windows.firstMatch.frame.width + + let isStatusBar = NSPredicate { (evaluatedObject, _) in + guard let element = evaluatedObject as? XCUIElementAttributes else { return false } + + return element.isStatusBar(deviceWidth) + } + + return self.containing(isStatusBar) + } +} + +private extension CGFloat { + func isBetween(_ numberA: CGFloat, and numberB: CGFloat) -> Bool { + return numberA...numberB ~= self + } +} + +// Please don't remove the lines below +// They are used to detect outdated configuration files +// SnapshotHelperVersion [1.30] diff --git a/docs/screenshots/screen-grab-ios.png b/docs/screenshots/screen-grab-ios.png index a0b14af..6b56711 100644 Binary files a/docs/screenshots/screen-grab-ios.png and b/docs/screenshots/screen-grab-ios.png differ diff --git a/docs/screenshots/screen-grab-macos.png b/docs/screenshots/screen-grab-macos.png index ec652bf..08935e5 100644 Binary files a/docs/screenshots/screen-grab-macos.png and b/docs/screenshots/screen-grab-macos.png differ diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 0000000..8cde778 --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,96 @@ +# Screenshot automation. The full App Store set is one command: +# +# fastlane screenshots +# +# iPhone/iPad come from `snapshot` (see Snapfile); the Mac window screenshot +# comes from a custom xcodebuild step because snapshot only supports +# iOS/tvOS/watchOS simulators. Staged boards live in the app as +# ScreenshotFixtures; see ScreenshotFixtureTests for regenerating them. + +default_platform(:ios) + +# Mac shots land next to the iOS ones in the locale folder with a device-style +# prefix — the same layout `deliver` uploads from (it keys on pixel size). +# fastlane chdirs into this fastlane directory for lane execution, so plain +# Ruby file ops and `sh` both resolve these relative to fastlane/. +MAC_EXPORT_TMP = "screenshots/mac-export".freeze +MAC_FINAL_DIR = "screenshots/en-US".freeze + +# Shared scheme whose test action contains only ScreenshotCaptureUITests, so +# screenshot runs never build or run the (macOS-only) unit test suite. +SCHEME = "ComputerSolitaireScreenshots".freeze + +platform :ios do + desc "Capture all App Store screenshots (iPhone, iPad, and Mac)" + lane :screenshots do + # One iOS build shared by every simulator run (snapshot is configured + # with test_without_building against this derived data). + run_tests( + project: "ComputerSolitaire.xcodeproj", + scheme: SCHEME, + destination: "generic/platform=iOS Simulator", + derived_data_path: "fastlane/derived_data", + build_for_testing: true + ) + snapshot + mac_screenshots + end + + desc "Capture the Mac window screenshot via xcodebuild" + private_lane :mac_screenshots do + # The UI test can't terminate an app instance it didn't spawn (e.g. one + # left running from Xcode), so quit it first — gracefully, then harder. + # Gated on the app actually running: an unconditional quit AppleEvent can + # linger and get delivered to the instance the test launches later, + # quitting it mid-capture. An instance *paused in Xcode's debugger* can't + # be killed at all (the kernel defers signals for traced processes); if + # this leg fails with "Failed to terminate", hit Stop in Xcode and rerun. + if system("pgrep -xq 'Computer Solitaire'") + sh(%q(osascript -e 'with timeout of 3 seconds' -e 'quit app "Computer Solitaire"' -e 'end timeout' 2>/dev/null || true)) + sh(%q(pkill -x "Computer Solitaire" 2>/dev/null || true; sleep 1; pkill -9 -x "Computer Solitaire" 2>/dev/null || true)) + end + + result_bundle = "screenshots/mac.xcresult" + FileUtils.rm_rf([MAC_EXPORT_TMP, result_bundle]) + FileUtils.mkdir_p([MAC_EXPORT_TMP, MAC_FINAL_DIR]) + + sh("xcodebuild", "test", + "-project", "../ComputerSolitaire.xcodeproj", + "-scheme", SCHEME, + "-destination", "platform=macOS", + "-resultBundlePath", result_bundle, + "-quiet") + sh("xcrun", "xcresulttool", "export", "attachments", + "--path", result_bundle, "--output-path", MAC_EXPORT_TMP) + + rename_exported_attachments(MAC_EXPORT_TMP) + Dir.glob(File.join(MAC_EXPORT_TMP, "*.png")).each do |png| + FileUtils.mv(png, File.join(MAC_FINAL_DIR, "macOS-#{File.basename(png)}")) + end + FileUtils.rm_rf([MAC_EXPORT_TMP, result_bundle]) + UI.success("Mac screenshots in fastlane/#{MAC_FINAL_DIR}") + end +end + +# xcresulttool exports opaque file names; the manifest maps them back to the +# attachment names the UI test set (board names, plus an index/UUID suffix +# this strips). Non-PNG attachments are runner diagnostics — dropped. +def rename_exported_attachments(export_dir) + manifest_path = File.join(export_dir, "manifest.json") + return unless File.exist?(manifest_path) + + JSON.parse(File.read(manifest_path)).each do |test| + (test["attachments"] || []).each do |attachment| + exported = File.join(export_dir, attachment["exportedFileName"].to_s) + next unless File.exist?(exported) + unless File.extname(exported).casecmp(".png").zero? + File.delete(exported) + next + end + name = attachment["suggestedHumanReadableName"] || File.basename(exported) + stem = File.basename(name, ".*").sub(/_\d+_[0-9A-Fa-f-]{36}\z/, "") + File.rename(exported, File.join(export_dir, "#{stem}.png")) + end + end + File.delete(manifest_path) +end diff --git a/fastlane/README.md b/fastlane/README.md new file mode 100644 index 0000000..256c1fc --- /dev/null +++ b/fastlane/README.md @@ -0,0 +1,32 @@ +fastlane documentation +---- + +# Installation + +Make sure you have the latest version of the Xcode command line tools installed: + +```sh +xcode-select --install +``` + +For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) + +# Available Actions + +## iOS + +### ios screenshots + +```sh +[bundle exec] fastlane ios screenshots +``` + +Capture all App Store screenshots (iPhone, iPad, and Mac) + +---- + +This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. + +More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). + +The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). diff --git a/fastlane/Snapfile b/fastlane/Snapfile new file mode 100644 index 0000000..0c441bc --- /dev/null +++ b/fastlane/Snapfile @@ -0,0 +1,32 @@ +# Configuration for `fastlane snapshot` — iPhone/iPad App Store screenshots. +# The Mac screenshot is captured by the custom step in the :screenshots lane +# (snapshot does not support macOS apps). + +# Shared scheme whose test action contains only ScreenshotCaptureUITests, so +# device runs never build or run the (macOS-only) unit test suite. +scheme("ComputerSolitaireScreenshots") + +devices([ + "iPhone 17 Pro Max", # 6.9" (1320x2868) — the required iPhone size + "iPhone 17 Pro", # 6.3" (1206x2622) + "iPad Pro 13-inch (M5)", # 13" (2064x2752) — the required iPad size + "iPad Pro 11-inch (M5)" # 11" (2420x1668) +]) + +languages(["en-US"]) + +# Clean 9:41 / full-bars / full-battery status bar, applied by fastlane. +override_status_bar(true) + +# Concurrent mode runs on simulator clones, which don't inherit status-bar +# overrides — keep runs sequential. +concurrent_simulators(false) + +output_directory("./fastlane/screenshots") +clear_previous_screenshots(true) +stop_after_first_error(true) + +# The lane builds once via build_for_testing; every device run reuses that +# build instead of recompiling the app per device. +derived_data_path("./fastlane/derived_data") +test_without_building(true)