ICU-20941 Port Arbitrary Units support from ICU4C to ICU4J
authorHugo van der Merwe <17109322+hugovdm@users.noreply.github.com>
Mon, 1 Mar 2021 17:47:44 +0000 (17:47 +0000)
committerHugo van der Merwe <17109322+hugovdm@users.noreply.github.com>
Tue, 2 Mar 2021 19:34:19 +0000 (20:34 +0100)
See #1597

icu4c/source/i18n/number_longnames.cpp
icu4c/source/i18n/number_longnames.h
icu4c/source/test/intltest/numbertest_api.cpp
icu4j/main/classes/core/src/com/ibm/icu/impl/PatternProps.java
icu4j/main/classes/core/src/com/ibm/icu/impl/number/LongNameHandler.java
icu4j/main/classes/core/src/com/ibm/icu/impl/number/MixedUnitLongNameHandler.java
icu4j/main/classes/core/src/com/ibm/icu/number/NumberFormatterImpl.java
icu4j/main/classes/core/src/com/ibm/icu/util/MeasureUnit.java
icu4j/main/tests/core/src/com/ibm/icu/dev/test/number/NumberFormatterApiTest.java

index 76d57a1b8674e7c366a231c418a3e0884f5a9fa9..be62a1318d031ce48a0aa13abac3cffb7b51e5c7 100644 (file)
@@ -143,35 +143,32 @@ enum PlaceholderPosition { PH_EMPTY, PH_NONE, PH_BEGINNING, PH_MIDDLE, PH_END };
  *   found.
  * @param joinerChar Iff the placeholder was at the beginning or end, joinerChar
  *   contains the space character (if any) that separated the placeholder from
- *   the rest of the pattern. Otherwise, joinerChar is set to NUL.
+ *   the rest of the pattern. Otherwise, joinerChar is set to NUL. Only one
+ *   space character is considered.
  */
 void extractCorePattern(const UnicodeString &pattern,
                         UnicodeString &coreUnit,
                         PlaceholderPosition &placeholderPosition,
                         UChar &joinerChar) {
     joinerChar = 0;
+    int32_t len = pattern.length();
     if (pattern.startsWith(u"{0}", 3)) {
         placeholderPosition = PH_BEGINNING;
         if (u_isJavaSpaceChar(pattern[3])) {
             joinerChar = pattern[3];
-            coreUnit.setTo(pattern, 4, pattern.length() - 4);
-            // Expecting no double spaces
-            U_ASSERT(!u_isJavaSpaceChar(pattern[4]));
+            coreUnit.setTo(pattern, 4, len - 4);
         } else {
-            coreUnit.setTo(pattern, 3, pattern.length() - 3);
+            coreUnit.setTo(pattern, 3, len - 3);
         }
     } else if (pattern.endsWith(u"{0}", 3)) {
         placeholderPosition = PH_END;
-        int32_t len = pattern.length();
         if (u_isJavaSpaceChar(pattern[len - 4])) {
-            coreUnit.setTo(pattern, 0, pattern.length() - 4);
+            coreUnit.setTo(pattern, 0, len - 4);
             joinerChar = pattern[len - 4];
-            // Expecting no double spaces
-            U_ASSERT(!u_isJavaSpaceChar(pattern[len - 5]));
         } else {
-            coreUnit.setTo(pattern, 0, pattern.length() - 3);
+            coreUnit.setTo(pattern, 0, len - 3);
         }
-    } else if (pattern.indexOf(u"{0}", 0, 1, pattern.length() - 2) == -1) {
+    } else if (pattern.indexOf(u"{0}", 3, 1, len - 2) == -1) {
         placeholderPosition = PH_NONE;
         coreUnit = pattern;
     } else {
@@ -361,7 +358,7 @@ void getInflectedMeasureData(StringPiece subKey,
     key.append(subKey, status);
 
     UErrorCode localStatus = status;
-    ures_getAllItemsWithFallback(unitsBundle.getAlias(), key.data(), sink, status);
+    ures_getAllItemsWithFallback(unitsBundle.getAlias(), key.data(), sink, localStatus);
     if (width == UNUM_UNIT_WIDTH_SHORT) {
         status = localStatus;
         return;
@@ -777,11 +774,6 @@ void LongNameHandler::forMeasureUnit(const Locale &loc,
     //
     // 1. If the unitId is empty or invalid, fail
     // 2. Put the unitId into normalized order
-    //
-    // We just need to check if it is a MeasureUnit this constructor handles:
-    // this constructor does not handle mixed units
-    U_ASSERT(uprv_strcmp(unitRef.getType(), "") != 0 ||
-             unitRef.getComplexity(status) != UMEASURE_UNIT_MIXED);
     U_ASSERT(fillIn != nullptr);
 
     if (uprv_strcmp(unitRef.getType(), "") != 0) {
@@ -812,6 +804,9 @@ void LongNameHandler::forMeasureUnit(const Locale &loc,
         // fillIn->parent = parent;
         // return;
     } else {
+        // Check if it is a MeasureUnit this constructor handles: this
+        // constructor does not handle mixed units
+        U_ASSERT(unitRef.getComplexity(status) != UMEASURE_UNIT_MIXED);
         forArbitraryUnit(loc, unitRef, width, unitDisplayCase, fillIn, status);
         fillIn->rules = rules;
         fillIn->parent = parent;
@@ -1070,7 +1065,7 @@ void LongNameHandler::processPatternTimes(MeasureUnitImpl &&productUnit,
             getInflectedMeasureData(dimensionalityKey.toStringPiece(), loc, width, gender,
                                     singleCaseVariant, dimensionalityPrefixPatterns, status);
             if (U_FAILURE(status)) {
-                // At the time of writing, only power2 and power3 are supported.
+                // At the time of writing, only pow2 and pow3 are supported.
                 // Attempting to format other powers results in a
                 // U_RESOURCE_TYPE_MISMATCH. We convert the error if we
                 // understand it:
@@ -1410,6 +1405,7 @@ void MixedUnitLongNameHandler::forMeasureUnit(const Locale &loc,
                                               const MicroPropsGenerator *parent,
                                               MixedUnitLongNameHandler *fillIn,
                                               UErrorCode &status) {
+    U_ASSERT(mixedUnit.getComplexity(status) == UMEASURE_UNIT_MIXED);
     U_ASSERT(fillIn != nullptr);
     if (U_FAILURE(status)) {
         return;
@@ -1417,6 +1413,7 @@ void MixedUnitLongNameHandler::forMeasureUnit(const Locale &loc,
 
     MeasureUnitImpl temp;
     const MeasureUnitImpl &impl = MeasureUnitImpl::forMeasureUnit(mixedUnit, temp, status);
+    // Defensive, for production code:
     if (impl.complexity != UMEASURE_UNIT_MIXED) {
         // Should be using the normal LongNameHandler
         status = U_UNSUPPORTED_ERROR;
index cc66affd83ec84e34c54c89670ae5134eee92fd6..bca55e010317dc8e89a4ece75f40a32edd7b7e51 100644 (file)
@@ -51,10 +51,8 @@ class LongNameHandler : public MicroPropsGenerator, public ModifierStore, public
      * @param loc The desired locale.
      * @param unitRef The measure unit to construct a LongNameHandler for.
      * @param width Specifies the desired unit rendering.
-     * @param unitDisplayCase Specifies the desired grammatical case. The empty
-     *     string and "nominative" are treated the same. For other cases,
-     *     strings for the requested case are used if found. (For any missing
-     *     case-specific data, we fall back to nominative.)
+     * @param unitDisplayCase Specifies the desired grammatical case. If the
+     *     specified case is not found, we fall back to nominative or no-case.
      * @param rules Does not take ownership.
      * @param parent Does not take ownership.
      * @param fillIn Required.
@@ -149,10 +147,8 @@ class MixedUnitLongNameHandler : public MicroPropsGenerator, public ModifierStor
      * @param mixedUnit The mixed measure unit to construct a
      *     MixedUnitLongNameHandler for.
      * @param width Specifies the desired unit rendering.
-     * @param unitDisplayCase Specifies the desired grammatical case. The empty
-     *     string and "nominative" are treated the same. For other cases,
-     *     strings for the requested case are used if found. (For any missing
-     *     case-specific data, we fall back to nominative.)
+     * @param unitDisplayCase Specifies the desired grammatical case. If the
+     *     specified case is not found, we fall back to nominative or no-case.
      * @param rules Does not take ownership.
      * @param parent Does not take ownership.
      * @param fillIn Required.
index f64c99b4b6a76c88bfcf3ee0e4e4abf3eaf49fc5..20d0374277e78e304ce72a98af2452c210815df6 100644 (file)
@@ -2103,22 +2103,19 @@ void NumberFormatterApiTest::runUnitInflectionsTestCases(UnlocalizedNumberFormat
         };
         UnicodeString skelString = UnicodeString("unit/") + t.unitIdentifier + u" " + skeleton;
         const UChar *skel;
-        const UChar *cSkel;
         if (t.unitDisplayCase == nullptr || t.unitDisplayCase[0] == 0) {
             unf = unf.unit(mu).unitDisplayCase("");
             skel = skelString.getTerminatedBuffer();
-            cSkel = skelString.getTerminatedBuffer();
         } else {
             unf = unf.unit(mu).unitDisplayCase(t.unitDisplayCase);
             // No skeleton support for unitDisplayCase yet.
             skel = nullptr;
-            cSkel = nullptr;
         }
         assertFormatSingle((UnicodeString("Unit: \"") + t.unitIdentifier + ("\", \"") + skeleton +
                             u"\", locale=\"" + t.locale + u"\", case=\"" +
                             (t.unitDisplayCase ? t.unitDisplayCase : "") + u"\", value=" + t.value)
                                .getTerminatedBuffer(),
-                           skel, cSkel, unf, Locale(t.locale), t.value, t.expected);
+                           skel, skel, unf, Locale(t.locale), t.value, t.expected);
         status.assertSuccess();
     }
 }
@@ -2147,7 +2144,7 @@ void NumberFormatterApiTest::unitInflections() {
         // General testing of inflection rules
         unf = NumberFormatter::with().unitWidth(UNUM_UNIT_WIDTH_FULL_NAME);
         skeleton = u"unit-width-full-name";
-        const UnitInflectionTestCase meterCases[] = {
+        const UnitInflectionTestCase testCases[] = {
             // Check up on the basic values that the compound patterns below are
             // derived from:
             {"meter", "de", nullptr, 1, u"1 Meter"},
@@ -2219,13 +2216,13 @@ void NumberFormatterApiTest::unitInflections() {
             {"square-decimeter-square-second", "fr", nullptr, 1, u"1\u00A0décimètre carré-seconde carrée"},
             {"square-decimeter-square-second", "fr", nullptr, 2, u"2\u00A0décimètres carrés-secondes carrées"},
         };
-        runUnitInflectionsTestCases(unf, skeleton, meterCases, UPRV_LENGTHOF(meterCases), status);
+        runUnitInflectionsTestCases(unf, skeleton, testCases, UPRV_LENGTHOF(testCases), status);
     }
     {
         // Testing inflection of mixed units:
         unf = NumberFormatter::with().unitWidth(UNUM_UNIT_WIDTH_FULL_NAME);
         skeleton = u"unit-width-full-name";
-        const UnitInflectionTestCase meterPerDayCases[] = {
+        const UnitInflectionTestCase testCases[] = {
             {"meter", "de", nullptr, 1, u"1 Meter"},
             {"meter", "de", "genitive", 1, u"1 Meters"},
             {"meter", "de", "dative", 2, u"2 Metern"},
@@ -2241,7 +2238,7 @@ void NumberFormatterApiTest::unitInflections() {
             {"meter-and-centimeter", "de", "dative", 1.1, u"1 Meter, 10 Zentimetern"},
             {"meter-and-centimeter", "de", "dative", 2.1, u"2 Metern, 10 Zentimetern"},
         };
-        runUnitInflectionsTestCases(unf, skeleton, meterPerDayCases, UPRV_LENGTHOF(meterPerDayCases),
+        runUnitInflectionsTestCases(unf, skeleton, testCases, UPRV_LENGTHOF(testCases),
                                     status);
     }
     // TODO: add a usage case that selects between preferences with different
index fd142a3a2b418af462fcef4089da71c0ecc3d19d..7d8bdfb7091fa15cd1a9396b10c8456879012491 100644 (file)
@@ -119,6 +119,29 @@ public final class PatternProps {
         return s.substring(start, limit);
     }
 
+    /**
+     * @return s except with leading and trailing SpaceChar characters removed.
+     */
+    public static String trimSpaceChar(String s) {
+        if (s.length() == 0 ||
+            (!Character.isSpaceChar(s.charAt(0)) && !Character.isSpaceChar(s.charAt(s.length() - 1)))) {
+            return s;
+        }
+        int start = 0;
+        int limit = s.length();
+        while (start < limit && Character.isSpaceChar(s.charAt(start))) {
+            ++start;
+        }
+        if (start < limit) {
+            // There is non-SpaceChar at start; we will not move limit below that,
+            // so we need not test start<limit in the loop.
+            while (isWhiteSpace(s.charAt(limit - 1))) {
+                --limit;
+            }
+        }
+        return s.substring(start, limit);
+    }
+
     /**
      * Tests whether the CharSequence contains a "pattern identifier", that is,
      * whether it contains only non-Pattern_White_Space, non-Pattern_Syntax characters.
index d3e6317dcf3731f16e0fb043f2e13cdb5b01b0b7..608fa01351fc06064b85f951864c20e7de6d2ff5 100644 (file)
@@ -2,6 +2,7 @@
 // License & terms of use: http://www.unicode.org/copyright.html
 package com.ibm.icu.impl.number;
 
+import java.util.ArrayList;
 import java.util.EnumMap;
 import java.util.Map;
 import java.util.MissingResourceException;
@@ -9,18 +10,22 @@ import java.util.MissingResourceException;
 import com.ibm.icu.impl.CurrencyData;
 import com.ibm.icu.impl.ICUData;
 import com.ibm.icu.impl.ICUResourceBundle;
+import com.ibm.icu.impl.PatternProps;
 import com.ibm.icu.impl.SimpleFormatterImpl;
 import com.ibm.icu.impl.StandardPlural;
 import com.ibm.icu.impl.UResource;
 import com.ibm.icu.impl.number.Modifier.Signum;
 import com.ibm.icu.impl.units.MeasureUnitImpl;
 import com.ibm.icu.impl.units.SingleUnitImpl;
+import com.ibm.icu.lang.UCharacter;
 import com.ibm.icu.number.NumberFormatter.UnitWidth;
 import com.ibm.icu.text.NumberFormat;
 import com.ibm.icu.text.PluralRules;
 import com.ibm.icu.util.Currency;
 import com.ibm.icu.util.ICUException;
 import com.ibm.icu.util.MeasureUnit;
+import com.ibm.icu.util.MeasureUnit.Complexity;
+import com.ibm.icu.util.MeasureUnit.MeasurePrefix;
 import com.ibm.icu.util.ULocale;
 import com.ibm.icu.util.UResourceBundle;
 
@@ -36,6 +41,7 @@ public class LongNameHandler
     private static final int GENDER_INDEX = StandardPlural.COUNT + i++;
     static final int ARRAY_LENGTH = StandardPlural.COUNT + i++;
 
+    // Returns the array index that corresponds to the given pluralKeyword.
     private static int getIndex(String pluralKeyword) {
         // pluralKeyword can also be "dnam", "per" or "gender"
         if (pluralKeyword.equals("dnam")) {
@@ -61,14 +67,241 @@ public class LongNameHandler
         return result;
     }
 
+    private enum PlaceholderPosition { NONE, BEGINNING, MIDDLE, END }
+
+    private static class ExtractCorePatternResult {
+        String coreUnit;
+        PlaceholderPosition placeholderPosition;
+        char joinerChar;
+    }
+
+    /**
+     * Returns three outputs extracted from pattern.
+     *
+     * @param coreUnit is extracted as per Extract(...) in the spec:
+     *   https://unicode.org/reports/tr35/tr35-general.html#compound-units
+     * @param PlaceholderPosition indicates where in the string the placeholder
+     *   was found.
+     * @param joinerChar Iff the placeholder was at the beginning or end,
+     *   joinerChar contains the space character (if any) that separated the
+     *   placeholder from the rest of the pattern. Otherwise, joinerChar is set
+     *   to NUL. Only one space character is considered.
+     */
+    private static ExtractCorePatternResult extractCorePattern(String pattern) {
+        ExtractCorePatternResult result = new ExtractCorePatternResult();
+        result.joinerChar = 0;
+        int len = pattern.length();
+        if (pattern.startsWith("{0}")) {
+            result.placeholderPosition = PlaceholderPosition.BEGINNING;
+            if (len > 3 && Character.isSpaceChar(pattern.charAt(3))) {
+                result.joinerChar = pattern.charAt(3);
+                result.coreUnit = pattern.substring(4);
+            } else {
+                result.coreUnit = pattern.substring(3);
+            }
+        } else if (pattern.endsWith("{0}")) {
+            result.placeholderPosition = PlaceholderPosition.END;
+            if (Character.isSpaceChar(pattern.charAt(len - 4))) {
+                result.coreUnit = pattern.substring(0, len - 4);
+                result.joinerChar = pattern.charAt(len - 4);
+            } else {
+                result.coreUnit = pattern.substring(0, len - 3);
+            }
+        } else if (pattern.indexOf("{0}", 1) == -1) {
+            result.placeholderPosition = PlaceholderPosition.NONE;
+            result.coreUnit = pattern;
+        } else {
+            result.placeholderPosition = PlaceholderPosition.MIDDLE;
+            result.coreUnit = pattern;
+        }
+        return result;
+    }
+
     //////////////////////////
     /// BEGIN DATA LOADING ///
     //////////////////////////
 
+    // Gets the gender of a built-in unit: unit must be a built-in. Returns an empty
+    // string both in case of unknown gender and in case of unknown unit.
+    private static String getGenderForBuiltin(ULocale locale, MeasureUnit builtinUnit) {
+        ICUResourceBundle unitsBundle = (ICUResourceBundle) UResourceBundle.getBundleInstance(ICUData.ICU_UNIT_BASE_NAME, locale);
+
+        StringBuilder key = new StringBuilder();
+        key.append("units/");
+        key.append(builtinUnit.getType());
+        key.append("/");
+
+        // Map duration-year-person, duration-week-person, etc. to duration-year, duration-week, ...
+        // TODO(ICU-20400): Get duration-*-person data properly with aliases.
+        if (builtinUnit.getSubtype() != null && builtinUnit.getSubtype().endsWith("-person")) {
+            key.append(builtinUnit.getSubtype(), 0, builtinUnit.getSubtype().length() - 7);
+        } else {
+            key.append(builtinUnit.getSubtype());
+        }
+        key.append("/gender");
+
+        try {
+            ICUResourceBundle stackBundle =
+                (ICUResourceBundle)unitsBundle.getWithFallback(key.toString());
+            return stackBundle.getString();
+        } catch (Exception e) {
+            // TODO(icu-units#28): "$unitRes/gender" does not exist. Do we want to
+            // check whether the parent "$unitRes" exists? Then we could return
+            // U_MISSING_RESOURCE_ERROR for incorrect usage (e.g. builtinUnit not
+            // being a builtin).
+            return "";
+        }
+    }
+
+    // Loads data from a resource tree with paths matching
+    // $key/$pluralForm/$gender/$case, with lateral inheritance for missing cases
+    // and genders.
+    //
+    // An InflectedPluralSink is configured to load data for a specific gender and
+    // case. It loads all plural forms, because selection between plural forms is
+    // dependent upon the value being formatted.
+    //
+    // TODO(icu-units#138): Conceptually similar to PluralTableSink, however the
+    // tree structures are different. After homogenizing the structures, we may be
+    // able to unify the two classes.
+    //
+    // TODO: Spec violation: expects presence of "count" - does not fallback to an
+    // absent "count"! If this fallback were added, getCompoundValue could be
+    // superseded?
+    private static final class InflectedPluralSink extends UResource.Sink {
+        // NOTE: outArray MUST have a length of at least ARRAY_LENGTH. No bounds
+        // checking is performed.
+        public InflectedPluralSink(String gender, String caseVariant, String[] outArray) {
+            this.gender = gender;
+            this.caseVariant = caseVariant;
+            this.outArray = outArray;
+            for (int i = 0; i < ARRAY_LENGTH; i++) {
+                outArray[i] = null;
+            }
+        }
+
+        // See ResourceSink::put().
+        @Override
+        public void put(UResource.Key key, UResource.Value value, boolean noFallback) {
+            UResource.Table pluralsTable = value.getTable();
+            for (int i = 0; pluralsTable.getKeyAndValue(i, key, value); ++i) {
+                String keyString = key.toString();
+                int pluralIndex = getIndex(keyString);
+                if (outArray[pluralIndex] != null) {
+                    // We already have a pattern
+                    continue;
+                }
+                UResource.Table genderTable = value.getTable();
+                if (loadForPluralForm(genderTable, value)) {
+                    outArray[pluralIndex] = value.getString();
+                }
+            }
+        }
+
+        // Tries to load data for the configured gender from `genderTable`. The
+        // returned data will be for the configured gender if found, falling
+        // back to "neuter" and no-gender. If none of those are found, null is
+        // returned.
+        private boolean loadForPluralForm(UResource.Table genderTable, UResource.Value value) {
+            if (gender != null && !gender.isEmpty()) {
+                if (loadForGender(genderTable, gender, value)) {
+                    return true;
+                }
+                if (gender != "neuter") {
+                    if (loadForGender(genderTable, "neuter", value)) {
+                        return true;
+                    }
+                }
+            }
+            if (loadForGender(genderTable, "_", value)) {
+                return true;
+            }
+            return false;
+        }
+
+        // Tries to load data for the given gender from `genderTable`. Returns true
+        // if found, returning the data in `value`. The returned data will be for
+        // the configured case if found, falling back to "nominative" and no-case if
+        // not.
+        private boolean
+        loadForGender(UResource.Table genderTable, String genderVal, UResource.Value value) {
+            if (!genderTable.findValue(genderVal, value)) {
+                return false;
+            }
+            UResource.Table caseTable = value.getTable();
+            if (caseVariant != null && !caseVariant.isEmpty()) {
+                if (loadForCase(caseTable, caseVariant, value)) {
+                    return true;
+                }
+                if (caseVariant != "nominative") {
+                    if (loadForCase(caseTable, "nominative", value)) {
+                        return true;
+                    }
+                }
+            }
+            if (loadForCase(caseTable, "_", value)) {
+                return true;
+            }
+            return false;
+        }
+
+        // Tries to load data for the given case from `caseTable`. Returns null
+        // if not found.
+        private boolean loadForCase(UResource.Table caseTable, String caseValue, UResource.Value value) {
+            if (!caseTable.findValue(caseValue, value)) {
+                return false;
+            }
+            return true;
+        }
+
+        String gender;
+        String caseVariant;
+        String[] outArray;
+    }
+
+    static void getInflectedMeasureData(String subKey,
+                                        ULocale locale,
+                                        UnitWidth width,
+                                        String gender,
+                                        String caseVariant,
+                                        String[] outArray) {
+        InflectedPluralSink sink = new InflectedPluralSink(gender, caseVariant, outArray);
+        ICUResourceBundle unitsBundle =
+            (ICUResourceBundle)UResourceBundle.getBundleInstance(ICUData.ICU_UNIT_BASE_NAME, locale);
+
+        StringBuilder key = new StringBuilder();
+        key.append("units");
+        if (width == UnitWidth.NARROW) {
+            key.append("Narrow");
+        } else if (width == UnitWidth.SHORT) {
+            key.append("Short");
+        }
+        key.append("/");
+        key.append(subKey);
+
+        try {
+            unitsBundle.getAllItemsWithFallback(key.toString(), sink);
+            if (width == UnitWidth.SHORT) {
+                return;
+            }
+        } catch (Exception e) {
+            // Continue: fall back to short
+        }
+
+        // TODO(ICU-13353): The ICU4J fallback mechanism works differently:
+        // investigate? Without this code, unit tests do fail:
+        key.setLength(0);
+        key.append("unitsShort/");
+        key.append(subKey);
+        unitsBundle.getAllItemsWithFallback(key.toString(), sink);
+    }
+
     private static final class PluralTableSink extends UResource.Sink {
 
         String[] outArray;
 
+        // NOTE: outArray MUST have at least ARRAY_LENGTH entries. No bounds
+        // checking is performed.
         public PluralTableSink(String[] outArray) {
             this.outArray = outArray;
         }
@@ -138,6 +371,11 @@ public class LongNameHandler
             caseKey.append(unitDisplayCase);
 
             try {
+                // TODO(icu-units#138): our fallback logic is not spec-compliant:
+                // lateral fallback should happen before locale fallback. Switch to
+                // getInflectedMeasureData after homogenizing data format? Find a unit
+                // test case that demonstrates the incorrect fallback logic (via
+                // regional variant of an inflected language?)
                 resource.getAllItemsWithFallback(caseKey.toString(), sink);
                 // TODO(icu-units#138): our fallback logic is not spec-compliant: we
                 // check the given case, then go straight to the no-case data. The spec
@@ -174,7 +412,7 @@ public class LongNameHandler
         }
     }
 
-    private static String getPerUnitFormat(ULocale locale, UnitWidth width) {
+    private static String getCompoundValue(String compoundKey, ULocale locale, UnitWidth width) {
         ICUResourceBundle resource;
         resource = (ICUResourceBundle) UResourceBundle.getBundleInstance(ICUData.ICU_UNIT_BASE_NAME,
                 locale);
@@ -185,15 +423,114 @@ public class LongNameHandler
         } else if (width == UnitWidth.SHORT) {
             key.append("Short");
         }
-        key.append("/compound/per");
+        key.append("/compound/");
+        key.append(compoundKey);
         try {
             return resource.getStringWithFallback(key.toString());
-        } catch (MissingResourceException e) {
-            throw new IllegalArgumentException(
-                    "Could not find x-per-y format for " + locale + ", width " + width);
+        } catch (Exception e) {
+            if (width == UnitWidth.SHORT) {
+                return "";
+            }
+        }
+
+        // TODO(icu-units#28): this code is not exercised by unit tests yet.
+        // Also, what's the fallback mechanism mentioned in ICU-13353?
+        key.setLength(0);
+        key.append("unitsShort/compound/");
+        key.append(compoundKey);
+        try {
+            return resource.getStringWithFallback(key.toString());
+        } catch (Exception e) {
+            return "";
+        }
+    }
+
+    /**
+     * Loads and applies deriveComponent rules from CLDR's
+     * grammaticalFeatures.xml.
+     * <p>
+     * Consider a deriveComponent rule that looks like this:
+     * <pre>
+     *   &lt;deriveComponent feature="case" structure="per" value0="compound" value1="nominative"/&gt;
+     * </pre>
+     * Instantiating an instance as follows:
+     * <pre>
+     *   DerivedComponents d(loc, "case", "per");
+     * </pre>
+     * <p>
+     * Applying the rule in the XML element above, <code>d.value0("foo")</code>
+     * will be "foo", and <code>d.value1("foo")</code> will be "nominative".
+     * <p>
+     * In case of any kind of failure, value0() and value1() will simply return
+     * "".
+     */
+    private static class DerivedComponents {
+        /**
+         * Constructor.
+         */
+        DerivedComponents(ULocale locale, String feature, String structure) {
+            try {
+                ICUResourceBundle derivationsBundle =
+                    (ICUResourceBundle)UResourceBundle.getBundleInstance(ICUData.ICU_BASE_NAME,
+                                                                         "grammaticalFeatures");
+                derivationsBundle = (ICUResourceBundle)derivationsBundle.get("grammaticalData");
+                derivationsBundle = (ICUResourceBundle)derivationsBundle.get("derivations");
+
+                ICUResourceBundle stackBundle;
+                try {
+                    // TODO: use standard normal locale resolution algorithms rather than just grabbing
+                    // language:
+                    stackBundle = (ICUResourceBundle)derivationsBundle.get(locale.getLanguage());
+                } catch (MissingResourceException e) {
+                    stackBundle = (ICUResourceBundle)derivationsBundle.get("root");
+                }
+
+                stackBundle = (ICUResourceBundle)stackBundle.get("component");
+                stackBundle = (ICUResourceBundle)stackBundle.get(feature);
+                stackBundle = (ICUResourceBundle)stackBundle.get(structure);
+
+                String value = stackBundle.getString(0);
+                if (value.compareTo("compound") == 0) {
+                    this.value0 = null;
+                } else {
+                    this.value0 = value;
+                }
+
+                value = stackBundle.getString(1);
+                if (value.compareTo("compound") == 0) {
+                    this.value1 = null;
+                } else {
+                    this.value1 = value;
+                }
+            } catch (Exception e) {
+                // Fall back to uninflected.
+            }
         }
+
+        String value0(String compoundValue) {
+            return (this.value0 != null) ? this.value0 : compoundValue;
+        }
+
+        String value1(String compoundValue) {
+            return (this.value1 != null) ? this.value1 : compoundValue;
+        }
+
+        private String value0 = "", value1 = "";
     }
 
+    // TODO(icu-units#28): test somehow? Associate with an ICU ticket for adding
+    // testsuite support for testing with synthetic data?
+    /**
+     * Loads and returns the value in rules that look like these:
+     *
+     * <deriveCompound feature="gender" structure="per" value="0"/>
+     * <deriveCompound feature="gender" structure="times" value="1"/>
+     *
+     * Currently a fake example, but spec compliant:
+     * <deriveCompound feature="gender" structure="power" value="feminine"/>
+     *
+     * NOTE: If U_FAILURE(status), returns an empty string.
+     */
     private static String getDeriveCompoundRule(ULocale locale, String feature, String structure) {
         ICUResourceBundle derivationsBundle =
                 (ICUResourceBundle) UResourceBundle
@@ -216,6 +553,36 @@ public class LongNameHandler
         return stackBundle.getString(structure);
     }
 
+    // Returns the gender string for structures following these rules:
+    //
+    // <deriveCompound feature="gender" structure="per" value="0"/>
+    // <deriveCompound feature="gender" structure="times" value="1"/>
+    //
+    // Fake example:
+    // <deriveCompound feature="gender" structure="power" value="feminine"/>
+    //
+    // data0 and data1 should be pattern arrays (UnicodeString[ARRAY_SIZE]) that
+    // correspond to value="0" and value="1".
+    //
+    // Pass a null to data1 if the structure has no concept of value="1" (e.g.
+    // "prefix" doesn't).
+    private static String
+    getDerivedGender(ULocale locale, String structure, String[] data0, String[] data1) {
+        String val = getDeriveCompoundRule(locale, "gender", structure);
+        if (val.length() == 1) {
+            switch (val.charAt(0)) {
+            case '0':
+                return data0[GENDER_INDEX];
+            case '1':
+                if (data1 == null) {
+                    return null;
+                }
+                return data1[GENDER_INDEX];
+            }
+        }
+        return val;
+    }
+
     ////////////////////////
     /// END DATA LOADING ///
     ////////////////////////
@@ -253,6 +620,7 @@ public class LongNameHandler
                 StandardPlural.class);
         LongNameHandler result = new LongNameHandler(modifiers, rules, parent);
         result.simpleFormatsToModifiers(simpleFormats, NumberFormat.Field.CURRENCY);
+        // TODO(icu-units#28): currency gender?
         return result;
     }
 
@@ -264,7 +632,8 @@ public class LongNameHandler
      * @param locale The desired locale.
      * @param unit The measure unit to construct a LongNameHandler for.
      * @param width Specifies the desired unit rendering.
-     * @param unitDisplayCase
+     * @param unitDisplayCase Specifies the desired grammatical case. If the
+     *     specified case is not found, we fall back to nominative or no-case.
      * @param rules Plural rules.
      * @param parent Plural rules.
      */
@@ -275,184 +644,486 @@ public class LongNameHandler
             String unitDisplayCase,
             PluralRules rules,
             MicroPropsGenerator parent) {
-        if (unit.getType() == null) {
-            // Not a built-in unit. Split it up, since we can already format
-            // "builtin-per-builtin".
-            // TODO(ICU-20941): support more generic case than builtin-per-builtin.
-            MeasureUnitImpl fullUnit = unit.getCopyOfMeasureUnitImpl();
-            unit = null;
-            MeasureUnit perUnit = null;
-            for (SingleUnitImpl subUnit : fullUnit.getSingleUnits()) {
-                if (subUnit.getDimensionality() > 0) {
-                    if (unit == null) {
-                        unit = subUnit.build();
-                    } else {
-                        unit = unit.product(subUnit.build());
-                    }
+        // From https://unicode.org/reports/tr35/tr35-general.html#compound-units -
+        // Points 1 and 2 are mostly handled by MeasureUnit:
+        //
+        // 1. If the unitId is empty or invalid, fail
+        // 2. Put the unitId into normalized order
+        if (unit.getType() != null) {
+            String[] simpleFormats = new String[ARRAY_LENGTH];
+            getMeasureData(locale, unit, width, unitDisplayCase, simpleFormats);
+            // TODO(ICU4J): Reduce the number of object creations here?
+            Map<StandardPlural, SimpleModifier> modifiers = new EnumMap<>(StandardPlural.class);
+            LongNameHandler result = new LongNameHandler(modifiers, rules, parent);
+            result.simpleFormatsToModifiers(simpleFormats, NumberFormat.Field.MEASURE_UNIT);
+            if (simpleFormats[GENDER_INDEX] != null) {
+                result.gender = simpleFormats[GENDER_INDEX];
+            }
+            return result;
+        } else {
+            assert unit.getComplexity() != Complexity.MIXED
+                : "Mixed units not supported by LongNameHandler: use MixedUnitLongNameHandler";
+            return forArbitraryUnit(locale, unit, width, unitDisplayCase, rules, parent);
+        }
+    }
+
+    private static LongNameHandler forArbitraryUnit(ULocale loc,
+                                                    MeasureUnit unit,
+                                                    UnitWidth width,
+                                                    String unitDisplayCase,
+                                                    PluralRules rules,
+                                                    MicroPropsGenerator parent) {
+        // Numbered list items are from the algorithms at
+        // https://unicode.org/reports/tr35/tr35-general.html#compound-units:
+        //
+        // 4. Divide the unitId into numerator (the part before the "-per-") and
+        //    denominator (the part after the "-per-). If both are empty, fail
+        MeasureUnitImpl fullUnit = unit.getCopyOfMeasureUnitImpl();
+        unit = null;
+        MeasureUnit perUnit = null;
+        // TODO(icu-units#28): lots of inefficiency in the handling of
+        // MeasureUnit/MeasureUnitImpl:
+        for (SingleUnitImpl subUnit : fullUnit.getSingleUnits()) {
+            if (subUnit.getDimensionality() > 0) {
+                if (unit == null) {
+                    unit = subUnit.build();
                 } else {
-                    // It's okay to mutate fullUnit, we made a temporary copy:
-                    subUnit.setDimensionality(subUnit.getDimensionality() * -1);
-                    if (perUnit == null) {
-                        perUnit = subUnit.build();
-                    } else {
-                        perUnit = perUnit.product(subUnit.build());
-                    }
+                    unit = unit.product(subUnit.build());
+                }
+            } else {
+                // It's okay to mutate fullUnit, we made a temporary copy:
+                subUnit.setDimensionality(subUnit.getDimensionality() * -1);
+                if (perUnit == null) {
+                    perUnit = subUnit.build();
+                } else {
+                    perUnit = perUnit.product(subUnit.build());
                 }
             }
-            return forCompoundUnit(locale, unit, perUnit, width, unitDisplayCase, rules, parent);
         }
+        MeasureUnitImpl unitImpl = unit == null ? null : unit.getCopyOfMeasureUnitImpl();
+        MeasureUnitImpl perUnitImpl = perUnit == null ? null : perUnit.getCopyOfMeasureUnitImpl();
 
-        String[] simpleFormats = new String[ARRAY_LENGTH];
-        getMeasureData(locale, unit, width, unitDisplayCase, simpleFormats);
-        // TODO(ICU4J): Reduce the number of object creations here?
+        // TODO(icu-units#28): check placeholder logic, see if it needs to be
+        // present here instead of only in processPatternTimes:
+        //
+        // 5. Set both globalPlaceholder and globalPlaceholderPosition to be empty
+
+        DerivedComponents derivedPerCases = new DerivedComponents(loc, "case", "per");
+
+        // 6. numeratorUnitString
+        String[] numeratorUnitData = new String[ARRAY_LENGTH];
+        processPatternTimes(unitImpl, loc, width, derivedPerCases.value0(unitDisplayCase),
+                            numeratorUnitData);
+
+        // 7. denominatorUnitString
+        String[] denominatorUnitData = new String[ARRAY_LENGTH];
+        processPatternTimes(perUnitImpl, loc, width, derivedPerCases.value1(unitDisplayCase),
+                            denominatorUnitData);
+
+        // TODO(icu-units#139):
+        // - implement DerivedComponents for "plural/times" and "plural/power":
+        //   French has different rules, we'll be producing the wrong results
+        //   currently. (Prove via tests!)
+        // - implement DerivedComponents for "plural/per", "plural/prefix",
+        //   "case/times", "case/power", and "case/prefix" - although they're
+        //   currently hardcoded. Languages with different rules are surely on the
+        //   way.
+        //
+        // Currently we only use "case/per", "plural/times", "case/times", and
+        // "case/power".
+        //
+        // This may have impact on multiSimpleFormatsToModifiers(...) below too?
+        // These rules are currently (ICU 69) all the same and hard-coded below.
+        String perUnitPattern = null;
+        if (denominatorUnitData[PER_INDEX] != null) {
+            // If we have no denominator, we obtain the empty string:
+            perUnitPattern = denominatorUnitData[PER_INDEX];
+        } else {
+            StringBuilder sb = new StringBuilder();
+
+            // 8. Set perPattern to be getValue([per], locale, length)
+            String rawPerUnitFormat = getCompoundValue("per", loc, width);
+            // rawPerUnitFormat is something like "{0} per {1}"; we need to substitute in the secondary
+            // unit.
+            String perPatternFormatter =
+                SimpleFormatterImpl.compileToStringMinMaxArguments(rawPerUnitFormat, sb, 2, 2);
+            // Plural and placeholder handling for 7. denominatorUnitString:
+            // TODO(icu-units#139): hardcoded:
+            // <deriveComponent feature="plural" structure="per" value0="compound" value1="one"/>
+            String rawDenominatorFormat = getWithPlural(denominatorUnitData, StandardPlural.ONE);
+            // Some "one" pattern may not contain "{0}". For example in "ar" or "ne" locale.
+            String denominatorFormatter =
+                SimpleFormatterImpl.compileToStringMinMaxArguments(rawDenominatorFormat, sb, 0, 1);
+            String denominatorString = PatternProps.trimSpaceChar(
+                SimpleFormatterImpl.getTextWithNoArguments(denominatorFormatter));
+
+            // 9. If the denominatorString is empty, set result to
+            //    [numeratorString], otherwise set result to format(perPattern,
+            //    numeratorString, denominatorString)
+            //
+            // TODO(icu-units#28): Why does UnicodeString need to be explicit in the
+            // following line?
+            perUnitPattern =
+                SimpleFormatterImpl.formatCompiledPattern(perPatternFormatter, "{0}", denominatorString);
+        }
         Map<StandardPlural, SimpleModifier> modifiers = new EnumMap<>(
                 StandardPlural.class);
         LongNameHandler result = new LongNameHandler(modifiers, rules, parent);
-        result.simpleFormatsToModifiers(simpleFormats, NumberFormat.Field.MEASURE_UNIT);
-        if (simpleFormats[GENDER_INDEX] != null) {
-            result.gender = simpleFormats[GENDER_INDEX];
+        if (perUnitPattern.length() == 0) {
+            result.simpleFormatsToModifiers(numeratorUnitData, NumberFormat.Field.MEASURE_UNIT);
+        } else {
+            result.multiSimpleFormatsToModifiers(numeratorUnitData, perUnitPattern,
+                                                 NumberFormat.Field.MEASURE_UNIT);
         }
 
+        // Gender
+        //
+        // TODO(icu-units#28): find out what gender to use in the absence of a first
+        // value - e.g. what's the gender of "per-second"? Mentioned in CLDR-14253.
+        //
+        // gender/per deriveCompound rules don't say:
+        // <deriveCompound feature="gender" structure="per" value="0"/> <!-- gender(gram-per-meter) ←
+        // gender(gram) -->
+        result.gender = getDerivedGender(loc, "per", numeratorUnitData, denominatorUnitData);
         return result;
     }
 
     /**
-     * Loads and applies deriveComponent rules from CLDR's grammaticalFeatures.xml.
-     * <pre>
-     * Consider a deriveComponent rule that looks like this:
-     * </pre>
-     * <deriveComponent feature="case" structure="per" value0="compound" value1="nominative"/>
-     * <p>
-     * Instantiating an instance as follows:
-     * <pre>
-     * DerivedComponents d(loc, "case", "per", "foo");
-     * </pre>
-     * <p>
-     * Applying the rule in the XML element above, <code>d.value0()</code> will be "foo", and
-     * <code>d.value1()</code> will be "nominative".
-     * <p>
-     * <p>
-     * In case of any kind of failure, value0() and value1() will simply return "".
+     * Roughly corresponds to patternTimes(...) in the spec:
+     * https://unicode.org/reports/tr35/tr35-general.html#compound-units
      */
-    private static class DerivedComponents {
-        /**
-         * Constructor.
-         */
-        public DerivedComponents(ULocale locale,
-                                 String feature,
-                                 String structure,
-                                 String compoundValue) {
-            ICUResourceBundle derivationsBundle =
-                    (ICUResourceBundle) UResourceBundle
-                            .getBundleInstance(ICUData.ICU_BASE_NAME, "grammaticalFeatures");
-            derivationsBundle = (ICUResourceBundle) derivationsBundle.get("grammaticalData");
-            derivationsBundle = (ICUResourceBundle) derivationsBundle.get("derivations");
-
-            ICUResourceBundle stackBundle;
-            try {
-                // TODO: use standard normal locale resolution algorithms rather than just grabbing language:
-                stackBundle = (ICUResourceBundle) derivationsBundle.get(locale.getLanguage());
-            } catch (MissingResourceException e) {
-                stackBundle = (ICUResourceBundle) derivationsBundle.get("root");
-            }
+    private static void processPatternTimes(MeasureUnitImpl productUnit,
+                                            ULocale loc,
+                                            UnitWidth width,
+                                            String caseVariant,
+                                            String[] outArray) {
+        assert outArray[StandardPlural.OTHER.ordinal()] == null : "outArray must have only null values!";
+        assert outArray[PER_INDEX] == null : "outArray must have only null values!";
 
-            stackBundle = (ICUResourceBundle) stackBundle.get("component");
-            stackBundle = (ICUResourceBundle) stackBundle.get(feature);
-            stackBundle = (ICUResourceBundle) stackBundle.get(structure);
+        if (productUnit == null) {
+            outArray[StandardPlural.OTHER.ordinal()] = "";
+            outArray[PER_INDEX] = "";
+            return;
+        }
+        if (productUnit.getComplexity() == Complexity.MIXED) {
+            // These are handled by MixedUnitLongNameHandler
+            throw new UnsupportedOperationException("Mixed units not supported by LongNameHandler");
+        }
+
+        if (productUnit.getIdentifier() == null) {
+            // TODO(icu-units#28): consider when serialize should be called.
+            // identifier might also be empty for MeasureUnit().
+            productUnit.serialize();
+        }
+        if (productUnit.getIdentifier().length() == 0) {
+            // MeasureUnit(): no units: return empty strings.
+            return;
+        }
+
+        MeasureUnit builtinUnit = MeasureUnit.findBySubType(productUnit.getIdentifier());
+        if (builtinUnit != null) {
+            // TODO(icu-units#145): spec doesn't cover builtin-per-builtin, it
+            // breaks them all down. Do we want to drop this?
+            // - findBySubType isn't super efficient, if we skip it and go to basic
+            //   singles, we don't have to construct MeasureUnit's anymore.
+            // - Check all the existing unit tests that fail without this: is it due
+            //   to incorrect fallback via getMeasureData?
+            // - Do those unit tests cover this code path representatively?
+            getMeasureData(loc, builtinUnit, width, caseVariant, outArray);
+            return;
+        }
+
+        // 2. Set timesPattern to be getValue(times, locale, length)
+        String timesPattern = getCompoundValue("times", loc, width);
+        StringBuilder sb = new StringBuilder();
+        String timesPatternFormatter = SimpleFormatterImpl.compileToStringMinMaxArguments(timesPattern, sb, 2, 2);
 
-            String value = stackBundle.getString(0);
-            if (value.compareTo("compound") == 0) {
-                this.value0 = compoundValue;
+        PlaceholderPosition[] globalPlaceholder = new PlaceholderPosition[ARRAY_LENGTH];
+        char globalJoinerChar = 0;
+        // Numbered list items are from the algorithms at
+        // https://unicode.org/reports/tr35/tr35-general.html#compound-units:
+        //
+        // pattern(...) point 5:
+        // - Set both globalPlaceholder and globalPlaceholderPosition to be empty
+        //
+        // 3. Set result to be empty
+        for (StandardPlural plural : StandardPlural.values()) {
+            int pluralIndex = plural.ordinal();
+            // Initial state: empty string pattern, via all falling back to OTHER:
+            if (plural == StandardPlural.OTHER) {
+                outArray[pluralIndex] = "";
             } else {
-                this.value0 = value;
+                outArray[pluralIndex] = null;
             }
+            globalPlaceholder[pluralIndex] = null;
+        }
+
+        // null represents "compound" (propagate the plural form).
+        String pluralCategory = null;
+        DerivedComponents derivedTimesPlurals = new DerivedComponents(loc, "plural", "times");
+        DerivedComponents derivedTimesCases = new DerivedComponents(loc, "case", "times");
+        DerivedComponents derivedPowerCases = new DerivedComponents(loc, "case", "power");
 
-            value = stackBundle.getString(1);
-            if (value.compareTo("compound") == 0) {
-                this.value1 = compoundValue;
+        // 4. For each single_unit in product_unit
+        ArrayList<SingleUnitImpl> singleUnits = productUnit.getSingleUnits();
+        for (int singleUnitIndex = 0; singleUnitIndex < singleUnits.size(); singleUnitIndex++) {
+            SingleUnitImpl singleUnit = singleUnits.get(singleUnitIndex);
+            String singlePluralCategory;
+            String singleCaseVariant;
+            // TODO(icu-units#28): ensure we have unit tests that change/fail if we
+            // assign incorrect case variants here:
+            if (singleUnitIndex < singleUnits.size() - 1) {
+                // 4.1. If hasMultiple
+                singlePluralCategory = derivedTimesPlurals.value0(pluralCategory);
+                singleCaseVariant = derivedTimesCases.value0(caseVariant);
+                pluralCategory = derivedTimesPlurals.value1(pluralCategory);
+                caseVariant = derivedTimesCases.value1(caseVariant);
             } else {
-                this.value1 = value;
+                singlePluralCategory = derivedTimesPlurals.value1(pluralCategory);
+                singleCaseVariant = derivedTimesCases.value1(caseVariant);
             }
-        }
 
-        public final String value0, value1;
-    }
+            // 4.2. Get the gender of that single_unit
+            builtinUnit = MeasureUnit.findBySubType(singleUnit.getSimpleUnitID());
+            if (builtinUnit == null) {
+                // Ideally all simple units should be known, but they're not:
+                // 100-kilometer is internally treated as a simple unit, but it is
+                // not a built-in unit and does not have formatting data in CLDR 39.
+                //
+                // TODO(icu-units#28): test (desirable) invariants in unit tests.
+                throw new UnsupportedOperationException("Unsupported sinlgeUnit: " +
+                                                        singleUnit.getSimpleUnitID());
+            }
+            String gender = getGenderForBuiltin(loc, builtinUnit);
 
-    private static LongNameHandler forCompoundUnit(
-            ULocale locale,
-            MeasureUnit unit,
-            MeasureUnit perUnit,
-            UnitWidth width,
-            String unitDisplayCase,
-            PluralRules rules,
-            MicroPropsGenerator parent) {
-        if (unit.getType() == null || perUnit.getType() == null) {
-            // TODO(ICU-20941): Unsanctioned unit. Not yet fully supported. Set an
-            // error code.
-            throw new UnsupportedOperationException(
-                "Unsanctioned units, not yet supported: " + unit.getIdentifier() + "/" +
-                perUnit.getIdentifier());
-        }
+            // 4.3. If singleUnit starts with a dimensionality_prefix, such as 'square-'
+            assert singleUnit.getDimensionality() > 0;
+            int dimensionality = singleUnit.getDimensionality();
+            String[] dimensionalityPrefixPatterns = new String[ARRAY_LENGTH];
+            if (dimensionality != 1) {
+                // 4.3.1. set dimensionalityPrefixPattern to be
+                //   getValue(that dimensionality_prefix, locale, length, singlePluralCategory,
+                //   singleCaseVariant, gender), such as "{0} kwadratowym"
+                StringBuilder dimensionalityKey = new StringBuilder("compound/power");
+                dimensionalityKey.append(dimensionality);
+                try {
+                    getInflectedMeasureData(dimensionalityKey.toString(), loc, width, gender,
+                                            singleCaseVariant, dimensionalityPrefixPatterns);
+                } catch (MissingResourceException e) {
+                    // At the time of writing, only pow2 and pow3 are supported.
+                    // Attempting to format other powers results in a
+                    // U_RESOURCE_TYPE_MISMATCH. We convert the error if we
+                    // understand it:
+                    if (dimensionality > 3) {
+                        throw new UnsupportedOperationException("powerN not supported for N > 3: " +
+                                                                productUnit.getIdentifier());
+                    } else {
+                        throw e;
+                    }
+                }
 
-         DerivedComponents derivedPerCases = new DerivedComponents(locale, "case", "per", unitDisplayCase);
+                // TODO(icu-units#139):
+                // 4.3.2. set singlePluralCategory to be power0(singlePluralCategory)
 
+                // 4.3.3. set singleCaseVariant to be power0(singleCaseVariant)
+                singleCaseVariant = derivedPowerCases.value0(singleCaseVariant);
+                // 4.3.4. remove the dimensionality_prefix from singleUnit
+                singleUnit.setDimensionality(1);
+            }
 
-        String[] primaryData = new String[ARRAY_LENGTH];
-        getMeasureData(locale, unit, width, derivedPerCases.value0, primaryData);
-        String[] secondaryData = new String[ARRAY_LENGTH];
-        getMeasureData(locale, perUnit, width, derivedPerCases.value1, secondaryData);
+            // 4.4. if singleUnit starts with an si_prefix, such as 'centi'
+            MeasurePrefix prefix = singleUnit.getPrefix();
+            String prefixPattern = "";
+            if (prefix != MeasurePrefix.ONE) {
+                // 4.4.1. set siPrefixPattern to be getValue(that si_prefix, locale,
+                //        length), such as "centy{0}"
+                StringBuilder prefixKey = new StringBuilder();
+                // prefixKey looks like "1024p3" or "10p-2":
+                prefixKey.append(prefix.getBase());
+                prefixKey.append('p');
+                prefixKey.append(prefix.getPower());
+                // Contains a pattern like "centy{0}".
+                prefixPattern = getCompoundValue(prefixKey.toString(), loc, width);
 
-        // TODO(icu-units#139): implement these rules:
-        //    - <deriveComponent feature="plural" structure="per" ...>
-        //    - This has impact on multiSimpleFormatsToModifiers(...) below too.
-        //
-        // These rules are currently (ICU 69) all the same and hard-coded below.
-        String perUnitFormat;
-        if (secondaryData[PER_INDEX] != null) {
-            perUnitFormat = secondaryData[PER_INDEX];
-        } else {
-            String rawPerUnitFormat = getPerUnitFormat(locale, width);
-            // rawPerUnitFormat is something like "{0}/{1}"; we need to substitute in the secondary unit.
-            // TODO: Lots of thrashing. Improve?
-            StringBuilder sb = new StringBuilder();
-            String compiled = SimpleFormatterImpl
-                    .compileToStringMinMaxArguments(rawPerUnitFormat, sb, 2, 2);
-            String secondaryFormat = getWithPlural(secondaryData, StandardPlural.ONE);
+                // 4.4.2. set singlePluralCategory to be prefix0(singlePluralCategory)
+                //
+                // TODO(icu-units#139): that refers to these rules:
+                // <deriveComponent feature="plural" structure="prefix" value0="one" value1="compound"/>
+                // though I'm not sure what other value they might end up having.
+                //
+                // 4.4.3. set singleCaseVariant to be prefix0(singleCaseVariant)
+                //
+                // TODO(icu-units#139): that refers to:
+                // <deriveComponent feature="case" structure="prefix" value0="nominative"
+                // value1="compound"/> but the prefix (value0) doesn't have case, the rest simply
+                // propagates.
 
-            // Some "one" pattern may not contain "{0}". For example in "ar" or "ne" locale.
-            String secondaryCompiled = SimpleFormatterImpl
-                    .compileToStringMinMaxArguments(secondaryFormat, sb, 0, 1);
-            String secondaryFormatString = SimpleFormatterImpl.getTextWithNoArguments(secondaryCompiled);
+                // 4.4.4. remove the si_prefix from singleUnit
+                singleUnit.setPrefix(MeasurePrefix.ONE);
+            }
 
-            // TODO(icu-units#28): do not use regular expression
-            String secondaryString = secondaryFormatString.replaceAll("(^\\h*)|(\\h*$)",""); // Trim all spaces.
+            // 4.5. Set corePattern to be the getValue(singleUnit, locale, length,
+            //      singlePluralCategory, singleCaseVariant), such as "{0} metrem"
+            String[] singleUnitArray = new String[ARRAY_LENGTH];
+            // At this point we are left with a Simple Unit:
+            assert singleUnit.build().getIdentifier().equals(singleUnit.getSimpleUnitID())
+                : "Should be equal: singleUnit.build().getIdentifier() produced " +
+                singleUnit.build().getIdentifier() + ", singleUnit.getSimpleUnitID() produced " +
+                singleUnit.getSimpleUnitID();
+            getMeasureData(loc, singleUnit.build(), width, singleCaseVariant, singleUnitArray);
 
-            perUnitFormat = SimpleFormatterImpl.formatCompiledPattern(compiled, "{0}", secondaryString);
-        }
-        Map<StandardPlural, SimpleModifier> modifiers = new EnumMap<>(
-                StandardPlural.class);
-        LongNameHandler result = new LongNameHandler(modifiers, rules, parent);
-        result.multiSimpleFormatsToModifiers(primaryData, perUnitFormat, NumberFormat.Field.MEASURE_UNIT);
+            // Calculate output gender
+            if (singleUnitArray[GENDER_INDEX] != null) {
+                assert !singleUnitArray[GENDER_INDEX].isEmpty();
 
-        // Gender
-        String val = getDeriveCompoundRule(locale, "gender", "per");
+                if (prefix != MeasurePrefix.ONE) {
+                    singleUnitArray[GENDER_INDEX] =
+                        getDerivedGender(loc, "prefix", singleUnitArray, null);
+                }
 
-        assert (val != null && val.length() == 1);
-        switch (val.charAt(0)) {
-        case '0':
-            result.gender = primaryData[GENDER_INDEX];
-            break;
-        case '1':
-            result.gender = secondaryData[GENDER_INDEX];
-            break;
-        default:
-            // Data error. Assert-fail in debug mode, else return no gender.
-            assert false;
-        }
+                // Powers use compoundUnitPattern1, dimensionalityPrefixPatterns may
+                // have a "gender" element
+                //
+                // TODO(icu-units#28): untested: no locale data uses this currently:
+                if (dimensionality != 1) {
+                    singleUnitArray[GENDER_INDEX] =
+                        getDerivedGender(loc, "power", singleUnitArray, dimensionalityPrefixPatterns);
+                }
 
-        return result;
+                String timesGenderRule = getDeriveCompoundRule(loc, "gender", "times");
+                if (timesGenderRule.length() == 1) {
+                    switch (timesGenderRule.charAt(0)) {
+                    case '0':
+                        if (singleUnitIndex == 0) {
+                            assert outArray[GENDER_INDEX] == null;
+                            outArray[GENDER_INDEX] = singleUnitArray[GENDER_INDEX];
+                        }
+                        break;
+                    case '1':
+                        if (singleUnitIndex == singleUnits.size() - 1) {
+                            assert outArray[GENDER_INDEX] == null;
+                            outArray[GENDER_INDEX] = singleUnitArray[GENDER_INDEX];
+                        }
+                    }
+                } else {
+                    if (outArray[GENDER_INDEX] == null) {
+                        outArray[GENDER_INDEX] = timesGenderRule;
+                    }
+                }
+            }
+
+            // Calculate resulting patterns for each plural form
+            for (StandardPlural plural_ : StandardPlural.values()) {
+                StandardPlural plural = plural_;
+                int pluralIndex = plural.ordinal();
+
+                // singleUnitArray[pluralIndex] looks something like "{0} Meter"
+                if (outArray[pluralIndex] == null) {
+                    if (singleUnitArray[pluralIndex] == null) {
+                        // Let the usual plural fallback mechanism take care of this
+                        // plural form
+                        continue;
+                    } else {
+                        // Since our singleUnit can have a plural form that outArray
+                        // doesn't yet have (relying on fallback to OTHER), we start
+                        // by grabbing it with the normal plural fallback mechanism
+                        outArray[pluralIndex] = getWithPlural(outArray, plural);
+                    }
+                }
+
+                if (singlePluralCategory != null) {
+                    plural = StandardPlural.fromString(singlePluralCategory);
+                }
+
+                // 4.6. Extract(corePattern, coreUnit, placeholder, placeholderPosition) from that
+                // pattern.
+                ExtractCorePatternResult r = extractCorePattern(getWithPlural(singleUnitArray, plural));
+
+                // 4.7 If the position is middle, then fail
+                if (r.placeholderPosition == PlaceholderPosition.MIDDLE) {
+                    throw new UnsupportedOperationException();
+                }
+
+                // 4.8. If globalPlaceholder is empty
+                if (globalPlaceholder[pluralIndex] == null) {
+                    globalPlaceholder[pluralIndex] = r.placeholderPosition;
+                    globalJoinerChar = r.joinerChar;
+                } else {
+                    // Expect all units involved to have the same placeholder position
+                    assert globalPlaceholder[pluralIndex] == r.placeholderPosition;
+                    // TODO(icu-units#28): Do we want to add a unit test that checks
+                    // for consistent joiner chars? Probably not, given how
+                    // inconsistent they are. File a CLDR ticket with examples?
+                }
+                // Now coreUnit would be just "Meter"
+
+                // 4.9. If siPrefixPattern is not empty
+                if (prefix != MeasurePrefix.ONE) {
+                    String prefixCompiled =
+                        SimpleFormatterImpl.compileToStringMinMaxArguments(prefixPattern, sb, 1, 1);
+
+                    // 4.9.1. Set coreUnit to be the combineLowercasing(locale, length, siPrefixPattern,
+                    //        coreUnit)
+                    // combineLowercasing(locale, length, prefixPattern, coreUnit)
+                    //
+                    // TODO(icu-units#28): run this only if prefixPattern does not
+                    // contain space characters - do languages "as", "bn", "hi",
+                    // "kk", etc have concepts of upper and lower case?:
+                    if (width == UnitWidth.FULL_NAME) {
+                        r.coreUnit = UCharacter.toLowerCase(loc, r.coreUnit);
+                    }
+                    r.coreUnit = SimpleFormatterImpl.formatCompiledPattern(prefixCompiled, r.coreUnit);
+                }
+
+                // 4.10. If dimensionalityPrefixPattern is not empty
+                if (dimensionality != 1) {
+                    String dimensionalityCompiled = SimpleFormatterImpl.compileToStringMinMaxArguments(
+                        getWithPlural(dimensionalityPrefixPatterns, plural), sb, 1, 1);
+
+                    // 4.10.1. Set coreUnit to be the combineLowercasing(locale, length,
+                    //         dimensionalityPrefixPattern, coreUnit)
+                    // combineLowercasing(locale, length, prefixPattern, coreUnit)
+                    //
+                    // TODO(icu-units#28): run this only if prefixPattern does not
+                    // contain space characters - do languages "as", "bn", "hi",
+                    // "kk", etc have concepts of upper and lower case?:
+                    if (width == UnitWidth.FULL_NAME) {
+                        r.coreUnit = UCharacter.toLowerCase(loc, r.coreUnit);
+                    }
+                    r.coreUnit =
+                        SimpleFormatterImpl.formatCompiledPattern(dimensionalityCompiled, r.coreUnit);
+                }
+
+                if (outArray[pluralIndex].length() == 0) {
+                    // 4.11. If the result is empty, set result to be coreUnit
+                    outArray[pluralIndex] = r.coreUnit;
+                } else {
+                    // 4.12. Otherwise set result to be format(timesPattern, result, coreUnit)
+                    outArray[pluralIndex] = SimpleFormatterImpl.formatCompiledPattern(
+                        timesPatternFormatter, outArray[pluralIndex], r.coreUnit);
+                }
+            }
+        }
+        for (StandardPlural plural : StandardPlural.values()) {
+            int pluralIndex = plural.ordinal();
+            if (globalPlaceholder[pluralIndex] == PlaceholderPosition.BEGINNING) {
+                StringBuilder tmp = new StringBuilder();
+                tmp.append("{0}");
+                if (globalJoinerChar != 0) {
+                    tmp.append(globalJoinerChar);
+                }
+                tmp.append(outArray[pluralIndex]);
+                outArray[pluralIndex] = tmp.toString();
+            } else if (globalPlaceholder[pluralIndex] == PlaceholderPosition.END) {
+                if (globalJoinerChar != 0) {
+                    outArray[pluralIndex] = outArray[pluralIndex] + globalJoinerChar;
+                }
+                outArray[pluralIndex] = outArray[pluralIndex] + "{0}";
+            }
+        }
     }
 
+    /** Sets modifiers to use the patterns from simpleFormats. */
     private void simpleFormatsToModifiers(
             String[] simpleFormats,
             NumberFormat.Field field) {
@@ -468,6 +1139,14 @@ public class LongNameHandler
         }
     }
 
+    /**
+     * Sets modifiers to a combination of `leadFormats` (one per plural form)
+     * and `trailFormat` appended to each.
+     *
+     * With a leadFormat of "{0}m" and a trailFormat of "{0}/s", it produces a
+     * pattern of "{0}m/s" by inserting each leadFormat pattern into
+     * trailFormat.
+     */
     private void multiSimpleFormatsToModifiers(
             String[] leadFormats,
             String trailFormat,
@@ -476,9 +1155,14 @@ public class LongNameHandler
         String trailCompiled = SimpleFormatterImpl.compileToStringMinMaxArguments(trailFormat, sb, 1, 1);
         for (StandardPlural plural : StandardPlural.VALUES) {
             String leadFormat = getWithPlural(leadFormats, plural);
-            String compoundFormat = SimpleFormatterImpl.formatCompiledPattern(trailCompiled, leadFormat);
-            String compoundCompiled = SimpleFormatterImpl
-                    .compileToStringMinMaxArguments(compoundFormat, sb, 0, 1);
+            String compoundFormat;
+            if (leadFormat.length() == 0) {
+                compoundFormat = trailFormat;
+            } else {
+                compoundFormat = SimpleFormatterImpl.formatCompiledPattern(trailCompiled, leadFormat);
+            }
+            String compoundCompiled =
+                SimpleFormatterImpl.compileToStringMinMaxArguments(compoundFormat, sb, 0, 1);
             Modifier.Parameters parameters = new Modifier.Parameters();
             parameters.obj = this;
             parameters.signum = null; // Signum ignored
index 8b9f159e25a623ed1e0f9820f1ca3655e440d9e3..dcce2a204d347c18ec5cb1aa1dcaaf20164853bd 100644 (file)
@@ -51,17 +51,24 @@ public class MixedUnitLongNameHandler
      * @param mixedUnit The mixed measure unit to construct a
      *                  MixedUnitLongNameHandler for.
      * @param width     Specifies the desired unit rendering.
-     * @param unitDisplayName
+     * @param unitDisplayCase Specifies the desired grammatical case. If the
+     *     specified case is not found, we fall back to nominative or no-case.
      * @param rules     PluralRules instance.
      * @param parent    MicroPropsGenerator instance.
      */
     public static MixedUnitLongNameHandler forMeasureUnit(ULocale locale,
                                                           MeasureUnit mixedUnit,
                                                           NumberFormatter.UnitWidth width,
-                                                          String unitDisplayName,
+                                                          String unitDisplayCase,
                                                           PluralRules rules,
                                                           MicroPropsGenerator parent) {
-        assert (mixedUnit.getComplexity() == MeasureUnit.Complexity.MIXED);
+        assert mixedUnit.getComplexity() == MeasureUnit.Complexity.MIXED
+            : "MixedUnitLongNameHandler only supports MIXED units";
+        // In ICU4C, in addition to an assert, we return a failure status if the
+        // unit is not mixed (commented by: "Defensive, for production code").
+        // In Java, we don't have efficient access to MeasureUnitImpl, so we
+        // skip this check - relying on unit tests and the assert above to help
+        // enforce the invariant.
 
         MixedUnitLongNameHandler result = new MixedUnitLongNameHandler(rules, parent);
         List<MeasureUnit> individualUnits = mixedUnit.splitToSingleUnits();
@@ -70,7 +77,8 @@ public class MixedUnitLongNameHandler
         for (int i = 0; i < individualUnits.size(); i++) {
             // Grab data for each of the components.
             String[] unitData = new String[LongNameHandler.ARRAY_LENGTH];
-            LongNameHandler.getMeasureData(locale, individualUnits.get(i), width, unitDisplayName, unitData);
+            LongNameHandler.getMeasureData(locale, individualUnits.get(i), width, unitDisplayCase,
+                                           unitData);
             result.fMixedUnitData.add(unitData);
         }
 
@@ -131,7 +139,7 @@ public class MixedUnitLongNameHandler
      */
     @Override
     public Modifier getModifier(Modifier.Signum signum, StandardPlural plural) {
-        // TODO(units): investigate this method while investigating where
+        // TODO(icu-units#28): investigate this method while investigating where
         // LongNameHandler.getModifier() gets used. To be sure it remains
         // unreachable:
         assert false : "should be unreachable";
index e7fa6ca7d29d533daa7fc227de3c9858e3425476..c7df2c6999fe8431eba88915f58636048f654915 100644 (file)
@@ -412,6 +412,14 @@ class NumberFormatterImpl {
                 MeasureUnit unit = macros.unit;
                 if (macros.perUnit != null) {
                     unit = unit.product(macros.perUnit.reciprocal());
+                    // This isn't strictly necessary, but was what we specced
+                    // out when perUnit became a backward-compatibility thing:
+                    // unit/perUnit use case is only valid if both units are
+                    // built-ins, or the product is a built-in.
+                    if (unit.getType() == null && (macros.unit.getType() == null || macros.perUnit.getType() == null)) {
+                        throw new UnsupportedOperationException(
+                            "perUnit() can only be used if unit and perUnit are both built-ins, or the combination is a built-in");
+                    }
                 }
                 chain = LongNameHandler.forMeasureUnit(
                         macros.loc,
index 2bce39847a948a3297c7d5f74137a0277d726406..b4364f22beab63a023f503d769556312a26840ff 100644 (file)
@@ -773,7 +773,12 @@ public class MeasureUnit implements Serializable {
         return MeasureUnit.addUnit(type, subType, factory);
     }
 
-    private static MeasureUnit findBySubType(String subType) {
+    /**
+     * @internal
+     * @deprecated This API is ICU internal only.
+     */
+    @Deprecated
+    public static MeasureUnit findBySubType(String subType) {
         populateCache();
         for (Map<String, MeasureUnit> unitsForType : cache.values()) {
             if (unitsForType.containsKey(subType)) {
index d2414dfc843b1f3bbf5ee90f0f0a7afb0fb31ab6..2eb887ccd2e24b20c1f92d1b5f51710de5f441af 100644 (file)
@@ -537,22 +537,21 @@ public class NumberFormatterApiTest extends TestFmwk {
                 "0.0088 meters",
                 "0 meters");
 
-        // // TODO(ICU-20941): Support formatting for not-built-in units
-        // assertFormatDescending(
-        //         "Hectometers",
-        //         "measure-unit/length-hectometer",
-        //         "unit/hectometer",
-        //         NumberFormatter.with().unit(MeasureUnit.forIdentifier("hectometer")),
-        //         ULocale.ENGLISH,
-        //         "87,650 hm",
-        //         "8,765 ham",
-        //         "876.5 hm",
-        //         "87.65 hm",
-        //         "8.765 hm",
-        //         "0.8765 hm",
-        //         "0.08765 hm",
-        //         "0.008765 hm",
-        //         "0 hm");
+        assertFormatDescending(
+                "Hectometers",
+                "unit/hectometer",
+                "unit/hectometer",
+                NumberFormatter.with().unit(MeasureUnit.forIdentifier("hectometer")),
+                ULocale.ENGLISH,
+                "87,650 hm",
+                "8,765 hm",
+                "876.5 hm",
+                "87.65 hm",
+                "8.765 hm",
+                "0.8765 hm",
+                "0.08765 hm",
+                "0.008765 hm",
+                "0 hm");
 
         assertFormatSingleMeasure(
                 "Meters with Measure Input",
@@ -674,16 +673,15 @@ public class NumberFormatterApiTest extends TestFmwk {
                 5,
                 "5 a\u00F1os");
 
-        // TODO(ICU-20941): arbitrary unit formatting
-        // assertFormatSingle(
-        //         "Hubble Constant",
-        //         "unit/kilometer-per-megaparsec-second",
-        //         "unit/kilometer-per-megaparsec-second",
-        //         NumberFormatter.with()
-        //                 .unit(MeasureUnit.forIdentifier("kilometer-per-megaparsec-second")),
-        //         new ULocale("en"),
-        //         74, // Approximate 2019-03-18 measurement
-        //         "74 km/s.Mpc");
+        assertFormatSingle(
+                "Hubble Constant",
+                "unit/kilometer-per-megaparsec-second",
+                "unit/kilometer-per-megaparsec-second",
+                NumberFormatter.with()
+                        .unit(MeasureUnit.forIdentifier("kilometer-per-megaparsec-second")),
+                new ULocale("en"),
+                74, // Approximate 2019-03-18 measurement
+                "74 km/Mpc⋅sec");
 
         assertFormatSingle(
                 "Mixed unit",
@@ -1023,7 +1021,8 @@ public class NumberFormatterApiTest extends TestFmwk {
 
         try {
             nf.format(2.4d);
-            fail("Expected failure, got: " + nf.format(2.4d) + ".");
+            fail("Expected failure for unit/furlong-pascal per-unit/length-meter, got: " +
+                 nf.format(2.4d) + ".");
         } catch (UnsupportedOperationException e) {
             // Pass
         }
@@ -1054,6 +1053,124 @@ public class NumberFormatterApiTest extends TestFmwk {
                 "2.4 m/s\u00B2");
     }
 
+    @Test
+    public void unitArbitraryMeasureUnits() {
+        // TODO: fix after data bug is resolved? See CLDR-14510.
+        //     assertFormatSingle(
+        //             "Binary unit prefix: kibibyte",
+        //             "unit/kibibyte",
+        //             "unit/kibibyte",
+        //             NumberFormatter.with().unit(MeasureUnit.forIdentifier("kibibyte")),
+        //             new ULocale("en-GB"),
+        //             2.4,
+        //             "2.4 KiB");
+
+        assertFormatSingle("Binary unit prefix: kibibyte full-name",
+                           "unit/kibibyte unit-width-full-name", "unit/kibibyte unit-width-full-name",
+                           NumberFormatter.with()
+                               .unit(MeasureUnit.forIdentifier("kibibyte"))
+                               .unitWidth(UnitWidth.FULL_NAME),
+                           new ULocale("en-GB"), 2.4, "2.4 kibibytes");
+
+        assertFormatSingle("Binary unit prefix: kibibyte full-name",
+                           "unit/kibibyte unit-width-full-name", "unit/kibibyte unit-width-full-name",
+                           NumberFormatter.with()
+                               .unit(MeasureUnit.forIdentifier("kibibyte"))
+                               .unitWidth(UnitWidth.FULL_NAME),
+                           new ULocale("de"), 2.4, "2,4 Kibibyte");
+
+        assertFormatSingle("Binary prefix for non-digital units: kibimeter", "unit/kibimeter",
+                           "unit/kibimeter",
+                           NumberFormatter.with().unit(MeasureUnit.forIdentifier("kibimeter")),
+                           new ULocale("en-GB"), 2.4, "2.4 Kim");
+
+        assertFormatSingle("SI prefix falling back to root: microohm", "unit/microohm", "unit/microohm",
+                           NumberFormatter.with().unit(MeasureUnit.forIdentifier("microohm")),
+                           new ULocale("de-CH"), 2.4, "2.4 μΩ");
+
+        assertFormatSingle("de-CH fallback to de: microohm unit-width-full-name",
+                           "unit/microohm unit-width-full-name", "unit/microohm unit-width-full-name",
+                           NumberFormatter.with()
+                               .unit(MeasureUnit.forIdentifier("microohm"))
+                               .unitWidth(UnitWidth.FULL_NAME),
+                           new ULocale("de-CH"), 2.4, "2.4\u00A0Mikroohm");
+
+        assertFormatSingle("No prefixes, 'times' pattern: joule-furlong", "unit/joule-furlong",
+                           "unit/joule-furlong",
+                           NumberFormatter.with().unit(MeasureUnit.forIdentifier("joule-furlong")),
+                           new ULocale("en"), 2.4, "2.4 J⋅fur");
+
+        assertFormatSingle("No numeratorUnitString: per-second", "unit/per-second", "unit/per-second",
+                           NumberFormatter.with().unit(MeasureUnit.forIdentifier("per-second")),
+                           new ULocale("de-CH"), 2.4, "2.4/s");
+
+        assertFormatSingle("No numeratorUnitString: per-second unit-width-full-name",
+                           "unit/per-second unit-width-full-name",
+                           "unit/per-second unit-width-full-name",
+                           NumberFormatter.with()
+                               .unit(MeasureUnit.forIdentifier("per-second"))
+                               .unitWidth(UnitWidth.FULL_NAME),
+                           new ULocale("de-CH"), 2.4, "2.4 pro Sekunde");
+
+        assertFormatSingle(
+            "Prefix in the denominator: nanogram-per-picobarrel", "unit/nanogram-per-picobarrel",
+            "unit/nanogram-per-picobarrel",
+            NumberFormatter.with().unit(MeasureUnit.forIdentifier("nanogram-per-picobarrel")),
+            new ULocale("en-ZA"), 2.4, "2,4 ng/pbbl");
+
+        assertFormatSingle("Prefix in the denominator: nanogram-per-picobarrel unit-width-full-name",
+                           "unit/nanogram-per-picobarrel unit-width-full-name",
+                           "unit/nanogram-per-picobarrel unit-width-full-name",
+                           NumberFormatter.with()
+                               .unit(MeasureUnit.forIdentifier("nanogram-per-picobarrel"))
+                               .unitWidth(UnitWidth.FULL_NAME),
+                           new ULocale("en-ZA"), 2.4, "2,4 nanograms per picobarrel");
+
+        // Valid MeasureUnit, but unformattable, because we only have patterns for
+        // pow2 and pow3 at this time:
+        LocalizedNumberFormatter lnf = NumberFormatter.with()
+                                           .unit(MeasureUnit.forIdentifier("pow4-mile"))
+                                           .unitWidth(UnitWidth.FULL_NAME)
+                                           .locale(new ULocale("en-ZA"));
+        try {
+            lnf.format(1);
+            fail("Expected failure for pow4-mile, got: " + lnf.format(1) + ".");
+        } catch (UnsupportedOperationException e) {
+            // pass
+        }
+
+        assertFormatSingle(
+            "kibijoule-foot-per-cubic-gigafurlong-square-second unit-width-full-name",
+            "unit/kibijoule-foot-per-cubic-gigafurlong-square-second unit-width-full-name",
+            "unit/kibijoule-foot-per-cubic-gigafurlong-square-second unit-width-full-name",
+            NumberFormatter.with()
+                .unit(MeasureUnit.forIdentifier("kibijoule-foot-per-cubic-gigafurlong-square-second"))
+                .unitWidth(UnitWidth.FULL_NAME),
+            new ULocale("en-ZA"), 2.4, "2,4 kibijoule-feet per cubic gigafurlong-square second");
+
+        assertFormatSingle(
+            "kibijoule-foot-per-cubic-gigafurlong-square-second unit-width-full-name",
+            "unit/kibijoule-foot-per-cubic-gigafurlong-square-second unit-width-full-name",
+            "unit/kibijoule-foot-per-cubic-gigafurlong-square-second unit-width-full-name",
+            NumberFormatter.with()
+                .unit(MeasureUnit.forIdentifier("kibijoule-foot-per-cubic-gigafurlong-square-second"))
+                .unitWidth(UnitWidth.FULL_NAME),
+            new ULocale("de-CH"), 2.4, "2.4\u00A0Kibijoule⋅Fuss pro Kubikgigafurlong⋅Quadratsekunde");
+
+        // TODO(ICU-21504): We want to be able to format this, but "100-kilometer"
+        // is not yet supported when it's not part of liter-per-100-kilometer:
+        lnf = NumberFormatter.with()
+                  .unit(MeasureUnit.forIdentifier("kilowatt-hour-per-100-kilometer"))
+                  .unitWidth(UnitWidth.FULL_NAME)
+                  .locale(new ULocale("en-ZA"));
+        try {
+            lnf.format(1);
+            fail("Expected failure for kilowatt-hour-per-100-kilometer, got: " + lnf.format(1) + ".");
+        } catch (UnsupportedOperationException e) {
+            // pass
+        }
+    }
+
     // TODO: merge these tests into NumberSkeletonTest.java instead of here:
     @Test
     public void unitSkeletons() {
@@ -1924,45 +2041,39 @@ public class NumberFormatterApiTest extends TestFmwk {
     }
 
     public static class UnitInflectionTestCase {
+        public final String unitIdentifier;
         public final String locale;
         public final String unitDisplayCase;
         public final double value;
         public final String expected;
 
-        UnitInflectionTestCase(String locale, String unitDisplayCase, double value, String expected) {
+        UnitInflectionTestCase(String unitIdentifier,
+                               String locale,
+                               String unitDisplayCase,
+                               double value,
+                               String expected) {
+            this.unitIdentifier = unitIdentifier;
             this.locale = locale;
             this.unitDisplayCase = unitDisplayCase;
             this.value = value;
             this.expected = expected;
         }
 
-        public static void runUnitInflectionsTestCases(UnlocalizedNumberFormatter unf,
-                                                       String skeleton,
-                                                       String conciseSkeleton,
-                                                       UnitInflectionTestCase cases[]) {
-            for (UnitInflectionTestCase t : cases) {
-                String skel;
-                String cSkel;
-                if (t.unitDisplayCase == null || t.unitDisplayCase.isEmpty()) {
-                    unf = unf.unitDisplayCase("");
-                    skel = skeleton;
-                    cSkel = conciseSkeleton;
-                } else {
-                    unf = unf.unitDisplayCase(t.unitDisplayCase);
-                    skel = null;
-                    cSkel = null;
-                }
-
-                assertFormatSingle(
-                        "\"" + skeleton + "\", locale=\"" + t.locale + "\", case=\"" +
-                                (t.unitDisplayCase != null ? t.unitDisplayCase : "")
-                                + "\", value=" + t.value,
-                        skel,
-                        cSkel,
-                        unf, new ULocale(t.locale),
-                        t.value,
-                        t.expected);
+        public void runTest(UnlocalizedNumberFormatter unf, String skeleton) {
+            MeasureUnit mu = MeasureUnit.forIdentifier(unitIdentifier);
+            String skel;
+            if (this.unitDisplayCase == null || this.unitDisplayCase.isEmpty()) {
+                unf = unf.unit(mu).unitDisplayCase("");
+                skel = "unit/" + unitIdentifier + " " + skeleton;
+            } else {
+                unf = unf.unit(mu).unitDisplayCase(this.unitDisplayCase);
+                // No skeleton support for unitDisplayCase yet.
+                skel = null;
             }
+            assertFormatSingle("\"" + skeleton + "\", locale=\"" + this.locale + "\", case=\"" +
+                                   (this.unitDisplayCase != null ? this.unitDisplayCase : "") +
+                                   "\", value=" + this.value,
+                               skel, skel, unf, new ULocale(this.locale), this.value, this.expected);
         }
     }
 
@@ -1970,115 +2081,151 @@ public class NumberFormatterApiTest extends TestFmwk {
     public void unitInflections() {
         UnlocalizedNumberFormatter unf;
         String skeleton;
-        String conciseSkeleton;
 
         {
             // Simple inflected form test - test case based on the example in CLDR's
             // grammaticalFeatures.xml
-            unf = NumberFormatter.with().unit(NoUnit.PERCENT).unitWidth(UnitWidth.FULL_NAME);
-            skeleton = "percent unit-width-full-name";
-            conciseSkeleton = "% unit-width-full-name";
+            unf = NumberFormatter.with().unitWidth(UnitWidth.FULL_NAME);
+            skeleton = "unit-width-full-name";
             final UnitInflectionTestCase percentCases[] = {
-                    new UnitInflectionTestCase("ru", null, 10, "10 процентов"),    // many
-                    new UnitInflectionTestCase("ru", "genitive", 10, "10 процентов"), // many
-                    new UnitInflectionTestCase("ru", null, 33, "33 процента"),     // few
-                    new UnitInflectionTestCase("ru", "genitive", 33, "33 процентов"), // few
-                    new UnitInflectionTestCase("ru", null, 1, "1 процент"),        // one
-                    new UnitInflectionTestCase("ru", "genitive", 1, "1 процента"),    // one
+                new UnitInflectionTestCase("percent", "ru", null, 10, "10 процентов"),       // many
+                new UnitInflectionTestCase("percent", "ru", "genitive", 10, "10 процентов"), // many
+                new UnitInflectionTestCase("percent", "ru", null, 33, "33 процента"),        // few
+                new UnitInflectionTestCase("percent", "ru", "genitive", 33, "33 процентов"), // few
+                new UnitInflectionTestCase("percent", "ru", null, 1, "1 процент"),           // one
+                new UnitInflectionTestCase("percent", "ru", "genitive", 1, "1 процента"),    // one
             };
-
-            for (UnitInflectionTestCase testCase :
-                    percentCases) {
-                UnitInflectionTestCase.runUnitInflectionsTestCases(unf, skeleton, conciseSkeleton, percentCases);
+            for (UnitInflectionTestCase t : percentCases) {
+                t.runTest(unf, skeleton);
             }
         }
         {
-            // Testing "de" rules:
-            // <deriveComponent feature="case" structure="per" value0="compound" value1="accusative"/>
-            // <deriveComponent feature="plural" structure="per" value0="compound" value1="one"/>
-            //
-            // per-patterns use accusative, but happen to match nominative, so we're
-            // not testing value1 in the first rule above.
-
-            unf = NumberFormatter.with().unit(MeasureUnit.METER).unitWidth(UnitWidth.FULL_NAME);
-            skeleton = "unit/meter unit-width-full-name";
-            conciseSkeleton = "unit/meter unit-width-full-name";
-            final UnitInflectionTestCase meterCases[] = {
-                    new UnitInflectionTestCase("de", null, 1, "1 Meter"),
-                    new UnitInflectionTestCase("de", "genitive", 1, "1 Meters"),
-                    new UnitInflectionTestCase("de", null, 2, "2 Meter"),
-                    new UnitInflectionTestCase("de", "dative", 2, "2 Metern"),
+            // General testing of inflection rules
+            unf = NumberFormatter.with().unitWidth(UnitWidth.FULL_NAME);
+            skeleton = "unit-width-full-name";
+            final UnitInflectionTestCase testCases[] = {
+                // Check up on the basic values that the compound patterns below
+                // are derived from:
+                new UnitInflectionTestCase("meter", "de", null, 1, "1 Meter"),
+                new UnitInflectionTestCase("meter", "de", "genitive", 1, "1 Meters"),
+                new UnitInflectionTestCase("meter", "de", null, 2, "2 Meter"),
+                new UnitInflectionTestCase("meter", "de", "dative", 2, "2 Metern"),
+                new UnitInflectionTestCase("mile", "de", null, 1, "1 Meile"),
+                new UnitInflectionTestCase("mile", "de", null, 2, "2 Meilen"),
+                new UnitInflectionTestCase("day", "de", null, 1, "1 Tag"),
+                new UnitInflectionTestCase("day", "de", "genitive", 1, "1 Tages"),
+                new UnitInflectionTestCase("day", "de", null, 2, "2 Tage"),
+                new UnitInflectionTestCase("day", "de", "dative", 2, "2 Tagen"),
+                new UnitInflectionTestCase("decade", "de", null, 1, "1\u00A0Jahrzehnt"),
+                new UnitInflectionTestCase("decade", "de", null, 2, "2\u00A0Jahrzehnte"),
+
+                // Testing de "per" rules:
+                //   <deriveComponent feature="case" structure="per" value0="compound" value1="accusative"/>
+                //   <deriveComponent feature="plural" structure="per" value0="compound" value1="one"/>
+                // per-patterns use accusative, but since the accusative form
+                // matches the nominative form, we're not effectively testing value1
+                // in the "case & per" rule above.
+
+                // We have a perUnitPattern for "day" in de, so "per" rules are not
+                // applied for these:
+                new UnitInflectionTestCase("meter-per-day", "de", null, 1, "1 Meter pro Tag"),
+                new UnitInflectionTestCase("meter-per-day", "de", "genitive", 1, "1 Meters pro Tag"),
+                new UnitInflectionTestCase("meter-per-day", "de", null, 2, "2 Meter pro Tag"),
+                new UnitInflectionTestCase("meter-per-day", "de", "dative", 2, "2 Metern pro Tag"),
+
+                // testing code path that falls back to "root" grammaticalFeatures
+                // but does not inflect:
+                new UnitInflectionTestCase("meter-per-day", "af", null, 1, "1 meter per dag"),
+                new UnitInflectionTestCase("meter-per-day", "af", "dative", 1, "1 meter per dag"),
+
+                // Decade does not have a perUnitPattern at this time (CLDR 39 / ICU
+                // 69), so we can test for the correct form of the per part:
+                // Fragile test cases: these cases will break when whitespace is more
+                // consistently applied.
+                new UnitInflectionTestCase("parsec-per-decade", "de", null, 1,
+                                           "1\u00A0Parsec pro Jahrzehnt"),
+                new UnitInflectionTestCase("parsec-per-decade", "de", "genitive", 1,
+                                           "1 Parsec pro Jahrzehnt"),
+                new UnitInflectionTestCase("parsec-per-decade", "de", null, 2,
+                                           "2\u00A0Parsec pro Jahrzehnt"),
+                new UnitInflectionTestCase("parsec-per-decade", "de", "dative", 2,
+                                           "2 Parsec pro Jahrzehnt"),
+
+                // Testing de "times", "power" and "prefix" rules:
+                //
+                //   <deriveComponent feature="plural" structure="times" value0="one" value1="compound"/>
+                //   <deriveComponent feature="case" structure="times" value0="nominative" value1="compound"/>
+                //
+                //   <deriveComponent feature="plural" structure="prefix" value0="one" value1="compound"/>
+                //   <deriveComponent feature="case" structure="prefix" value0="nominative" value1="compound"/>
+                //
+                // Prefixes in German don't change with plural or case, so these
+                // tests can't test value0 of the following two rules:
+                //   <deriveComponent feature="plural" structure="power" value0="one" value1="compound"/>
+                //   <deriveComponent feature="case" structure="power" value0="nominative" value1="compound"/>
+
+                new UnitInflectionTestCase("square-decimeter-dekameter", "de", null, 1,
+                                           "1 Quadratdezimeter⋅Dekameter"),
+                new UnitInflectionTestCase("square-decimeter-dekameter", "de", "genitive", 1,
+                                           "1 Quadratdezimeter⋅Dekameters"),
+                new UnitInflectionTestCase("square-decimeter-dekameter", "de", null, 2,
+                                           "2 Quadratdezimeter⋅Dekameter"),
+                new UnitInflectionTestCase("square-decimeter-dekameter", "de", "dative", 2,
+                                           "2 Quadratdezimeter⋅Dekametern"),
+                // Feminine "Meile" better demonstrates singular-vs-plural form:
+                new UnitInflectionTestCase("cubic-mile-dekamile", "de", null, 1,
+                                           "1 Kubikmeile⋅Dekameile"),
+                new UnitInflectionTestCase("cubic-mile-dekamile", "de", null, 2,
+                                           "2 Kubikmeile⋅Dekameilen"),
+
+                // French handles plural "times" and "power" structures differently:
+                // plural form impacts all "numerator" units (denominator remains
+                // singular like German), and "pow2" prefixes have different forms
+                //   <deriveComponent feature="plural" structure="times" value0="compound"
+                //   value1="compound"/>
+                //   <deriveComponent feature="plural" structure="power" value0="compound"
+                //   value1="compound"/>
+                new UnitInflectionTestCase("square-decimeter-square-second", "fr", null, 1,
+                                           "1\u00A0décimètre carré-seconde carrée"),
+                new UnitInflectionTestCase("square-decimeter-square-second", "fr", null, 2,
+                                           "2\u00A0décimètres carrés-secondes carrées"),
             };
-            UnitInflectionTestCase.runUnitInflectionsTestCases(unf, skeleton, conciseSkeleton, meterCases);
-
-            unf = NumberFormatter.with().unit(MeasureUnit.DAY).unitWidth(UnitWidth.FULL_NAME);
-            skeleton = "unit/day unit-width-full-name";
-            conciseSkeleton = "unit/day unit-width-full-name";
-            final UnitInflectionTestCase dayCases[] = {
-                    new UnitInflectionTestCase("de", null, 1, "1 Tag"),
-                    new UnitInflectionTestCase("de", "genitive", 1, "1 Tages"),
-                    new UnitInflectionTestCase("de", null, 2, "2 Tage"),
-                    new UnitInflectionTestCase("de", "dative", 2, "2 Tagen"),
-            };
-            UnitInflectionTestCase.runUnitInflectionsTestCases(unf, skeleton, conciseSkeleton, dayCases);
-
-            // Day has a perUnitPattern
-            unf = NumberFormatter.with()
-                    .unit(MeasureUnit.forIdentifier("meter-per-day"))
-                    .unitWidth(UnitWidth.FULL_NAME);
-            skeleton = "unit/meter-per-day unit-width-full-name";
-            conciseSkeleton = "unit/meter-per-day unit-width-full-name";
-            final UnitInflectionTestCase meterPerDayCases[] = {
-                    new UnitInflectionTestCase("de", null, 1, "1 Meter pro Tag"),
-                    new UnitInflectionTestCase("de", "genitive", 1, "1 Meters pro Tag"),
-                    new UnitInflectionTestCase("de", null, 2, "2 Meter pro Tag"),
-                    new UnitInflectionTestCase("de", "dative", 2, "2 Metern pro Tag"),
-                    // testing code path that falls back to "root" but does not inflect:
-                    new UnitInflectionTestCase("af", null, 1, "1 meter per dag"),
-                    new UnitInflectionTestCase("af", "dative", 1, "1 meter per dag"),
-            };
-            UnitInflectionTestCase.runUnitInflectionsTestCases(unf, skeleton, conciseSkeleton, meterPerDayCases);
-
-            // Decade does not have a perUnitPattern at this time (CLDR 39 / ICU
-            // 69), so we can test for the correct form of the per part:
-            unf = NumberFormatter.with()
-                    .unit(MeasureUnit.forIdentifier("parsec-per-decade"))
-                    .unitWidth(UnitWidth.FULL_NAME);
-            skeleton = "unit/parsec-per-decade unit-width-full-name";
-            conciseSkeleton = "unit/parsec-per-decade unit-width-full-name";
-            // Fragile test cases: these cases will break when whitespace is more
-            // consistently applied.
-            final UnitInflectionTestCase parsecPerDecadeCases[] = {
-                    new UnitInflectionTestCase("de", null, 1, "1\u00A0Parsec pro Jahrzehnt"),
-                    new UnitInflectionTestCase("de", "genitive", 1, "1 Parsec pro Jahrzehnt"),
-                    new UnitInflectionTestCase("de", null, 2, "2\u00A0Parsec pro Jahrzehnt"),
-                    new UnitInflectionTestCase("de", "dative", 2, "2 Parsec pro Jahrzehnt"),
-            };
-            UnitInflectionTestCase.runUnitInflectionsTestCases(unf, skeleton, conciseSkeleton, parsecPerDecadeCases);
+            for (UnitInflectionTestCase t : testCases) {
+                t.runTest(unf, skeleton);
+            }
         }
         {
             // Testing inflection of mixed units:
-            unf = NumberFormatter.with()
-                    .unit(MeasureUnit.forIdentifier("meter-and-centimeter"))
-                    .unitWidth(UnitWidth.FULL_NAME);
-            skeleton = "unit/meter-and-centimeter unit-width-full-name";
-            conciseSkeleton = "unit/meter-and-centimeter unit-width-full-name";
+            unf = NumberFormatter.with().unitWidth(UnitWidth.FULL_NAME);
+            skeleton = "unit-width-full-name";
             final UnitInflectionTestCase meterPerDayCases[] = {
-                    // TODO(CLDR-14502): check that these inflections are correct, and
-                    // whether CLDR needs any rules for them (presumably CLDR spec
-                    // should mention it, if it's a consistent rule):
-                    new UnitInflectionTestCase("de", null, 1.01, "1 Meter, 1 Zentimeter"),
-                    new UnitInflectionTestCase("de", "genitive", 1.01, "1 Meters, 1 Zentimeters"),
-                    new UnitInflectionTestCase("de", "genitive", 1.1, "1 Meters, 10 Zentimeter"),
-                    new UnitInflectionTestCase("de", "dative", 1.1, "1 Meter, 10 Zentimetern"),
-                    new UnitInflectionTestCase("de", "dative", 2.1, "2 Metern, 10 Zentimetern"),
+                new UnitInflectionTestCase("meter", "de", null, 1, "1 Meter"),
+                new UnitInflectionTestCase("meter", "de", "genitive", 1, "1 Meters"),
+                new UnitInflectionTestCase("meter", "de", "dative", 2, "2 Metern"),
+                new UnitInflectionTestCase("centimeter", "de", null, 1, "1 Zentimeter"),
+                new UnitInflectionTestCase("centimeter", "de", "genitive", 1, "1 Zentimeters"),
+                new UnitInflectionTestCase("centimeter", "de", "dative", 10, "10 Zentimetern"),
+                // TODO(CLDR-14502): check that these inflections are correct, and
+                // whether CLDR needs any rules for them (presumably CLDR spec
+                // should mention it, if it's a consistent rule):
+                new UnitInflectionTestCase("meter-and-centimeter", "de", null, 1.01,
+                                           "1 Meter, 1 Zentimeter"),
+                new UnitInflectionTestCase("meter-and-centimeter", "de", "genitive", 1.01,
+                                           "1 Meters, 1 Zentimeters"),
+                new UnitInflectionTestCase("meter-and-centimeter", "de", "genitive", 1.1,
+                                           "1 Meters, 10 Zentimeter"),
+                new UnitInflectionTestCase("meter-and-centimeter", "de", "dative", 1.1,
+                                           "1 Meter, 10 Zentimetern"),
+                new UnitInflectionTestCase("meter-and-centimeter", "de", "dative", 2.1,
+                                           "2 Metern, 10 Zentimetern"),
             };
-            UnitInflectionTestCase.runUnitInflectionsTestCases(unf, skeleton, conciseSkeleton, meterPerDayCases);
+            for (UnitInflectionTestCase t : meterPerDayCases) {
+                t.runTest(unf, skeleton);
+            }
         }
         // TODO: add a usage case that selects between preferences with different
         // genders (e.g. year, month, day, hour).
         // TODO: look at "↑↑↑" cases: check that inheritance is done right.
-
     }
 
     @Test
@@ -2096,39 +2243,57 @@ public class NumberFormatterApiTest extends TestFmwk {
         }
 
         TestCase cases[] = {
-                new TestCase("de", "meter", "masculine"),
-                new TestCase("de", "minute", "feminine"),
-                new TestCase("de", "hour", "feminine"),
-                new TestCase("de", "day", "masculine"),
-                new TestCase("de", "year", "neuter"),
+            new TestCase("de", "meter", "masculine"),
+            new TestCase("de", "second", "feminine"),
+            new TestCase("de", "minute", "feminine"),
+            new TestCase("de", "hour", "feminine"),
+            new TestCase("de", "day", "masculine"),
+            new TestCase("de", "year", "neuter"),
+            new TestCase("fr", "meter", "masculine"),
+            new TestCase("fr", "second", "feminine"),
                 new TestCase("fr", "minute", "feminine"),
-                new TestCase("fr", "hour", "feminine"),
-                new TestCase("fr", "day", "masculine"),
-                // grammaticalFeatures deriveCompound "per" rule:
-                new TestCase("de", "meter-per-hour", "masculine"),
-                new TestCase("af", "meter-per-hour", null),
-                // TODO(ICU-21494): determine whether list genders behave as follows,
-                // and implement proper getListGender support (covering more than just
-                // two genders):
-                // // gender rule for lists of people: de "neutral", fr "maleTaints"
-                // new TestCase("de", "day-and-hour-and-minute", "neuter"),
-                // new TestCase("de", "hour-and-minute", "feminine"),
-                // new TestCase("fr", "day-and-hour-and-minute", "masculine"),
-                // new TestCase("fr", "hour-and-minute", "feminine"),
+            new TestCase("fr", "hour", "feminine"),
+            new TestCase("fr", "day", "masculine"),
+            // grammaticalFeatures deriveCompound "per" rule takes the gender of the
+            // numerator unit:
+            new TestCase("de", "meter-per-hour", "masculine"),
+            new TestCase("fr", "meter-per-hour", "masculine"),
+            new TestCase("af", "meter-per-hour", null), // ungendered language
+            // French "times" takes gender from first value, German takes the
+            // second. Prefix and power does not have impact on gender for these
+            // languages:
+            new TestCase("de", "square-decimeter-square-second", "feminine"),
+            new TestCase("fr", "square-decimeter-square-second", "masculine"),
+            // TODO(ICU-21494): determine whether list genders behave as follows,
+            // and implement proper getListGender support (covering more than just
+            // two genders):
+            // // gender rule for lists of people: de "neutral", fr "maleTaints"
+            // new TestCase("de", "day-and-hour-and-minute", "neuter"),
+            // new TestCase("de", "hour-and-minute", "feminine"),
+            // new TestCase("fr", "day-and-hour-and-minute", "masculine"),
+            // new TestCase("fr", "hour-and-minute", "feminine"),
         };
 
         LocalizedNumberFormatter formatter;
         FormattedNumber fn;
         for (TestCase t : cases) {
-            // TODO(icu-units#140): make this work for more than just UnitWidth.FULL_NAME
+            // // TODO(icu-units#140): make this work for more than just UnitWidth.FULL_NAME
+            // formatter = NumberFormatter.with()
+            //                 .unit(MeasureUnit.forIdentifier(t.unitIdentifier))
+            //                 .locale(new ULocale(t.locale));
+            // fn = formatter.format(1.1);
+            // assertEquals("Testing gender with default width, unit: " + t.unitIdentifier +
+            //                  ", locale: " + t.locale,
+            //              t.expectedGender, fn.getGender());
+
             formatter = NumberFormatter.with()
-                    .unit(MeasureUnit.forIdentifier(t.unitIdentifier))
-                    .unitWidth(UnitWidth.FULL_NAME)
-                    .locale(new ULocale(t.locale));
+                            .unit(MeasureUnit.forIdentifier(t.unitIdentifier))
+                            .unitWidth(UnitWidth.FULL_NAME)
+                            .locale(new ULocale(t.locale));
             fn = formatter.format(1.1);
-            assertEquals("Testing gender, unit: " + t.unitIdentifier +
-                            ", locale: " + t.locale,
-                    t.expectedGender, fn.getGender());
+            assertEquals("Testing gender with UnitWidth.FULL_NAME, unit: " + t.unitIdentifier +
+                             ", locale: " + t.locale,
+                         t.expectedGender, fn.getGender());
         }
 
         // Make sure getGender does not return garbage for genderless languages