From b1247515553428a0fa8dc0e1add52c60e17570ea Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Fri, 11 Jul 2025 10:20:59 -0400 Subject: [PATCH 1/5] SystemStatusRemote: create `loadSystemStatus` that returns a generic response, and refactor existing remote methods to use this. --- .../Remote/SystemStatusRemote.swift | 78 +++++++++++++------ 1 file changed, 56 insertions(+), 22 deletions(-) diff --git a/Modules/Sources/Networking/Remote/SystemStatusRemote.swift b/Modules/Sources/Networking/Remote/SystemStatusRemote.swift index 83243004f35..ae820966bc3 100644 --- a/Modules/Sources/Networking/Remote/SystemStatusRemote.swift +++ b/Modules/Sources/Networking/Remote/SystemStatusRemote.swift @@ -3,6 +3,15 @@ import Foundation /// System Status: Remote Endpoint /// public class SystemStatusRemote: Remote { + /// Fields that can be requested in the app from the system status endpoint. + /// Reference of all supported fields: https://woocommerce.github.io/woocommerce-rest-api-docs/#system-status-properties + /// TODO: move raw value to private extension + public enum Field: String { + case activePlugins = "active_plugins" + case inactivePlugins = "inactive_plugins" + case environment = "environment" + case settings = "settings" + } /// Retrieves information from the system status that belongs to the current site. /// Currently fetching: @@ -16,19 +25,17 @@ public class SystemStatusRemote: Remote { /// public func loadSystemInformation(for siteID: Int64, completion: @escaping (Result) -> Void) { - let path = Constants.systemStatusPath - let parameters = [ - ParameterKeys.fields: [ParameterValues.environment, ParameterValues.activePlugins, ParameterValues.inactivePlugins] - ] - let request = JetpackRequest(wooApiVersion: .mark3, - method: .get, - siteID: siteID, - path: path, - parameters: parameters, - availableAsRESTRequest: true) - let mapper = SystemStatusMapper(siteID: siteID) - - enqueue(request, mapper: mapper, completion: completion) + Task { @MainActor in + do { + let mapper = SystemStatusMapper(siteID: siteID) + let systemStatus = try await loadSystemStatus(for: siteID, + fields: [Field.environment, Field.activePlugins, Field.inactivePlugins], + mapper: mapper) + completion(.success(systemStatus)) + } catch { + completion(.failure(error)) + } + } } /// Fetch details about system status for a given site. @@ -39,15 +46,48 @@ public class SystemStatusRemote: Remote { /// public func fetchSystemStatusReport(for siteID: Int64, completion: @escaping (Result) -> Void) { + Task { @MainActor in + do { + let mapper = SystemStatusReportMapper(siteID: siteID) + let systemStatus = try await loadSystemStatus(for: siteID, + fields: nil, + mapper: mapper) + completion(.success(systemStatus)) + } catch { + completion(.failure(error)) + } + } + } + + /// Loads system status information with configurable fields for a given site. + /// + /// - Parameters: + /// - siteID: Site for which the system status is fetched from. + /// - fields: Optional array of fields to fetch. If nil or empty, it fetches all available fields. + /// - mapper: Mapper to transform the response data. + /// - Returns: Mapped object based on the mapper output type. + /// - Throws: Network or parsing errors. + /// + public func loadSystemStatus(for siteID: Int64, + fields: [Field]? = nil, + mapper: M) async throws -> T where M.Output == T { let path = Constants.systemStatusPath + let parameters: [String: Any]? = { + if let fields, !fields.isEmpty { + return [ + ParameterKeys.fields: fields.map(\.rawValue) + ] + } else { + return nil + } + }() let request = JetpackRequest(wooApiVersion: .mark3, method: .get, siteID: siteID, path: path, - parameters: nil, + parameters: parameters, availableAsRESTRequest: true) - let mapper = SystemStatusReportMapper(siteID: siteID) - enqueue(request, mapper: mapper, completion: completion) + return try await enqueue(request, mapper: mapper) } } @@ -58,12 +98,6 @@ private extension SystemStatusRemote { static let systemStatusPath: String = "system_status" } - enum ParameterValues { - static let activePlugins: String = "active_plugins" - static let inactivePlugins: String = "inactive_plugins" - static let environment: String = "environment" - } - enum ParameterKeys { static let fields: String = "_fields" } From b11e98ad5241c9b8de307cfc9d14a3cc62b41799 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Fri, 11 Jul 2025 13:32:43 -0400 Subject: [PATCH 2/5] Create `POSSystemStatusService` to load WC plugin and POS feature switch value from one system status API request. --- .../Networking/Mapper/SingleItemMapper.swift | 8 +- .../Eligibility/POSSystemStatusService.swift | 81 +++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 Modules/Sources/Yosemite/PointOfSale/Eligibility/POSSystemStatusService.swift diff --git a/Modules/Sources/Networking/Mapper/SingleItemMapper.swift b/Modules/Sources/Networking/Mapper/SingleItemMapper.swift index 2cc75cf82df..44e14bb1f87 100644 --- a/Modules/Sources/Networking/Mapper/SingleItemMapper.swift +++ b/Modules/Sources/Networking/Mapper/SingleItemMapper.swift @@ -2,16 +2,20 @@ import Foundation /// SingleItemMapper: Maps generic REST API requests for a single item /// -struct SingleItemMapper: Mapper { +public struct SingleItemMapper: Mapper { /// Site Identifier associated to the items that will be parsed. /// /// We're injecting this field via `JSONDecoder.userInfo` because SiteID is not returned by our endpoints. /// let siteID: Int64 + public init(siteID: Int64) { + self.siteID = siteID + } + /// (Attempts) to convert a dictionary into Output. /// - func map(response: Data) throws -> Output { + public func map(response: Data) throws -> Output { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .formatted(DateFormatter.Defaults.dateTimeFormatter) decoder.userInfo = [ diff --git a/Modules/Sources/Yosemite/PointOfSale/Eligibility/POSSystemStatusService.swift b/Modules/Sources/Yosemite/PointOfSale/Eligibility/POSSystemStatusService.swift new file mode 100644 index 00000000000..f030e866b00 --- /dev/null +++ b/Modules/Sources/Yosemite/PointOfSale/Eligibility/POSSystemStatusService.swift @@ -0,0 +1,81 @@ +import Foundation +import Networking + +public protocol POSSystemStatusServiceProtocol { + /// Loads WooCommerce plugin and POS feature switch value remotely for eligibility checks. + /// - Parameter siteID: The site ID to fetch information for. + /// - Returns: POSPluginAndFeatureInfo containing plugin and feature data. + /// - Throws: Network or parsing errors. + func loadWooCommercePluginAndPOSFeatureSwitch(siteID: Int64) async throws -> POSPluginAndFeatureInfo +} + +/// Contains WooCommerce plugin information and POS feature switch value. +public struct POSPluginAndFeatureInfo { + public let wcPlugin: SystemPlugin? + public let featureValue: Bool? + + public init(wcPlugin: SystemPlugin?, featureValue: Bool?) { + self.wcPlugin = wcPlugin + self.featureValue = featureValue + } +} + +/// Service for fetching POS-related system status information. +public final class POSSystemStatusService: POSSystemStatusServiceProtocol { + private let remote: SystemStatusRemote + + public init(credentials: Credentials?) { + let network = AlamofireNetwork(credentials: credentials) + remote = SystemStatusRemote(network: network) + } + + public func loadWooCommercePluginAndPOSFeatureSwitch(siteID: Int64) async throws -> POSPluginAndFeatureInfo { + let mapper = SingleItemMapper(siteID: siteID) + let systemStatus: POSPluginEligibilitySystemStatus = try await remote.loadSystemStatus( + for: siteID, + fields: [.activePlugins, .settings], + mapper: mapper + ) + + // Finds WooCommerce plugin from active plugins response. + guard let wcPlugin = systemStatus.activePlugins.first(where: { $0.plugin == Constants.wcPluginPath }) else { + return POSPluginAndFeatureInfo(wcPlugin: nil, featureValue: nil) + } + + // Extracts POS feature value from settings response. + let featureValue = systemStatus.settings.enabledFeatures.contains(SiteSettingsFeature.pointOfSale.rawValue) ? true : nil + return POSPluginAndFeatureInfo(wcPlugin: wcPlugin, featureValue: featureValue) + } +} + +private extension POSSystemStatusService { + enum Constants { + static let wcPluginPath = "woocommerce/woocommerce.php" + } +} + +// MARK: - Errors + +public enum POSSystemStatusServiceError: Error { + case wooCommercePluginNotFound +} + +// MARK: - Network Response Structs + +private struct POSPluginEligibilitySystemStatus: Decodable { + let activePlugins: [SystemPlugin] + let settings: POSEligibilitySystemStatusSettings + + enum CodingKeys: String, CodingKey { + case activePlugins = "active_plugins" + case settings + } +} + +private struct POSEligibilitySystemStatusSettings: Decodable { + let enabledFeatures: [String] + + enum CodingKeys: String, CodingKey { + case enabledFeatures = "enabled_features" + } +} From 3a26eb2dc165181033c9622336bcf655ef1f45ff Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Fri, 11 Jul 2025 16:43:30 -0400 Subject: [PATCH 3/5] Add test cases for `POSSystemStatusService.loadWooCommercePluginAndPOSFeatureSwitch`. --- .../Eligibility/POSSystemStatusService.swift | 11 ++- ...us-wc-plugin-and-pos-feature-disabled.json | 58 ++++++++++++ ...tus-wc-plugin-and-pos-feature-enabled.json | 59 ++++++++++++ .../system-status-wc-plugin-missing.json | 49 ++++++++++ .../POSSystemStatusServiceTests.swift | 89 +++++++++++++++++++ 5 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 Modules/Tests/NetworkingTests/Responses/system-status-wc-plugin-and-pos-feature-disabled.json create mode 100644 Modules/Tests/NetworkingTests/Responses/system-status-wc-plugin-and-pos-feature-enabled.json create mode 100644 Modules/Tests/NetworkingTests/Responses/system-status-wc-plugin-missing.json create mode 100644 Modules/Tests/YosemiteTests/PointOfSale/POSSystemStatusServiceTests.swift diff --git a/Modules/Sources/Yosemite/PointOfSale/Eligibility/POSSystemStatusService.swift b/Modules/Sources/Yosemite/PointOfSale/Eligibility/POSSystemStatusService.swift index f030e866b00..3c85cf5b963 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Eligibility/POSSystemStatusService.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Eligibility/POSSystemStatusService.swift @@ -29,6 +29,11 @@ public final class POSSystemStatusService: POSSystemStatusServiceProtocol { remote = SystemStatusRemote(network: network) } + /// Test-friendly initializer that accepts a network implementation. + init(network: Network) { + remote = SystemStatusRemote(network: network) + } + public func loadWooCommercePluginAndPOSFeatureSwitch(siteID: Int64) async throws -> POSPluginAndFeatureInfo { let mapper = SingleItemMapper(siteID: siteID) let systemStatus: POSPluginEligibilitySystemStatus = try await remote.loadSystemStatus( @@ -43,7 +48,7 @@ public final class POSSystemStatusService: POSSystemStatusServiceProtocol { } // Extracts POS feature value from settings response. - let featureValue = systemStatus.settings.enabledFeatures.contains(SiteSettingsFeature.pointOfSale.rawValue) ? true : nil + let featureValue = systemStatus.settings.enabledFeatures?.contains(SiteSettingsFeature.pointOfSale.rawValue) == true ? true : nil return POSPluginAndFeatureInfo(wcPlugin: wcPlugin, featureValue: featureValue) } } @@ -73,7 +78,9 @@ private struct POSPluginEligibilitySystemStatus: Decodable { } private struct POSEligibilitySystemStatusSettings: Decodable { - let enabledFeatures: [String] + // As `settings.enable_features` was introduced in WC version 9.9.0, this field is optional. + // Ref: https://github.com/woocommerce/woocommerce/pull/57168 + let enabledFeatures: [String]? enum CodingKeys: String, CodingKey { case enabledFeatures = "enabled_features" diff --git a/Modules/Tests/NetworkingTests/Responses/system-status-wc-plugin-and-pos-feature-disabled.json b/Modules/Tests/NetworkingTests/Responses/system-status-wc-plugin-and-pos-feature-disabled.json new file mode 100644 index 00000000000..146bdf85529 --- /dev/null +++ b/Modules/Tests/NetworkingTests/Responses/system-status-wc-plugin-and-pos-feature-disabled.json @@ -0,0 +1,58 @@ +{ + "data": { + "active_plugins":[ + { + "plugin": "woocommerce/woocommerce.php", + "name": "WooCommerce", + "version": "10.0.0-dev", + "version_latest": "10.0.0-dev", + "url": "https://woocommerce.com/", + "author_name": "Automattic", + "author_url": "https://woocommerce.com", + "network_activated": false + } + ], + "settings": { + "api_enabled": false, + "force_ssl": false, + "currency": "USD", + "currency_symbol": "$", + "currency_position": "left", + "thousand_separator": ",", + "decimal_separator": ".", + "number_of_decimals": 2, + "geolocation_enabled": false, + "taxonomies": { + "external": "external", + "grouped": "grouped", + "simple": "simple", + "variable": "variable" + }, + "product_visibility_terms": { + "exclude-from-catalog": "exclude-from-catalog", + "exclude-from-search": "exclude-from-search", + "featured": "featured", + "outofstock": "outofstock", + "rated-1": "rated-1", + "rated-2": "rated-2", + "rated-3": "rated-3", + "rated-4": "rated-4", + "rated-5": "rated-5" + }, + "woocommerce_com_connected": "no", + "enforce_approved_download_dirs": true, + "order_datastore": "Automattic\\WooCommerce\\Internal\\DataStores\\Orders\\OrdersTableDataStore", + "HPOS_enabled": true, + "HPOS_sync_enabled": false, + "enabled_features": [ + "analytics", + "marketplace", + "order_attribution", + "site_visibility_badge", + "remote_logging", + "email_improvements", + "custom_order_tables" + ] + } + } +} diff --git a/Modules/Tests/NetworkingTests/Responses/system-status-wc-plugin-and-pos-feature-enabled.json b/Modules/Tests/NetworkingTests/Responses/system-status-wc-plugin-and-pos-feature-enabled.json new file mode 100644 index 00000000000..72c3e66d6cf --- /dev/null +++ b/Modules/Tests/NetworkingTests/Responses/system-status-wc-plugin-and-pos-feature-enabled.json @@ -0,0 +1,59 @@ +{ + "data": { + "active_plugins":[ + { + "plugin": "woocommerce/woocommerce.php", + "name": "WooCommerce", + "version": "10.0.0-dev", + "version_latest": "10.0.0-dev", + "url": "https://woocommerce.com/", + "author_name": "Automattic", + "author_url": "https://woocommerce.com", + "network_activated": false + } + ], + "settings": { + "api_enabled": false, + "force_ssl": false, + "currency": "USD", + "currency_symbol": "$", + "currency_position": "left", + "thousand_separator": ",", + "decimal_separator": ".", + "number_of_decimals": 2, + "geolocation_enabled": false, + "taxonomies": { + "external": "external", + "grouped": "grouped", + "simple": "simple", + "variable": "variable" + }, + "product_visibility_terms": { + "exclude-from-catalog": "exclude-from-catalog", + "exclude-from-search": "exclude-from-search", + "featured": "featured", + "outofstock": "outofstock", + "rated-1": "rated-1", + "rated-2": "rated-2", + "rated-3": "rated-3", + "rated-4": "rated-4", + "rated-5": "rated-5" + }, + "woocommerce_com_connected": "no", + "enforce_approved_download_dirs": true, + "order_datastore": "Automattic\\WooCommerce\\Internal\\DataStores\\Orders\\OrdersTableDataStore", + "HPOS_enabled": true, + "HPOS_sync_enabled": false, + "enabled_features": [ + "analytics", + "marketplace", + "order_attribution", + "site_visibility_badge", + "remote_logging", + "email_improvements", + "point_of_sale", + "custom_order_tables" + ] + } + } +} diff --git a/Modules/Tests/NetworkingTests/Responses/system-status-wc-plugin-missing.json b/Modules/Tests/NetworkingTests/Responses/system-status-wc-plugin-missing.json new file mode 100644 index 00000000000..2bfadb84427 --- /dev/null +++ b/Modules/Tests/NetworkingTests/Responses/system-status-wc-plugin-missing.json @@ -0,0 +1,49 @@ +{ + "data": { + "active_plugins":[ + ], + "settings": { + "api_enabled": false, + "force_ssl": false, + "currency": "USD", + "currency_symbol": "$", + "currency_position": "left", + "thousand_separator": ",", + "decimal_separator": ".", + "number_of_decimals": 2, + "geolocation_enabled": false, + "taxonomies": { + "external": "external", + "grouped": "grouped", + "simple": "simple", + "variable": "variable" + }, + "product_visibility_terms": { + "exclude-from-catalog": "exclude-from-catalog", + "exclude-from-search": "exclude-from-search", + "featured": "featured", + "outofstock": "outofstock", + "rated-1": "rated-1", + "rated-2": "rated-2", + "rated-3": "rated-3", + "rated-4": "rated-4", + "rated-5": "rated-5" + }, + "woocommerce_com_connected": "no", + "enforce_approved_download_dirs": true, + "order_datastore": "Automattic\\WooCommerce\\Internal\\DataStores\\Orders\\OrdersTableDataStore", + "HPOS_enabled": true, + "HPOS_sync_enabled": false, + "enabled_features": [ + "analytics", + "marketplace", + "order_attribution", + "site_visibility_badge", + "remote_logging", + "email_improvements", + "point_of_sale", + "custom_order_tables" + ] + } + } +} diff --git a/Modules/Tests/YosemiteTests/PointOfSale/POSSystemStatusServiceTests.swift b/Modules/Tests/YosemiteTests/PointOfSale/POSSystemStatusServiceTests.swift new file mode 100644 index 00000000000..aa769b933ab --- /dev/null +++ b/Modules/Tests/YosemiteTests/PointOfSale/POSSystemStatusServiceTests.swift @@ -0,0 +1,89 @@ +import Foundation +import Testing +import TestKit +@testable import Networking +@testable import Yosemite + +@MainActor +struct POSSystemStatusServiceTests { + private let network = MockNetwork() + private let sampleSiteID: Int64 = 134 + private let sut: POSSystemStatusService + + init() async throws { + network.removeAllSimulatedResponses() + sut = POSSystemStatusService(network: network) + } + + // MARK: - loadWooCommercePluginAndPOSFeatureSwitch Tests + + @Test func loadWooCommercePluginAndPOSFeatureSwitch_returns_plugin_and_nil_feature_when_settings_response_does_not_include_enabled_featuers() async throws { + // Given + network.simulateResponse(requestUrlSuffix: "system_status", filename: "systemStatus") + + // When + let result = try await sut.loadWooCommercePluginAndPOSFeatureSwitch(siteID: sampleSiteID) + + // Then + let plugin = try #require(result.wcPlugin) + #expect(plugin.plugin == "woocommerce/woocommerce.php") + #expect(plugin.name == "WooCommerce") + #expect(plugin.version == "5.8.0") + #expect(plugin.active == true) + #expect(result.featureValue == nil) + } + + @Test func loadWooCommercePluginAndPOSFeatureSwitch_returns_plugin_and_enabled_feature_when_feature_is_enabled() async throws { + // Given + network.simulateResponse(requestUrlSuffix: "system_status", filename: "system-status-wc-plugin-and-pos-feature-enabled") + + // When + let result = try await sut.loadWooCommercePluginAndPOSFeatureSwitch(siteID: sampleSiteID) + + // Then + let plugin = try #require(result.wcPlugin) + #expect(plugin.plugin == "woocommerce/woocommerce.php") + #expect(plugin.name == "WooCommerce") + #expect(plugin.version == "10.0.0-dev") + #expect(plugin.active == true) + #expect(result.featureValue == true) + } + + @Test func loadWooCommercePluginAndPOSFeatureSwitch_returns_plugin_and_nil_feature_when_feature_is_disabled() async throws { + // Given + network.simulateResponse(requestUrlSuffix: "system_status", filename: "system-status-wc-plugin-and-pos-feature-disabled") + + // When + let result = try await sut.loadWooCommercePluginAndPOSFeatureSwitch(siteID: sampleSiteID) + + // Then + let plugin = try #require(result.wcPlugin) + #expect(plugin.plugin == "woocommerce/woocommerce.php") + #expect(plugin.name == "WooCommerce") + #expect(plugin.version == "10.0.0-dev") + #expect(plugin.active == true) + #expect(result.featureValue == nil) + } + + @Test func loadWooCommercePluginAndPOSFeatureSwitch_returns_nil_plugin_and_nil_feature_when_woocommerce_plugin_not_found() async throws { + // Given + network.simulateResponse(requestUrlSuffix: "system_status", filename: "system-status-wc-plugin-missing") + + // When + let result = try await sut.loadWooCommercePluginAndPOSFeatureSwitch(siteID: sampleSiteID) + + // Then + #expect(result.wcPlugin == nil) + #expect(result.featureValue == nil) + } + + @Test func loadWooCommercePluginAndPOSFeatureSwitch_throws_error_on_network_failure() async throws { + // Given + network.simulateError(requestUrlSuffix: "system_status", error: NetworkError.notFound()) + + // When & Then + await #expect(throws: NetworkError.self) { + try await sut.loadWooCommercePluginAndPOSFeatureSwitch(siteID: sampleSiteID) + } + } +} From 961d2f9f63636fee3ff85f7b69ba02d43915c4f4 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Fri, 11 Jul 2025 16:53:19 -0400 Subject: [PATCH 4/5] Move `SystemStatusRemote.Field` raw value to a private extension. --- .../Remote/SystemStatusRemote.swift | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/Modules/Sources/Networking/Remote/SystemStatusRemote.swift b/Modules/Sources/Networking/Remote/SystemStatusRemote.swift index ae820966bc3..b8d8efd8e40 100644 --- a/Modules/Sources/Networking/Remote/SystemStatusRemote.swift +++ b/Modules/Sources/Networking/Remote/SystemStatusRemote.swift @@ -5,12 +5,11 @@ import Foundation public class SystemStatusRemote: Remote { /// Fields that can be requested in the app from the system status endpoint. /// Reference of all supported fields: https://woocommerce.github.io/woocommerce-rest-api-docs/#system-status-properties - /// TODO: move raw value to private extension - public enum Field: String { - case activePlugins = "active_plugins" - case inactivePlugins = "inactive_plugins" - case environment = "environment" - case settings = "settings" + public enum Field { + case activePlugins + case inactivePlugins + case environment + case settings } /// Retrieves information from the system status that belongs to the current site. @@ -102,3 +101,20 @@ private extension SystemStatusRemote { static let fields: String = "_fields" } } + +// MARK: - Field Raw Values +// +private extension SystemStatusRemote.Field { + var rawValue: String { + switch self { + case .activePlugins: + return "active_plugins" + case .inactivePlugins: + return "inactive_plugins" + case .environment: + return "environment" + case .settings: + return "settings" + } + } +} From 452083874bf7dfb66218bd4e7feaf2aec81aa5e2 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Fri, 11 Jul 2025 17:11:02 -0400 Subject: [PATCH 5/5] Remove unused error enum. --- .../PointOfSale/Eligibility/POSSystemStatusService.swift | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Modules/Sources/Yosemite/PointOfSale/Eligibility/POSSystemStatusService.swift b/Modules/Sources/Yosemite/PointOfSale/Eligibility/POSSystemStatusService.swift index 3c85cf5b963..ee4c09b8979 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Eligibility/POSSystemStatusService.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Eligibility/POSSystemStatusService.swift @@ -59,12 +59,6 @@ private extension POSSystemStatusService { } } -// MARK: - Errors - -public enum POSSystemStatusServiceError: Error { - case wooCommercePluginNotFound -} - // MARK: - Network Response Structs private struct POSPluginEligibilitySystemStatus: Decodable {