diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/scope/EspiScope.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/scope/EspiScope.java new file mode 100644 index 00000000..2909df26 --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/scope/EspiScope.java @@ -0,0 +1,181 @@ +/* + * Copyright 2025 Green Button Alliance, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.greenbuttonalliance.espi.common.scope; + +import org.greenbuttonalliance.espi.common.domain.usage.enums.ServiceKind; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +/** + * Immutable value object for one parsed NAESB ESPI 4.0 OAuth scope string. + * + *
ESPI scope grammar is a {@code ;}-delimited list of {@code key=value} terms, e.g. + * {@code FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13}. The {@code FB} + * term carries a {@code _}-delimited set of Function Block ids.
+ * + *The parser is intentionally FB-agnostic: it carries whatever FB ids appear + * in the string (including spec-deprecated or future ids) without consulting the catalog. Semantic + * questions — category, commodity {@link ServiceKind} — are answered on demand via + * {@link FunctionBlock}. Unrecognized non-{@code FB} terms are preserved in + * {@link #additionalParameters()} so no information is lost.
+ * + *This object holds no OAuth, HTTP, persistence, or resource knowledge. It is the shared + * foundation for grant→subscription candidate resolution and resource-server enforcement (#122).
+ * + * @param raw the original scope string as parsed + * @param functionBlocks FB ids from the {@code FB=} term, sorted ascending and immutable + * @param intervalDuration {@code IntervalDuration} term in seconds, or {@code null} if absent + * @param blockDuration {@code BlockDuration} term (e.g. {@code monthly}), or {@code null} if absent + * @param historyLength {@code HistoryLength} term, or {@code null} if absent + * @param additionalParameters any other {@code key=value} terms (and bare tokens, with empty value), + * insertion-ordered and immutable + * @see FunctionBlock + * @see FunctionBlockCategory + */ +public record EspiScope( + String raw, + SetTolerant of surrounding whitespace, stray/empty {@code ;} segments, and mixed-case term + * keys. The recognized keys ({@code FB}, {@code IntervalDuration}, {@code BlockDuration}, + * {@code HistoryLength}) are matched case-insensitively; every other {@code key=value} term is + * preserved in {@link #additionalParameters()}, and a bare token with no {@code =} is preserved + * with an empty value.
+ * + * @param scope the scope string (e.g. {@code FB=4_5_15;IntervalDuration=3600}) + * @return the parsed value object + * @throws IllegalArgumentException if {@code scope} is null/blank, or an {@code FB}, + * {@code IntervalDuration}, or {@code HistoryLength} value is + * not an integer + */ + public static EspiScope parse(String scope) { + if (scope == null || scope.isBlank()) { + throw new IllegalArgumentException("ESPI scope must not be null or blank"); + } + + SetThis enum is the single in-code home for ESPI FB semantics. Each constant carries the + * FB's numeric id (the wire value used in the {@code FB=} scope term), its + * {@link FunctionBlockCategory}, the spec title, and — for most {@code COMMODITY} FBs — + * the {@link ServiceKind} it selects.
+ * + *Only active (non-deprecated) FBs are enumerated. FB ids the spec marks + * {@code [DEPRECATED]} are recorded in {@link #DEPRECATED_IDS} so that {@link #categoryOf(int)} + * reports {@link FunctionBlockCategory#DEPRECATED} rather than {@link FunctionBlockCategory#UNKNOWN}. + * Any id absent from both is genuinely outside the spec table and resolves to {@code UNKNOWN}.
+ * + *The {@link EspiScope} parser is intentionally FB-agnostic: it carries whatever FB ids appear + * in a scope string, so future or unrecognized FBs still parse cleanly. This catalog is consulted + * only when a category or service kind is needed. The {@code title} is diagnostic only (logging / + * documentation) — it is never serialized and never drives an authorization decision.
+ * + * @see FunctionBlockCategory + * @see EspiScope + */ +public enum FunctionBlock { + + // --- Energy Usage domain ------------------------------------------------- + FB_01(1, FunctionBlockCategory.PLATFORM, "Common (Energy Usage)"), + FB_02(2, FunctionBlockCategory.INTERACTION, "Download My Data (Energy Usage)"), + FB_03(3, FunctionBlockCategory.INTERACTION, "Connect My Data (Energy Usage)"), + FB_04(4, FunctionBlockCategory.BASE, "Interval Metering"), + FB_05(5, FunctionBlockCategory.COMMODITY, ServiceKind.ELECTRICITY, "Interval Electricity Metering"), + FB_06(6, FunctionBlockCategory.COMMODITY, ServiceKind.ELECTRICITY, "Demand Electricity Metering"), + FB_07(7, FunctionBlockCategory.COMMODITY, ServiceKind.ELECTRICITY, "Net Metering"), + FB_08(8, FunctionBlockCategory.COMMODITY, ServiceKind.ELECTRICITY, "Forward and Reverse Metering"), + FB_09(9, FunctionBlockCategory.COMMODITY, ServiceKind.ELECTRICITY, "Register Values"), + FB_10(10, FunctionBlockCategory.COMMODITY, ServiceKind.GAS, "Gas Interval Metering"), + FB_11(11, FunctionBlockCategory.COMMODITY, ServiceKind.WATER, "Water Interval Metering"), + FB_12(12, FunctionBlockCategory.ENERGY_DATA_SHAPE, "Cost of Interval Data"), + FB_13(13, FunctionBlockCategory.PLATFORM, "Security and Privacy Classes (Energy Usage)"), + FB_15(15, FunctionBlockCategory.ENERGY_DATA_SHAPE, "Usage Summary"), + FB_16(16, FunctionBlockCategory.ENERGY_DATA_SHAPE, "Usage Summary with Cost"), + FB_17(17, FunctionBlockCategory.ENERGY_DATA_SHAPE, "Power Quality Summary"), + FB_27(27, FunctionBlockCategory.ENERGY_DATA_SHAPE, "Usage Summary with Demands and Previous Day Attributes"), + FB_28(28, FunctionBlockCategory.ENERGY_DATA_SHAPE, "Usage Summary Costs for Current Billing Period"), + + /** + * Temperature Interval Metering — commodity FB with no {@link ServiceKind} mapping. + * + *The ESPI 4.0 {@code ServiceKind} XSD enumeration (electricity, gas, water, time, heat, + * refuse, sewerage, rates, TV licence, internet) has no value for temperature, so a UsagePoint's + * {@code ServiceCategory.kind} cannot encode it — a known gap in the standard. FB 29 is + * therefore {@link FunctionBlockCategory#COMMODITY} with no service kind, and + * {@link EspiScope#commodityServiceKinds()} will not include any kind solely on its account.
+ */ + FB_29(29, FunctionBlockCategory.COMMODITY, "Temperature Interval Metering"), + + FB_30(30, FunctionBlockCategory.PLATFORM, "Common User Experience"), + FB_31(31, FunctionBlockCategory.AUTHORIZATION, "Authorization and Authentication w/o Pre-Negotiated Scope"), + FB_35(35, FunctionBlockCategory.BULK_TRANSFER, "REST for Energy Usage Bulk"), + FB_37(37, FunctionBlockCategory.INTERACTION, "Query Parameters (Energy Usage)"), + FB_39(39, FunctionBlockCategory.INTERACTION, "PUSH Model (Energy Usage)"), + FB_40(40, FunctionBlockCategory.AUTHORIZATION, "Offline Authorization (Energy Usage)"), + FB_41(41, FunctionBlockCategory.ADMINISTRATION, "Manage ApplicationInformation Resource"), + FB_44(44, FunctionBlockCategory.ADMINISTRATION, "Manage Authorization Resource"), + + // --- Retail Customer domain ---------------------------------------------- + FB_51(51, FunctionBlockCategory.PLATFORM, "Common (Retail Customer)"), + FB_52(52, FunctionBlockCategory.INTERACTION, "Download My Data (Retail Customer)"), + FB_53(53, FunctionBlockCategory.BASE, "Connect My Data (Retail Customer)"), + FB_54(54, FunctionBlockCategory.CUSTOMER_PII, "Basic Retail Customer Information"), + FB_55(55, FunctionBlockCategory.CUSTOMER_PII, "Retail Customer Address Information"), + FB_56(56, FunctionBlockCategory.CUSTOMER_PII, "Retail Customer Billing Information"), + FB_57(57, FunctionBlockCategory.CUSTOMER_PII, "Retail Customer AccountAgreement Information"), + FB_58(58, FunctionBlockCategory.CUSTOMER_PII, "Retail Customer ServiceLocation Information"), + FB_59(59, FunctionBlockCategory.CUSTOMER_PII, "Retail Customer ServiceSupplier Information"), + FB_60(60, FunctionBlockCategory.CUSTOMER_PII, "Retail Customer Meter Information"), + FB_61(61, FunctionBlockCategory.CUSTOMER_PII, "Retail Customer EndDevice Information"), + FB_62(62, FunctionBlockCategory.CUSTOMER_PII, "Retail Customer ProgramDateIdMapping Information"), + FB_63(63, FunctionBlockCategory.PLATFORM, "Common User Experience (Retail Customer)"), + FB_64(64, FunctionBlockCategory.PLATFORM, "Security and Privacy Classes (Retail Customer)"), + FB_65(65, FunctionBlockCategory.AUTHORIZATION, "Authorization and Authentication w/o Pre-Negotiated Scope (Retail Customer)"), + FB_67(67, FunctionBlockCategory.BULK_TRANSFER, "REST for Retail Customer Bulk"), + FB_68(68, FunctionBlockCategory.INTERACTION, "Query Parameters (Retail Customer)"), + FB_69(69, FunctionBlockCategory.INTERACTION, "PUSH Model (Retail Customer)"), + FB_70(70, FunctionBlockCategory.AUTHORIZATION, "Offline Authorization (Retail Customer)"); + + /** + * FB ids that NAESB ESPI 4.0 marks {@code [DEPRECATED]} (no longer assignable but reserved so + * the numbers are not reused). Recorded so {@link #categoryOf(int)} can distinguish a deprecated + * FB from one that was never defined. Includes the SFTP bulk FBs (34, 66), which the standard + * retired in favor of the REST bulk FBs (35, 67). + */ + public static final SetA granted ESPI scope is a set of Function Block IDs (e.g. {@code FB=4_5_15}). + * Every FB defined by the standard falls into exactly one of these categories. + * The first four are load-bearing — they drive grant→subscription + * resolution and resource-server enforcement (Phase 2, #122). The remainder exist so + * that a real spec FB is never mislabeled {@link #UNKNOWN}.
+ * + * @see FunctionBlock + * @see EspiScope + */ +public enum FunctionBlockCategory { + + /** Root resource an FB unlocks: FB 04 (Interval Metering / UsagePoint), FB 53 (Connect My Data, Retail Customer). */ + BASE, + + /** + * Commodity selector — maps to {@code ServiceCategory.kind} (FB 05-11, 29). + * Note FB 29 (Temperature Interval Metering) is a commodity FB with no {@code ServiceKind} + * equivalent in the ESPI XSD enumeration. + */ + COMMODITY, + + /** Shape of energy data exposed: summaries, cost, power quality (FB 12, 15, 16, 17, 27, 28). */ + ENERGY_DATA_SHAPE, + + /** Customer / PII resources from customer.xsd (FB 54-62). */ + CUSTOMER_PII, + + /** Delivery and query models: Download/Connect My Data, Query Parameters, PUSH (FB 02, 03, 37, 39, 52, 68, 69). */ + INTERACTION, + + /** Bulk transfer transport: REST bulk (FB 35, 67). The SFTP bulk FBs (34, 66) are deprecated. */ + BULK_TRANSFER, + + /** Authorization / authentication function blocks, incl. offline authorization (FB 31, 40, 65, 70). */ + AUTHORIZATION, + + /** Resource-management function blocks (FB 41, 44). */ + ADMINISTRATION, + + /** Cross-cutting platform FBs: Common, Common User Experience, Security and Privacy Classes (FB 01, 13, 30, 51, 63, 64). */ + PLATFORM, + + /** An FB ID that NAESB ESPI 4.0 marks DEPRECATED (FB 14, 18, 19, 32, 33, 34, 36, 38, 46-50, 66). */ + DEPRECATED, + + /** An FB ID not defined by the NAESB ESPI 4.0 ScopeFBTerms table. */ + UNKNOWN +} diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/scope/EspiScopeTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/scope/EspiScopeTest.java new file mode 100644 index 00000000..a01d93d8 --- /dev/null +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/scope/EspiScopeTest.java @@ -0,0 +1,194 @@ +/* + * Copyright 2025 Green Button Alliance, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.greenbuttonalliance.espi.common.scope; + +import org.greenbuttonalliance.espi.common.domain.usage.enums.ServiceKind; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNoException; + +/** + * Unit tests for {@link EspiScope} parsing and the category-aware views it derives from + * {@link FunctionBlock}. + */ +class EspiScopeTest { + + @Test + @DisplayName("parses the full ESPI scope grammar") + void parsesFullGrammar() { + EspiScope scope = EspiScope.parse("FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13"); + + assertThat(scope) + .satisfies(s -> assertThat(s.raw()).isEqualTo("FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13")) + .extracting( + EspiScope::functionBlocks, + EspiScope::intervalDuration, + EspiScope::blockDuration, + EspiScope::historyLength, + EspiScope::additionalParameters) + .containsExactly( + java.util.Set.of(4, 5, 15), + 3600, + "monthly", + 13, + java.util.Map.of()); + } + + @Test + @DisplayName("FB ids are sorted ascending regardless of input order") + void functionBlocksAreSorted() { + EspiScope scope = EspiScope.parse("FB=15_4_29_5"); + + assertThat(scope.functionBlocks()).containsExactly(4, 5, 15, 29); + } + + @Test + @DisplayName("unrecognized terms and bare tokens are preserved in additionalParameters") + void preservesUnknownTerms() { + EspiScope scope = EspiScope.parse("FB=4_5;ServiceKindFilter=ELECTRIC;DataCustodian_Admin_Access"); + + assertThat(scope.additionalParameters()) + .containsEntry("ServiceKindFilter", "ELECTRIC") + .containsEntry("DataCustodian_Admin_Access", ""); + } + + @Test + @DisplayName("recognized term keys are matched case-insensitively") + void keysAreCaseInsensitive() { + EspiScope scope = EspiScope.parse("fb=4_5;intervalduration=900;BLOCKDURATION=daily;HistoryLength=7"); + + assertThat(scope) + .extracting(EspiScope::functionBlocks, EspiScope::intervalDuration, EspiScope::blockDuration, EspiScope::historyLength) + .containsExactly(java.util.Set.of(4, 5), 900, "daily", 7); + assertThat(scope.additionalParameters()).isEmpty(); + } + + @Test + @DisplayName("tolerant of surrounding whitespace and stray/empty segments") + void tolerantOfWhitespaceAndEmptySegments() { + EspiScope scope = EspiScope.parse(" ;; FB = 4_5 ; ; IntervalDuration = 3600 ;"); + + assertThat(scope) + .extracting(EspiScope::functionBlocks, EspiScope::intervalDuration) + .containsExactly(java.util.Set.of(4, 5), 3600); + } + + @Test + @DisplayName("a scope with no FB term yields an empty functionBlocks set") + void scopeWithoutFbTerm() { + EspiScope scope = EspiScope.parse("IntervalDuration=3600"); + + assertThat(scope.functionBlocks()).isEmpty(); + assertThat(scope.intervalDuration()).isEqualTo(3600); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "\t"}) + @DisplayName("null or blank scope is rejected") + void rejectsNullOrBlank(String scope) { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> EspiScope.parse(scope)) + .withMessageContaining("must not be null or blank"); + } + + @Test + @DisplayName("a non-integer FB token is rejected") + void rejectsNonIntegerFb() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> EspiScope.parse("FB=4_x_15")) + .withMessageContaining("FB"); + } + + @Test + @DisplayName("a non-integer IntervalDuration is rejected") + void rejectsNonIntegerIntervalDuration() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> EspiScope.parse("FB=4;IntervalDuration=hourly")) + .withMessageContaining("IntervalDuration"); + } + + @Test + @DisplayName("containsFunctionBlock reflects membership") + void containsFunctionBlock() { + EspiScope scope = EspiScope.parse("FB=4_5_15"); + + assertThat(scope.containsFunctionBlock(5)).isTrue(); + assertThat(scope.containsFunctionBlock(16)).isFalse(); + } + + @Test + @DisplayName("functionBlocksIn filters by category") + void functionBlocksInByCategory() { + EspiScope scope = EspiScope.parse("FB=4_5_10_15_54_36_99"); + + assertThat(scope.functionBlocksIn(FunctionBlockCategory.BASE)).containsExactly(4); + assertThat(scope.functionBlocksIn(FunctionBlockCategory.COMMODITY)).containsExactly(5, 10); + assertThat(scope.functionBlocksIn(FunctionBlockCategory.ENERGY_DATA_SHAPE)).containsExactly(15); + assertThat(scope.functionBlocksIn(FunctionBlockCategory.CUSTOMER_PII)).containsExactly(54); + assertThat(scope.functionBlocksIn(FunctionBlockCategory.DEPRECATED)).containsExactly(36); + assertThat(scope.functionBlocksIn(FunctionBlockCategory.UNKNOWN)).containsExactly(99); + } + + @Test + @DisplayName("commodityServiceKinds aggregates kinds; temperature (FB 29) contributes none") + void commodityServiceKinds() { + EspiScope scope = EspiScope.parse("FB=4_5_10_11_29_15"); + + assertThat(scope.commodityServiceKinds()) + .containsExactlyInAnyOrder(ServiceKind.ELECTRICITY, ServiceKind.GAS, ServiceKind.WATER); + } + + @Test + @DisplayName("FB 29 alone yields no commodity ServiceKind") + void temperatureAloneYieldsNoServiceKind() { + assertThat(EspiScope.parse("FB=4_29").commodityServiceKinds()).isEmpty(); + } + + @Test + @DisplayName("includesCustomerPii and includesEnergyData reflect the granted FBs") + void domainPredicates() { + assertThat(EspiScope.parse("FB=53_54").includesCustomerPii()).isTrue(); + assertThat(EspiScope.parse("FB=4_5_15").includesCustomerPii()).isFalse(); + assertThat(EspiScope.parse("FB=4_5_15").includesEnergyData()).isTrue(); + assertThat(EspiScope.parse("FB=53_54").includesEnergyData()).isFalse(); + } + + @Test + @DisplayName("unrecognized and deprecated FB ids parse without error and are carried") + void carriesUnknownAndDeprecatedFbs() { + assertThatNoException().isThrownBy(() -> EspiScope.parse("FB=99_36")); + + EspiScope scope = EspiScope.parse("FB=99_36"); + assertThat(scope.functionBlocks()).containsExactly(36, 99); + } + + @Test + @DisplayName("the functionBlocks set is immutable") + void functionBlocksImmutable() { + EspiScope scope = EspiScope.parse("FB=4_5"); + + assertThatExceptionOfType(UnsupportedOperationException.class) + .isThrownBy(() -> scope.functionBlocks().add(99)); + } +} diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/scope/FunctionBlockTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/scope/FunctionBlockTest.java new file mode 100644 index 00000000..fa7fbf1c --- /dev/null +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/scope/FunctionBlockTest.java @@ -0,0 +1,132 @@ +/* + * Copyright 2025 Green Button Alliance, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.greenbuttonalliance.espi.common.scope; + +import org.greenbuttonalliance.espi.common.domain.usage.enums.ServiceKind; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Guard tests that lock the {@link FunctionBlock} catalog to NAESB ESPI 4.0 §REQ.21.4.2.1.3.1. + */ +class FunctionBlockTest { + + @Test + @DisplayName("every FB id is unique and within the spec range 1..70") + void idsAreUniqueAndInRange() { + long distinctIds = Arrays.stream(FunctionBlock.values()).map(FunctionBlock::getId).distinct().count(); + + assertThat(FunctionBlock.values()) + .allSatisfy(fb -> assertThat(fb.getId()).isBetween(1, 70)) + .hasSize((int) distinctIds); + } + + @Test + @DisplayName("deprecated ids never overlap an active FB and never escape the 1..70 range") + void deprecatedIdsAreDisjointFromActive() { + var activeIds = Arrays.stream(FunctionBlock.values()).map(FunctionBlock::getId).toList(); + + assertThat(FunctionBlock.DEPRECATED_IDS) + .doesNotContainAnyElementsOf(activeIds) + .allSatisfy(id -> assertThat(id).isBetween(1, 70)); + } + + @Test + @DisplayName("every commodity FB except temperature (FB 29) maps to a ServiceKind; FB 29 maps to none") + void commodityServiceKindMapping() { + assertThat(FunctionBlock.values()) + .filteredOn(fb -> fb.getCategory() == FunctionBlockCategory.COMMODITY) + .allSatisfy(fb -> { + if (fb == FunctionBlock.FB_29) { + assertThat(fb.getServiceKind()).isEmpty(); + } + else { + assertThat(fb.getServiceKind()).isPresent(); + } + }); + } + + @Test + @DisplayName("no non-commodity FB carries a ServiceKind") + void onlyCommodityFbsCarryServiceKind() { + assertThat(FunctionBlock.values()) + .filteredOn(fb -> fb.getCategory() != FunctionBlockCategory.COMMODITY) + .allSatisfy(fb -> assertThat(fb.getServiceKind()).isEmpty()); + } + + @Test + @DisplayName("commodity FBs map to the expected ServiceKind per the #122 mapping") + void serviceKindOfKnownCommodities() { + assertThat(FunctionBlock.serviceKindOf(5)).contains(ServiceKind.ELECTRICITY); + assertThat(FunctionBlock.serviceKindOf(9)).contains(ServiceKind.ELECTRICITY); + assertThat(FunctionBlock.serviceKindOf(10)).contains(ServiceKind.GAS); + assertThat(FunctionBlock.serviceKindOf(11)).contains(ServiceKind.WATER); + assertThat(FunctionBlock.serviceKindOf(29)).isEmpty(); + assertThat(FunctionBlock.serviceKindOf(15)).isEmpty(); + assertThat(FunctionBlock.serviceKindOf(99)).isEmpty(); + } + + @Test + @DisplayName("categoryOf resolves the load-bearing categories") + void categoryOfLoadBearing() { + assertThat(FunctionBlock.categoryOf(4)).isEqualTo(FunctionBlockCategory.BASE); + assertThat(FunctionBlock.categoryOf(53)).isEqualTo(FunctionBlockCategory.BASE); + assertThat(FunctionBlock.categoryOf(5)).isEqualTo(FunctionBlockCategory.COMMODITY); + assertThat(FunctionBlock.categoryOf(29)).isEqualTo(FunctionBlockCategory.COMMODITY); + assertThat(FunctionBlock.categoryOf(15)).isEqualTo(FunctionBlockCategory.ENERGY_DATA_SHAPE); + assertThat(FunctionBlock.categoryOf(54)).isEqualTo(FunctionBlockCategory.CUSTOMER_PII); + assertThat(FunctionBlock.categoryOf(62)).isEqualTo(FunctionBlockCategory.CUSTOMER_PII); + } + + @ParameterizedTest + @ValueSource(ints = {14, 18, 19, 32, 33, 34, 36, 38, 46, 47, 48, 49, 50, 66}) + @DisplayName("deprecated ids resolve to DEPRECATED, not UNKNOWN") + void deprecatedIdsCategorizeAsDeprecated(int id) { + assertThat(FunctionBlock.categoryOf(id)).isEqualTo(FunctionBlockCategory.DEPRECATED); + assertThat(FunctionBlock.byId(id)).isEmpty(); + } + + @ParameterizedTest + @ValueSource(ints = {0, -1, 20, 26, 42, 43, 45, 71, 99}) + @DisplayName("ids absent from the spec table resolve to UNKNOWN") + void undefinedIdsCategorizeAsUnknown(int id) { + assertThat(FunctionBlock.categoryOf(id)).isEqualTo(FunctionBlockCategory.UNKNOWN); + assertThat(FunctionBlock.byId(id)).isEmpty(); + } + + @Test + @DisplayName("byId returns the catalogued FB for an active id") + void byIdResolvesActive() { + assertThat(FunctionBlock.byId(5)).contains(FunctionBlock.FB_05); + assertThat(FunctionBlock.byId(53)).contains(FunctionBlock.FB_53); + } + + @Test + @DisplayName("SFTP bulk FBs are deprecated; REST bulk FBs are active") + void bulkTransferReflectsSftpDeprecation() { + assertThat(FunctionBlock.categoryOf(34)).isEqualTo(FunctionBlockCategory.DEPRECATED); + assertThat(FunctionBlock.categoryOf(66)).isEqualTo(FunctionBlockCategory.DEPRECATED); + assertThat(FunctionBlock.categoryOf(35)).isEqualTo(FunctionBlockCategory.BULK_TRANSFER); + assertThat(FunctionBlock.categoryOf(67)).isEqualTo(FunctionBlockCategory.BULK_TRANSFER); + } +}