[#212] Wrapped user defaults (#298)

tests enhanced

guard/fail

cleanup
This commit is contained in:
Lukas Korba 2022-05-03 14:34:17 +02:00 committed by GitHub
parent e54ea3aa18
commit f6e6f6991f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 278 additions and 44 deletions

View File

@ -105,6 +105,7 @@
9E5BF63F2819542C00BA3F17 /* TransactionHistoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5BF63E2819542C00BA3F17 /* TransactionHistoryTests.swift */; }; 9E5BF63F2819542C00BA3F17 /* TransactionHistoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5BF63E2819542C00BA3F17 /* TransactionHistoryTests.swift */; };
9E5BF641281FD7B600BA3F17 /* TransactionFailedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5BF640281FD7B600BA3F17 /* TransactionFailedView.swift */; }; 9E5BF641281FD7B600BA3F17 /* TransactionFailedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5BF640281FD7B600BA3F17 /* TransactionFailedView.swift */; };
9E5BF644281FEC9900BA3F17 /* SendTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5BF643281FEC9900BA3F17 /* SendTests.swift */; }; 9E5BF644281FEC9900BA3F17 /* SendTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5BF643281FEC9900BA3F17 /* SendTests.swift */; };
9E5BF6462821028C00BA3F17 /* WrappedUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5BF6452821028C00BA3F17 /* WrappedUserDefaults.swift */; };
9E69A24D27FB002800A55317 /* Welcome.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E69A24C27FB002800A55317 /* Welcome.swift */; }; 9E69A24D27FB002800A55317 /* Welcome.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E69A24C27FB002800A55317 /* Welcome.swift */; };
9E80B47227E4B34B008FF493 /* UserPreferencesStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E80B47127E4B34B008FF493 /* UserPreferencesStorage.swift */; }; 9E80B47227E4B34B008FF493 /* UserPreferencesStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E80B47127E4B34B008FF493 /* UserPreferencesStorage.swift */; };
9EAFEB822805793200199FC9 /* AppReducerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAFEB812805793200199FC9 /* AppReducerTests.swift */; }; 9EAFEB822805793200199FC9 /* AppReducerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAFEB812805793200199FC9 /* AppReducerTests.swift */; };
@ -272,6 +273,7 @@
9E5BF63E2819542C00BA3F17 /* TransactionHistoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionHistoryTests.swift; sourceTree = "<group>"; }; 9E5BF63E2819542C00BA3F17 /* TransactionHistoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionHistoryTests.swift; sourceTree = "<group>"; };
9E5BF640281FD7B600BA3F17 /* TransactionFailedView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransactionFailedView.swift; sourceTree = "<group>"; }; 9E5BF640281FD7B600BA3F17 /* TransactionFailedView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransactionFailedView.swift; sourceTree = "<group>"; };
9E5BF643281FEC9900BA3F17 /* SendTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendTests.swift; sourceTree = "<group>"; }; 9E5BF643281FEC9900BA3F17 /* SendTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendTests.swift; sourceTree = "<group>"; };
9E5BF6452821028C00BA3F17 /* WrappedUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedUserDefaults.swift; sourceTree = "<group>"; };
9E69A24C27FB002800A55317 /* Welcome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Welcome.swift; sourceTree = "<group>"; }; 9E69A24C27FB002800A55317 /* Welcome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Welcome.swift; sourceTree = "<group>"; };
9E80B47127E4B34B008FF493 /* UserPreferencesStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPreferencesStorage.swift; sourceTree = "<group>"; }; 9E80B47127E4B34B008FF493 /* UserPreferencesStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPreferencesStorage.swift; sourceTree = "<group>"; };
9EAFEB812805793200199FC9 /* AppReducerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppReducerTests.swift; sourceTree = "<group>"; }; 9EAFEB812805793200199FC9 /* AppReducerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppReducerTests.swift; sourceTree = "<group>"; };
@ -759,6 +761,7 @@
9EAFEB83280597B700199FC9 /* WrappedSecItem.swift */, 9EAFEB83280597B700199FC9 /* WrappedSecItem.swift */,
9E02B5C2280458D2005B809B /* WrappedDerivationTool.swift */, 9E02B5C2280458D2005B809B /* WrappedDerivationTool.swift */,
9EAFEB872806E5AE00199FC9 /* WrappedSDKSynchronizer.swift */, 9EAFEB872806E5AE00199FC9 /* WrappedSDKSynchronizer.swift */,
9E5BF6452821028C00BA3F17 /* WrappedUserDefaults.swift */,
); );
path = Wrappers; path = Wrappers;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1288,6 +1291,7 @@
9EAFEB84280597B700199FC9 /* WrappedSecItem.swift in Sources */, 9EAFEB84280597B700199FC9 /* WrappedSecItem.swift in Sources */,
9E2AC10327DA28200042AA47 /* WalletStorage.swift in Sources */, 9E2AC10327DA28200042AA47 /* WalletStorage.swift in Sources */,
9ECAE56827FC713C0089A0EF /* DatabaseFiles.swift in Sources */, 9ECAE56827FC713C0089A0EF /* DatabaseFiles.swift in Sources */,
9E5BF6462821028C00BA3F17 /* WrappedUserDefaults.swift in Sources */,
F9971A6B27680E1000A2DB75 /* WalletInfo.swift in Sources */, F9971A6B27680E1000A2DB75 /* WalletInfo.swift in Sources */,
0D185819272723FF0046B928 /* ColoredChip.swift in Sources */, 0D185819272723FF0046B928 /* ColoredChip.swift in Sources */,
2EA11F5D27467F7700709571 /* OnboardingContentView.swift in Sources */, 2EA11F5D27467F7700709571 /* OnboardingContentView.swift in Sources */,

View File

@ -6,25 +6,12 @@
// //
import Foundation import Foundation
import ComposableArchitecture
/// Representation of the user preferences stored in the local persistent storage (non-encrypted, no security needed)
protocol UserPreferences {
/// From when the app is on and uninterrupted
var activeAppSessionFrom: TimeInterval { get set }
/// What is the set up currency
var currency: String { get set }
/// Whether the fiat conversion is on/off
var isFiatConverted: Bool { get set }
/// Whether user finished recovery phrase backup test
var isRecoveryPhraseTestCompleted: Bool { get set }
/// Whether the user has been autoshielded in the running session
var isSessionAutoshielded: Bool { get set }
}
/// Live implementation of the `UserPreferences` using User Defaults /// Live implementation of the `UserPreferences` using User Defaults
/// according to https://developer.apple.com/documentation/foundation/userdefaults /// according to https://developer.apple.com/documentation/foundation/userdefaults
/// the UserDefaults class is thread-safe. /// the UserDefaults class is thread-safe.
struct UserPreferencesStorage: UserPreferences { struct UserPreferencesStorage {
enum Constants: String, CaseIterable { enum Constants: String, CaseIterable {
case zcashActiveAppSessionFrom case zcashActiveAppSessionFrom
case zcashCurrency case zcashCurrency
@ -32,15 +19,6 @@ struct UserPreferencesStorage: UserPreferences {
case zcashRecoveryPhraseTestCompleted case zcashRecoveryPhraseTestCompleted
case zcashSessionAutoshielded case zcashSessionAutoshielded
} }
static let `default` = UserPreferencesStorage(
appSessionFrom: Date().timeIntervalSince1970,
convertedCurrency: "USD",
fiatConvertion: true,
recoveryPhraseTestCompleted: false,
sessionAutoshielded: true,
userDefaults: UserDefaults.standard
)
/// Default values for all preferences in case there is no value stored (counterparts to `Constants`) /// Default values for all preferences in case there is no value stored (counterparts to `Constants`)
private let appSessionFrom: TimeInterval private let appSessionFrom: TimeInterval
@ -49,7 +27,7 @@ struct UserPreferencesStorage: UserPreferences {
private let recoveryPhraseTestCompleted: Bool private let recoveryPhraseTestCompleted: Bool
private let sessionAutoshielded: Bool private let sessionAutoshielded: Bool
private let userDefaults: UserDefaults private let userDefaults: WrappedUserDefaults
init( init(
appSessionFrom: TimeInterval, appSessionFrom: TimeInterval,
@ -57,7 +35,7 @@ struct UserPreferencesStorage: UserPreferences {
fiatConvertion: Bool, fiatConvertion: Bool,
recoveryPhraseTestCompleted: Bool, recoveryPhraseTestCompleted: Bool,
sessionAutoshielded: Bool, sessionAutoshielded: Bool,
userDefaults: UserDefaults userDefaults: WrappedUserDefaults
) { ) {
self.appSessionFrom = appSessionFrom self.appSessionFrom = appSessionFrom
self.convertedCurrency = convertedCurrency self.convertedCurrency = convertedCurrency
@ -69,47 +47,88 @@ struct UserPreferencesStorage: UserPreferences {
/// From when the app is on and uninterrupted /// From when the app is on and uninterrupted
var activeAppSessionFrom: TimeInterval { var activeAppSessionFrom: TimeInterval {
get { getValue(forKey: Constants.zcashActiveAppSessionFrom.rawValue, default: appSessionFrom) } getValue(forKey: Constants.zcashActiveAppSessionFrom.rawValue, default: appSessionFrom)
set { setValue(newValue, forKey: Constants.zcashActiveAppSessionFrom.rawValue) } }
func setActiveAppSessionFrom(_ timeInterval: TimeInterval) -> Effect<Never, Never> {
setValue(timeInterval, forKey: Constants.zcashActiveAppSessionFrom.rawValue)
} }
/// What is the set up currency /// What is the set up currency
var currency: String { var currency: String {
get { getValue(forKey: Constants.zcashCurrency.rawValue, default: convertedCurrency) } getValue(forKey: Constants.zcashCurrency.rawValue, default: convertedCurrency)
set { setValue(newValue, forKey: Constants.zcashCurrency.rawValue) } }
func setCurrency(_ string: String) -> Effect<Never, Never> {
setValue(string, forKey: Constants.zcashCurrency.rawValue)
} }
/// Whether the fiat conversion is on/off /// Whether the fiat conversion is on/off
var isFiatConverted: Bool { var isFiatConverted: Bool {
get { getValue(forKey: Constants.zcashFiatConverted.rawValue, default: fiatConvertion) } getValue(forKey: Constants.zcashFiatConverted.rawValue, default: fiatConvertion)
set { setValue(newValue, forKey: Constants.zcashFiatConverted.rawValue) } }
func setIsFiatConverted(_ bool: Bool) -> Effect<Never, Never> {
setValue(bool, forKey: Constants.zcashFiatConverted.rawValue)
} }
/// Whether user finished recovery phrase backup test /// Whether user finished recovery phrase backup test
var isRecoveryPhraseTestCompleted: Bool { var isRecoveryPhraseTestCompleted: Bool {
get { getValue(forKey: Constants.zcashRecoveryPhraseTestCompleted.rawValue, default: recoveryPhraseTestCompleted) } getValue(forKey: Constants.zcashRecoveryPhraseTestCompleted.rawValue, default: recoveryPhraseTestCompleted)
set { setValue(newValue, forKey: Constants.zcashRecoveryPhraseTestCompleted.rawValue) } }
func setIsRecoveryPhraseTestCompleted(_ bool: Bool) -> Effect<Never, Never> {
setValue(bool, forKey: Constants.zcashRecoveryPhraseTestCompleted.rawValue)
} }
/// Whether the user has been autoshielded in the running session /// Whether the user has been autoshielded in the running session
var isSessionAutoshielded: Bool { var isSessionAutoshielded: Bool {
get { getValue(forKey: Constants.zcashSessionAutoshielded.rawValue, default: sessionAutoshielded) } getValue(forKey: Constants.zcashSessionAutoshielded.rawValue, default: sessionAutoshielded)
set { setValue(newValue, forKey: Constants.zcashSessionAutoshielded.rawValue) } }
func setIsSessionAutoshielded(_ bool: Bool) -> Effect<Never, Never> {
setValue(bool, forKey: Constants.zcashSessionAutoshielded.rawValue)
} }
/// Use carefully: Deletes all user preferences from the User Defaults /// Use carefully: Deletes all user preferences from the User Defaults
func removeAll() { func removeAll() -> Effect<Never, Never> {
Constants.allCases.forEach { userDefaults.removeObject(forKey: $0.rawValue) } var removals: [Effect<Never, Never>] = []
Constants.allCases.forEach { removals.append(userDefaults.remove($0.rawValue)) }
return Effect.concatenate(removals)
} }
} }
private extension UserPreferencesStorage { private extension UserPreferencesStorage {
func getValue<Value>(forKey: String, default defaultIfNil: Value) -> Value { func getValue<Value>(forKey: String, default defaultIfNil: Value) -> Value {
userDefaults.object(forKey: forKey) as? Value ?? defaultIfNil userDefaults.objectForKey(forKey) as? Value ?? defaultIfNil
} }
func setValue<Value>(_ value: Value, forKey: String) { func setValue<Value>(_ value: Value, forKey: String) -> Effect<Never, Never> {
userDefaults.set(value, forKey: forKey) let effect = userDefaults.setValue(value, forKey)
userDefaults.synchronize() _ = userDefaults.synchronize()
return effect
} }
} }
extension UserPreferencesStorage {
static let live = UserPreferencesStorage(
appSessionFrom: Date().timeIntervalSince1970,
convertedCurrency: "USD",
fiatConvertion: true,
recoveryPhraseTestCompleted: false,
sessionAutoshielded: true,
userDefaults: .live()
)
static let mock = UserPreferencesStorage(
appSessionFrom: 1651039606.0,
convertedCurrency: "USD",
fiatConvertion: true,
recoveryPhraseTestCompleted: false,
sessionAutoshielded: true,
userDefaults: .mock
)
}

View File

@ -0,0 +1,44 @@
//
// WrappedUserDefaults.swift
// secant-testnet
//
// Created by Lukáš Korba on 03.05.2022.
//
import Foundation
import ComposableArchitecture
struct WrappedUserDefaults {
let objectForKey: (String) -> Any?
let remove: (String) -> Effect<Never, Never>
let setValue: (Any?, String) -> Effect<Never, Never>
let synchronize: () -> Bool
}
extension WrappedUserDefaults {
static func live(
userDefaults: UserDefaults = .standard
) -> Self {
Self(
objectForKey: userDefaults.object(forKey:),
remove: { key in
.fireAndForget {
userDefaults.removeObject(forKey: key)
}
},
setValue: { value, key in
.fireAndForget {
userDefaults.set(value, forKey: key)
}
},
synchronize: userDefaults.synchronize
)
}
static let mock = WrappedUserDefaults(
objectForKey: { _ in },
remove: { _ in .none },
setValue: { _, _ in .none },
synchronize: { true }
)
}

View File

@ -7,29 +7,45 @@
import XCTest import XCTest
@testable import secant_testnet @testable import secant_testnet
import Combine
class UserPreferencesStorageTests: XCTestCase { class UserPreferencesStorageTests: XCTestCase {
private var cancellables: [AnyCancellable] = []
// swiftlint:disable:next implicitly_unwrapped_optional // swiftlint:disable:next implicitly_unwrapped_optional
var storage: UserPreferencesStorage! var storage: UserPreferencesStorage!
override func setUp() { override func setUp() {
super.setUp() super.setUp()
guard let userDefaults = UserDefaults.init(suiteName: "test") else {
XCTFail("UserPreferencesStorageTests: UserDefaults.init(suiteName: "test") failed to initialize")
return
}
storage = UserPreferencesStorage( storage = UserPreferencesStorage(
appSessionFrom: 12345678.0, appSessionFrom: 12345678.0,
convertedCurrency: "USD", convertedCurrency: "USD",
fiatConvertion: true, fiatConvertion: true,
recoveryPhraseTestCompleted: true, recoveryPhraseTestCompleted: true,
sessionAutoshielded: false, sessionAutoshielded: false,
userDefaults: .standard userDefaults: .live(userDefaults: userDefaults)
) )
storage.removeAll() storage.removeAll()
.sink(receiveValue: { _ in })
.store(in: &cancellables)
} }
override func tearDown() { override func tearDown() {
super.tearDown() super.tearDown()
storage.removeAll()
.sink(receiveValue: { _ in })
.store(in: &cancellables)
storage = nil storage = nil
} }
// MARK: - Default values in the live UserDefaults environment
func testAppSessionFrom_defaultValue() throws { func testAppSessionFrom_defaultValue() throws {
XCTAssertEqual(12345678.0, storage.activeAppSessionFrom, "User Preferences: `activeAppSessionFrom` default doesn't match.") XCTAssertEqual(12345678.0, storage.activeAppSessionFrom, "User Preferences: `activeAppSessionFrom` default doesn't match.")
} }
@ -50,8 +66,157 @@ class UserPreferencesStorageTests: XCTestCase {
XCTAssertEqual(false, storage.isSessionAutoshielded, "User Preferences: `isSessionAutoshielded` default doesn't match.") XCTAssertEqual(false, storage.isSessionAutoshielded, "User Preferences: `isSessionAutoshielded` default doesn't match.")
} }
// MARK: - Set new values in the live UserDefaults environment
func testAppSessionFrom_setNewValue() throws {
storage.setActiveAppSessionFrom(87654321.0)
.sink(receiveValue: { _ in })
.store(in: &cancellables)
XCTAssertEqual(87654321.0, storage.activeAppSessionFrom, "User Preferences: `activeAppSessionFrom` default doesn't match.")
}
func testConvertedCurrency_setNewValue() throws {
storage.setCurrency("CZK")
.sink(receiveValue: { _ in })
.store(in: &cancellables)
XCTAssertEqual("CZK", storage.currency, "User Preferences: `currency` default doesn't match.")
}
func testFiatConvertion_setNewValue() throws {
storage.setIsFiatConverted(false)
.sink(receiveValue: { _ in })
.store(in: &cancellables)
XCTAssertEqual(false, storage.isFiatConverted, "User Preferences: `isFiatConverted` default doesn't match.")
}
func testRecoveryPhraseTestCompleted_setNewValue() throws {
storage.setIsRecoveryPhraseTestCompleted(false)
.sink(receiveValue: { _ in })
.store(in: &cancellables)
XCTAssertEqual(false, storage.isRecoveryPhraseTestCompleted, "User Preferences: `isRecoveryPhraseTestCompleted` default doesn't match.")
}
func testSessionAutoshielded_setNewValue() throws {
storage.setIsSessionAutoshielded(true)
.sink(receiveValue: { _ in })
.store(in: &cancellables)
XCTAssertEqual(true, storage.isSessionAutoshielded, "User Preferences: `isSessionAutoshielded` default doesn't match.")
}
// MARK: - Mocked user defaults vs. default values
func testAppSessionFrom_mocked() throws {
let mockedUD = WrappedUserDefaults(
objectForKey: { _ in 87654321.0 },
remove: { _ in .none },
setValue: { _, _ in .none },
synchronize: { true }
)
let mockedStorage = UserPreferencesStorage(
appSessionFrom: 12345678.0,
convertedCurrency: "USD",
fiatConvertion: true,
recoveryPhraseTestCompleted: true,
sessionAutoshielded: false,
userDefaults: mockedUD
)
XCTAssertEqual(87654321.0, mockedStorage.activeAppSessionFrom, "User Preferences: `activeAppSessionFrom` default doesn't match.")
}
func testConvertedCurrency_mocked() throws {
let mockedUD = WrappedUserDefaults(
objectForKey: { _ in "CZK" },
remove: { _ in .none },
setValue: { _, _ in .none },
synchronize: { true }
)
let mockedStorage = UserPreferencesStorage(
appSessionFrom: 12345678.0,
convertedCurrency: "USD",
fiatConvertion: true,
recoveryPhraseTestCompleted: true,
sessionAutoshielded: false,
userDefaults: mockedUD
)
XCTAssertEqual("CZK", mockedStorage.currency, "User Preferences: `currency` default doesn't match.")
}
func testFiatConvertion_mocked() throws {
let mockedUD = WrappedUserDefaults(
objectForKey: { _ in false },
remove: { _ in .none },
setValue: { _, _ in .none },
synchronize: { true }
)
let mockedStorage = UserPreferencesStorage(
appSessionFrom: 12345678.0,
convertedCurrency: "USD",
fiatConvertion: true,
recoveryPhraseTestCompleted: true,
sessionAutoshielded: false,
userDefaults: mockedUD
)
XCTAssertEqual(false, mockedStorage.isFiatConverted, "User Preferences: `isFiatConverted` default doesn't match.")
}
func testRecoveryPhraseTestCompleted_mocked() throws {
let mockedUD = WrappedUserDefaults(
objectForKey: { _ in false },
remove: { _ in .none },
setValue: { _, _ in .none },
synchronize: { true }
)
let mockedStorage = UserPreferencesStorage(
appSessionFrom: 12345678.0,
convertedCurrency: "USD",
fiatConvertion: true,
recoveryPhraseTestCompleted: true,
sessionAutoshielded: false,
userDefaults: mockedUD
)
XCTAssertEqual(false, mockedStorage.isRecoveryPhraseTestCompleted, "User Preferences: `isRecoveryPhraseTestCompleted` default doesn't match.")
}
func testSessionAutoshielded_mocked() throws {
let mockedUD = WrappedUserDefaults(
objectForKey: { _ in true },
remove: { _ in .none },
setValue: { _, _ in .none },
synchronize: { true }
)
let mockedStorage = UserPreferencesStorage(
appSessionFrom: 12345678.0,
convertedCurrency: "USD",
fiatConvertion: true,
recoveryPhraseTestCompleted: true,
sessionAutoshielded: false,
userDefaults: mockedUD
)
XCTAssertEqual(true, mockedStorage.isSessionAutoshielded, "User Preferences: `isSessionAutoshielded` default doesn't match.")
}
// MARK: - Remove all keys from the live UD environment
func testRemoveAll() throws { func testRemoveAll() throws {
let userDefaults = UserDefaults.standard guard let userDefaults = UserDefaults.init(suiteName: "test") else {
XCTFail("User Preferences: UserDefaults.init(suiteName: "test") failed to initialize")
return
}
// fill in the data // fill in the data
UserPreferencesStorage.Constants.allCases.forEach { UserPreferencesStorage.Constants.allCases.forEach {
@ -60,6 +225,8 @@ class UserPreferencesStorageTests: XCTestCase {
// remove it // remove it
storage?.removeAll() storage?.removeAll()
.sink(receiveValue: { _ in })
.store(in: &cancellables)
// check the presence // check the presence
UserPreferencesStorage.Constants.allCases.forEach { UserPreferencesStorage.Constants.allCases.forEach {