Makes version update testable.

This commit is contained in:
zonble 2022-01-18 18:39:21 +08:00
parent 28a9483de8
commit dd1310d40a
4 changed files with 165 additions and 102 deletions

View File

@ -9,15 +9,15 @@ jobs:
DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
- name: Clean - name: Clean McBopomofo
run: xcodebuild -scheme McBopomofo -configuration Release clean run: xcodebuild -scheme McBopomofo -configuration Release clean
- name: Clean - name: Clean McBopomofoInstaller
run: xcodebuild -scheme McBopomofoInstaller -configuration Release clean run: xcodebuild -scheme McBopomofoInstaller -configuration Release clean
- name: Build - name: Build McBopomofo
run: xcodebuild -scheme McBopomofo -configuration Release build run: xcodebuild -scheme McBopomofo -configuration Release build
- name: Build - name: Build McBopomofoInstaller
run: xcodebuild -scheme McBopomofoInstaller -configuration Release build run: xcodebuild -scheme McBopomofoInstaller -configuration Release build
- name: Test - name: Test McBopomofo App Bundle
run: xcodebuild -scheme McBopomofo -configuration Debug test run: xcodebuild -scheme McBopomofo -configuration Debug test
- name: Test CandidateUI - name: Test CandidateUI
run: swift test run: swift test

View File

@ -56,6 +56,7 @@
D47F7DD0278C0897002F9DD7 /* NonModalAlertWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D47F7DCF278C0897002F9DD7 /* NonModalAlertWindowController.swift */; }; D47F7DD0278C0897002F9DD7 /* NonModalAlertWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D47F7DCF278C0897002F9DD7 /* NonModalAlertWindowController.swift */; };
D47F7DD3278C1263002F9DD7 /* UserOverrideModel.cpp in Sources */ = {isa = PBXBuildFile; fileRef = D47F7DD2278C1263002F9DD7 /* UserOverrideModel.cpp */; }; D47F7DD3278C1263002F9DD7 /* UserOverrideModel.cpp in Sources */ = {isa = PBXBuildFile; fileRef = D47F7DD2278C1263002F9DD7 /* UserOverrideModel.cpp */; };
D485D3B92796A8A000657FF3 /* PreferencesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D485D3B82796A8A000657FF3 /* PreferencesTests.swift */; }; D485D3B92796A8A000657FF3 /* PreferencesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D485D3B82796A8A000657FF3 /* PreferencesTests.swift */; };
D485D3C02796CE3200657FF3 /* VersionUpdateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D485D3BF2796CE3200657FF3 /* VersionUpdateTests.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@ -201,6 +202,7 @@
D47F7DD2278C1263002F9DD7 /* UserOverrideModel.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = UserOverrideModel.cpp; sourceTree = "<group>"; }; D47F7DD2278C1263002F9DD7 /* UserOverrideModel.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = UserOverrideModel.cpp; sourceTree = "<group>"; };
D485D3B62796A8A000657FF3 /* McBopomofoTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = McBopomofoTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D485D3B62796A8A000657FF3 /* McBopomofoTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = McBopomofoTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
D485D3B82796A8A000657FF3 /* PreferencesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesTests.swift; sourceTree = "<group>"; }; D485D3B82796A8A000657FF3 /* PreferencesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesTests.swift; sourceTree = "<group>"; };
D485D3BF2796CE3200657FF3 /* VersionUpdateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionUpdateTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -454,6 +456,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D485D3B82796A8A000657FF3 /* PreferencesTests.swift */, D485D3B82796A8A000657FF3 /* PreferencesTests.swift */,
D485D3BF2796CE3200657FF3 /* VersionUpdateTests.swift */,
); );
path = McBopomofoTests; path = McBopomofoTests;
sourceTree = "<group>"; sourceTree = "<group>";
@ -688,6 +691,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
D485D3B92796A8A000657FF3 /* PreferencesTests.swift in Sources */, D485D3B92796A8A000657FF3 /* PreferencesTests.swift in Sources */,
D485D3C02796CE3200657FF3 /* VersionUpdateTests.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };

View File

@ -0,0 +1,20 @@
import XCTest
@testable import McBopomofo
class VersionUpdateApiTests: XCTestCase {
func testFetchVersionUpdateInfo() {
let exp = self.expectation(description: "wait for 3 seconds")
_ = VersionUpdateApi.check(forced: true) { result in
exp.fulfill()
switch result {
case .success(_):
break
case .failure(let error):
XCTFail(error.localizedDescription)
}
}
self.wait(for: [exp], timeout: 3.0)
}
}

View File

@ -42,6 +42,119 @@ private let kUpdateInfoSiteKey = "UpdateInfoSite"
private let kNextCheckInterval: TimeInterval = 86400.0 private let kNextCheckInterval: TimeInterval = 86400.0
private let kTimeoutInterval: TimeInterval = 60.0 private let kTimeoutInterval: TimeInterval = 60.0
struct VersionUpdateReport {
var siteUrl: URL?
var currentShortVersion: String = ""
var currentVersion: String = ""
var remoteShortVersion: String = ""
var remoteVersion: String = ""
var versionDescription: String = ""
}
enum VersionUpdateApiResult
{
case shouldUpdate(report: VersionUpdateReport)
case noNeedToUpdate
case ignored
}
enum VersionUpdateApiError: Error, LocalizedError {
case connectionError(message:String)
var errorDescription: String? {
switch self {
case .connectionError(let message):
return String(format: NSLocalizedString("There may be no internet connection or the server failed to respond.\n\nError message: %@", comment: ""), message)
}
}
}
struct VersionUpdateApi {
static func check(forced: Bool, callback: @escaping (Result<VersionUpdateApiResult, Error>)->()) -> URLSessionTask? {
guard let infoDict = Bundle.main.infoDictionary,
let updateInfoURLString = infoDict[kUpdateInfoEndpointKey] as? String,
let updateInfoURL = URL(string: updateInfoURLString) else {
return nil
}
let request = URLRequest(url: updateInfoURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: kTimeoutInterval)
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
DispatchQueue.main.async {
forced ?
callback(.failure(VersionUpdateApiError.connectionError(message: error.localizedDescription))) :
callback(.success(.ignored))
}
return
}
do {
guard let plist = try PropertyListSerialization.propertyList(from: data ?? Data(), options: [], format: nil) as? [AnyHashable: Any],
let remoteVersion = plist[kCFBundleVersionKey] as? String,
let infoDict = Bundle.main.infoDictionary
else {
DispatchQueue.main.async {
forced ? callback(.success(.noNeedToUpdate)): callback(.success(.ignored))
}
return
}
// TODO: Validate info (e.g. bundle identifier)
// TODO: Use HTML to display change log, need a new key like UpdateInfoChangeLogURL for this
let currentVersion = infoDict[kCFBundleVersionKey as String] as? String ?? ""
let result = currentVersion.compare(remoteVersion, options: .numeric, range: nil, locale: nil)
if result != .orderedAscending {
DispatchQueue.main.async {
forced ? callback(.success(.noNeedToUpdate)): callback(.success(.ignored))
}
return
}
guard let siteInfoURLString = plist[kUpdateInfoSiteKey] as? String,
let siteInfoURL = URL(string: siteInfoURLString)
else {
DispatchQueue.main.async {
forced ? callback(.success(.noNeedToUpdate)): callback(.success(.ignored))
}
return
}
var report = VersionUpdateReport(siteUrl:siteInfoURL)
var versionDescription = ""
let versionDescriptions = plist["Description"] as? [AnyHashable: Any]
if let versionDescriptions = versionDescriptions {
var locale = "en"
let supportedLocales = ["en", "zh-Hant", "zh-Hans"]
let preferredTags = Bundle.preferredLocalizations(from: supportedLocales)
if let first = preferredTags.first {
locale = first
}
versionDescription = versionDescriptions[locale] as? String ?? versionDescriptions["en"] as? String ?? ""
if !versionDescription.isEmpty {
versionDescription = "\n\n" + versionDescription
}
}
report.currentShortVersion = infoDict["CFBundleShortVersionString"] as? String ?? ""
report.currentVersion = currentVersion
report.remoteShortVersion = plist["CFBundleShortVersionString"] as? String ?? ""
report.remoteVersion = remoteVersion
report.versionDescription = versionDescription
DispatchQueue.main.async {
callback(.success(.shouldUpdate(report: report)))
}
} catch {
DispatchQueue.main.async {
forced ? callback(.success(.noNeedToUpdate)): callback(.success(.ignored))
}
}
}
task.resume()
return task
}
}
@objc (AppDelegate) @objc (AppDelegate)
class AppDelegate: NSObject, NSApplicationDelegate, NonModalAlertWindowControllerDelegate { class AppDelegate: NSObject, NSApplicationDelegate, NonModalAlertWindowControllerDelegate {
@ -99,111 +212,37 @@ class AppDelegate: NSObject, NSApplicationDelegate, NonModalAlertWindowControlle
let nextUpdateDate = Date(timeInterval: kNextCheckInterval, since: Date()) let nextUpdateDate = Date(timeInterval: kNextCheckInterval, since: Date())
UserDefaults.standard.set(nextUpdateDate, forKey: kNextUpdateCheckDateKey) UserDefaults.standard.set(nextUpdateDate, forKey: kNextUpdateCheckDateKey)
guard let infoDict = Bundle.main.infoDictionary, checkTask = VersionUpdateApi.check(forced: forced) { result in
let updateInfoURLString = infoDict[kUpdateInfoEndpointKey] as? String,
let updateInfoURL = URL(string: updateInfoURLString) else {
return
}
let request = URLRequest(url: updateInfoURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: kTimeoutInterval)
func showNoUpdateAvailableAlert() {
NonModalAlertWindowController.shared.show(title: NSLocalizedString("Check for Update Completed", comment: ""), content: NSLocalizedString("You are already using the latest version of McBopomofo.", comment: ""), confirmButtonTitle: NSLocalizedString("OK", comment: ""), cancelButtonTitle: nil, cancelAsDefault: false, delegate: nil)
}
let task = URLSession.shared.dataTask(with: request) { data, response, error in
defer { defer {
self.checkTask = nil self.checkTask = nil
} }
switch result {
if let error = error { case .success(let apiResult):
if forced { switch apiResult {
let title = NSLocalizedString("Update Check Failed", comment: "") case .shouldUpdate(let report):
let content = String(format: NSLocalizedString("There may be no internet connection or the server failed to respond.\n\nError message: %@", comment: ""), error.localizedDescription) self.updateNextStepURL = report.siteUrl
let buttonTitle = NSLocalizedString("Dismiss", comment: "") let content = String(format: NSLocalizedString("You're currently using McBopomofo %@ (%@), a new version %@ (%@) is now available. Do you want to visit McBopomofo's website to download the version?%@", comment: ""),
report.currentShortVersion,
DispatchQueue.main.async { report.currentVersion,
NonModalAlertWindowController.shared.show(title: title, content: content, confirmButtonTitle: buttonTitle, cancelButtonTitle: nil, cancelAsDefault: false, delegate: nil) report.remoteShortVersion,
} report.remoteVersion,
} report.versionDescription)
return
}
do {
guard let plist = try PropertyListSerialization.propertyList(from: data ?? Data(), options: [], format: nil) as? [AnyHashable: Any],
let remoteVersion = plist[kCFBundleVersionKey] as? String,
let infoDict = Bundle.main.infoDictionary
else {
if forced {
DispatchQueue.main.async {
showNoUpdateAvailableAlert()
}
}
return
}
// TODO: Validate info (e.g. bundle identifier)
// TODO: Use HTML to display change log, need a new key like UpdateInfoChangeLogURL for this
let currentVersion = infoDict[kCFBundleVersionKey as String] as? String ?? ""
let result = currentVersion.compare(remoteVersion, options: .numeric, range: nil, locale: nil)
if result != .orderedAscending {
if forced {
DispatchQueue.main.async {
showNoUpdateAvailableAlert()
}
}
return
}
guard let siteInfoURLString = plist[kUpdateInfoSiteKey] as? String,
let siteInfoURL = URL(string: siteInfoURLString) else {
if forced {
DispatchQueue.main.async {
showNoUpdateAvailableAlert()
}
}
return
}
self.updateNextStepURL = siteInfoURL
var versionDescription = ""
let versionDescriptions = plist["Description"] as? [AnyHashable: Any]
if let versionDescriptions = versionDescriptions {
var locale = "en"
let supportedLocales = ["en", "zh-Hant", "zh-Hans"]
let preferredTags = Bundle.preferredLocalizations(from: supportedLocales)
if let first = preferredTags.first {
locale = first
}
versionDescription = versionDescriptions[locale] as? String ?? versionDescriptions["en"] as? String ?? ""
if !versionDescription.isEmpty {
versionDescription = "\n\n" + versionDescription
}
}
let content = String(format: NSLocalizedString("You're currently using McBopomofo %@ (%@), a new version %@ (%@) is now available. Do you want to visit McBopomofo's website to download the version?%@", comment: ""),
infoDict["CFBundleShortVersionString"] as? String ?? "",
currentVersion,
plist["CFBundleShortVersionString"] as? String ?? "",
remoteVersion,
versionDescription)
DispatchQueue.main.async {
NonModalAlertWindowController.shared.show(title: NSLocalizedString("New Version Available", comment: ""), content: content, confirmButtonTitle: NSLocalizedString("Visit Website", comment: ""), cancelButtonTitle: NSLocalizedString("Not Now", comment: ""), cancelAsDefault: false, delegate: self) NonModalAlertWindowController.shared.show(title: NSLocalizedString("New Version Available", comment: ""), content: content, confirmButtonTitle: NSLocalizedString("Visit Website", comment: ""), cancelButtonTitle: NSLocalizedString("Not Now", comment: ""), cancelAsDefault: false, delegate: self)
case .noNeedToUpdate, .ignored:
break
} }
case .failure(let error):
} catch { switch error {
if forced { case VersionUpdateApiError.connectionError(let message):
DispatchQueue.main.async { let title = NSLocalizedString("Update Check Failed", comment: "")
showNoUpdateAvailableAlert() let content = String(format: NSLocalizedString("There may be no internet connection or the server failed to respond.\n\nError message: %@", comment: ""), message)
} let buttonTitle = NSLocalizedString("Dismiss", comment: "")
NonModalAlertWindowController.shared.show(title: title, content: content, confirmButtonTitle: buttonTitle, cancelButtonTitle: nil, cancelAsDefault: false, delegate: nil)
default:
break
} }
} }
} }
checkTask = task
task.resume()
} }
func nonModalAlertWindowControllerDidConfirm(_ controller: NonModalAlertWindowController) { func nonModalAlertWindowControllerDidConfirm(_ controller: NonModalAlertWindowController) {