From d1490314337ac074166adbad86d2416fba41c8f5 Mon Sep 17 00:00:00 2001 From: younies Date: Mon, 14 Sep 2020 16:28:03 +0000 Subject: [PATCH] ICU-21010 MeasureUnit extension in Java See #1275 --- .../ibm/icu/impl/units/MeasureUnitImpl.java | 737 ++++++++++++++++++ .../ibm/icu/impl/units/SingleUnitImpl.java | 157 ++++ .../src/com/ibm/icu/impl/units/UnitsData.java | 92 +++ .../src/com/ibm/icu/util/MeasureUnit.java | 517 +++++++++++- .../icu/dev/test/format/MeasureUnitTest.java | 446 ++++++++++- 5 files changed, 1911 insertions(+), 38 deletions(-) create mode 100644 icu4j/main/classes/core/src/com/ibm/icu/impl/units/MeasureUnitImpl.java create mode 100644 icu4j/main/classes/core/src/com/ibm/icu/impl/units/SingleUnitImpl.java create mode 100644 icu4j/main/classes/core/src/com/ibm/icu/impl/units/UnitsData.java diff --git a/icu4j/main/classes/core/src/com/ibm/icu/impl/units/MeasureUnitImpl.java b/icu4j/main/classes/core/src/com/ibm/icu/impl/units/MeasureUnitImpl.java new file mode 100644 index 00000000000..db98db0705a --- /dev/null +++ b/icu4j/main/classes/core/src/com/ibm/icu/impl/units/MeasureUnitImpl.java @@ -0,0 +1,737 @@ +// © 2020 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + + +package com.ibm.icu.impl.units; + +import com.ibm.icu.util.*; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; + +public class MeasureUnitImpl { + + /** + * The full unit identifier. Null if not computed. + */ + private String identifier = null; + + /** + * The complexity, either SINGLE, COMPOUND, or MIXED. + */ + private MeasureUnit.Complexity complexity = MeasureUnit.Complexity.SINGLE; + + /** + * The list of simple units. These may be summed or multiplied, based on the + * value of the complexity field. + *

+ * The "dimensionless" unit (SingleUnitImpl default constructor) must not be + * added to this list. + *

+ * The "dimensionless" MeasureUnitImpl has an empty singleUnits. + */ + private ArrayList singleUnits; + + public MeasureUnitImpl() { + singleUnits = new ArrayList<>(); + } + + public MeasureUnitImpl(SingleUnitImpl singleUnit) { + this(); + this.appendSingleUnit(singleUnit); + } + + /** + * Parse a unit identifier into a MeasureUnitImpl. + * + * @param identifier The unit identifier string. + * @return A newly parsed object. + * @throws IllegalArgumentException in case of incorrect/non-parsed identifier. + */ + public static MeasureUnitImpl forIdentifier(String identifier) { + return UnitsParser.parseForIdentifier(identifier); + } + + /** + * Used for currency units. + */ + public static MeasureUnitImpl forCurrencyCode(String currencyCode) { + MeasureUnitImpl result = new MeasureUnitImpl(); + result.identifier = currencyCode; + return result; + } + + public MeasureUnitImpl clone() { + MeasureUnitImpl result = new MeasureUnitImpl(); + result.complexity = this.complexity; + result.identifier = this.identifier; + result.singleUnits = (ArrayList) this.singleUnits.clone(); + return result; + } + + /** + * Returns the list of simple units. + */ + public ArrayList getSingleUnits() { + return singleUnits; + } + + /** + * Mutates this MeasureUnitImpl to take the reciprocal. + */ + public void takeReciprocal() { + this.identifier = null; + for (SingleUnitImpl singleUnit : + this.singleUnits) { + singleUnit.setDimensionality(singleUnit.getDimensionality() * -1); + } + } + + /** + * Extracts the list of all the individual units inside the `MeasureUnitImpl`. + * For example: + * - if the MeasureUnitImpl is foot-per-hour + * it will return a list of 1 {foot-per-hour} + * - if the MeasureUnitImpl is foot-and-inch + * it will return a list of 2 { foot, inch} + * + * @return a list of MeasureUnitImpl + */ + public ArrayList extractIndividualUnits() { + ArrayList result = new ArrayList(); + if (this.getComplexity() == MeasureUnit.Complexity.MIXED) { + // In case of mixed units, each single unit can be considered as a stand alone MeasureUnitImpl. + for (SingleUnitImpl singleUnit : + this.getSingleUnits()) { + result.add(new MeasureUnitImpl(singleUnit)); + } + + return result; + } + + result.add(this.clone()); + return result; + } + + /** + * Mutates this MeasureUnitImpl to append a single unit. + * + * @return true if a new item was added. If unit is the dimensionless unit, + * it is never added: the return value will always be false. + */ + public boolean appendSingleUnit(SingleUnitImpl singleUnit) { + identifier = null; + + if (singleUnit == null) { + // We don't append dimensionless units. + return false; + } + + // Find a similar unit that already exists, to attempt to coalesce + SingleUnitImpl oldUnit = null; + for (int i = 0, n = this.singleUnits.size(); i < n; i++) { + SingleUnitImpl candidate = this.singleUnits.get(i); + if (candidate.isCompatibleWith(singleUnit)) { + oldUnit = candidate; + break; + } + } + + if (oldUnit != null) { + // Both dimensionalities will be positive, or both will be negative, by + // virtue of isCompatibleWith(). + oldUnit.setDimensionality(oldUnit.getDimensionality() + singleUnit.getDimensionality()); + + return false; + } + + // Add a copy of singleUnit + this.singleUnits.add(singleUnit.clone()); + + // If the MeasureUnitImpl is `UMEASURE_UNIT_SINGLE` and after the appending a unit, the singleUnits are more + // than one singleUnit. thus means the complexity should be `UMEASURE_UNIT_COMPOUND` + if (this.singleUnits.size() > 1 && this.complexity == MeasureUnit.Complexity.SINGLE) { + this.setComplexity(MeasureUnit.Complexity.COMPOUND); + } + + return true; + } + + /** + * Transform this MeasureUnitImpl into a MeasureUnit, simplifying if possible. + *

+ * NOTE: this function must be called from a thread-safe class + */ + public MeasureUnit build() { + return MeasureUnit.fromMeasureUnitImpl(this); + } + + /** + * @return SingleUnitImpl + * @throws UnsupportedOperationException if the object could not be converted to SingleUnitImpl. + */ + public SingleUnitImpl getSingleUnitImpl() { + if (this.singleUnits.size() == 0) { + return new SingleUnitImpl(); + } + if (this.singleUnits.size() == 1) { + return this.singleUnits.get(0).clone(); + } + + throw new UnsupportedOperationException(); + } + + + /** + * Returns the CLDR unit identifier and null if not computed. + */ + public String getIdentifier() { + return identifier; + } + + public MeasureUnit.Complexity getComplexity() { + return complexity; + } + + public void setComplexity(MeasureUnit.Complexity complexity) { + this.complexity = complexity; + } + + /** + * Normalizes the MeasureUnitImpl and generates the identifier string in place. + */ + public void serialize() { + if (this.getSingleUnits().size() == 0) { + // Dimensionless, constructed by the default constructor: no appending + // to this.result, we wish it to contain the zero-length string. + return; + } + if (this.complexity == MeasureUnit.Complexity.COMPOUND) { + // Note: don't sort a MIXED unit + Collections.sort(this.getSingleUnits(), new SingleUnitComparator()); + } + + StringBuilder result = new StringBuilder(); + boolean beforePer = true; + boolean firstTimeNegativeDimension = false; + for (SingleUnitImpl singleUnit : + this.getSingleUnits()) { + if (beforePer && singleUnit.getDimensionality() < 0) { + beforePer = false; + firstTimeNegativeDimension = true; + } else if (singleUnit.getDimensionality() < 0) { + firstTimeNegativeDimension = false; + } + + String singleUnitIdentifier = singleUnit.getNeutralIdentifier(); + if (this.getComplexity() == MeasureUnit.Complexity.MIXED) { + if (result.length() != 0) { + result.append("-and-"); + } + } else { + if (firstTimeNegativeDimension) { + if (result.length() == 0) { + result.append("per-"); + } else { + result.append("-per-"); + } + } else { + if (result.length() != 0) { + result.append("-"); + } + } + } + + result.append(singleUnitIdentifier); + } + + this.identifier = result.toString(); + } + + public enum CompoundPart { + // Represents "-per-" + PER(0), + // Represents "-" + TIMES(1), + // Represents "-and-" + AND(2); + + private final int index; + + CompoundPart(int index) { + this.index = index; + } + + public static CompoundPart getCompoundPartFromTrieIndex(int trieIndex) { + int index = trieIndex - UnitsData.Constants.kCompoundPartOffset; + switch (index) { + case 0: + return CompoundPart.PER; + case 1: + return CompoundPart.TIMES; + case 2: + return CompoundPart.AND; + default: + throw new AssertionError("CompoundPart index must be 0, 1 or 2"); + } + } + + public int getTrieIndex() { + return this.index + UnitsData.Constants.kCompoundPartOffset; + } + + public int getValue() { + return index; + } + } + + public enum PowerPart { + P2(2), + P3(3), + P4(4), + P5(5), + P6(6), + P7(7), + P8(8), + P9(9), + P10(10), + P11(11), + P12(12), + P13(13), + P14(14), + P15(15); + + private final int power; + + PowerPart(int power) { + this.power = power; + } + + public static int getPowerFromTrieIndex(int trieIndex) { + return trieIndex - UnitsData.Constants.kPowerPartOffset; + } + + public int getTrieIndex() { + return this.power + UnitsData.Constants.kPowerPartOffset; + } + + public int getValue() { + return power; + } + } + + public enum InitialCompoundPart { + + // Represents "per-", the only compound part that can appear at the start of + // an identifier. + INITIAL_COMPOUND_PART_PER(0); + + private final int index; + + InitialCompoundPart(int powerIndex) { + this.index = powerIndex; + } + + public static InitialCompoundPart getInitialCompoundPartFromTrieIndex(int trieIndex) { + int index = trieIndex - UnitsData.Constants.kInitialCompoundPartOffset; + if (index == 0) { + return INITIAL_COMPOUND_PART_PER; + } + + throw new IllegalArgumentException("Incorrect trieIndex"); + } + + public int getTrieIndex() { + return this.index + UnitsData.Constants.kInitialCompoundPartOffset; + } + + public int getValue() { + return index; + } + + } + + public static class UnitsParser { + // This used only to not build the trie each time we use the parser + private volatile static CharsTrie savedTrie = null; + private final String[] simpleUnits; + // This trie used in the parsing operation. + private CharsTrie trie; + // Tracks parser progress: the offset into fSource. + private int fIndex = 0; + // Set to true when we've seen a "-per-" or a "per-", after which all units + // are in the denominator. Until we find an "-and-", at which point the + // identifier is invalid pending TODO(CLDR-13700). + private boolean fAfterPer = false; + private String fSource; + // If an "-and-" was parsed prior to finding the "single + // * unit", sawAnd is set to true. If not, it is left as is. + private boolean fSawAnd = false; + + private UnitsParser(String identifier) { + this.simpleUnits = UnitsData.getSimpleUnits(); + this.fSource = identifier; + + if (UnitsParser.savedTrie != null) { + try { + this.trie = UnitsParser.savedTrie.clone(); + } catch (CloneNotSupportedException e) { + throw new ICUCloneNotSupportedException(); + } + return; + } + + // Building the trie. + CharsTrieBuilder trieBuilder; + trieBuilder = new CharsTrieBuilder(); + + // Add syntax parts (compound, power prefixes) + trieBuilder.add("-per-", CompoundPart.PER.getTrieIndex()); + trieBuilder.add("-", CompoundPart.TIMES.getTrieIndex()); + trieBuilder.add("-and-", CompoundPart.AND.getTrieIndex()); + trieBuilder.add("per-", InitialCompoundPart.INITIAL_COMPOUND_PART_PER.getTrieIndex()); + trieBuilder.add("square-", PowerPart.P2.getTrieIndex()); + trieBuilder.add("cubic-", PowerPart.P3.getTrieIndex()); + trieBuilder.add("pow2-", PowerPart.P2.getTrieIndex()); + trieBuilder.add("pow3-", PowerPart.P3.getTrieIndex()); + trieBuilder.add("pow4-", PowerPart.P4.getTrieIndex()); + trieBuilder.add("pow5-", PowerPart.P5.getTrieIndex()); + trieBuilder.add("pow6-", PowerPart.P6.getTrieIndex()); + trieBuilder.add("pow7-", PowerPart.P7.getTrieIndex()); + trieBuilder.add("pow8-", PowerPart.P8.getTrieIndex()); + trieBuilder.add("pow9-", PowerPart.P9.getTrieIndex()); + trieBuilder.add("pow10-", PowerPart.P10.getTrieIndex()); + trieBuilder.add("pow11-", PowerPart.P11.getTrieIndex()); + trieBuilder.add("pow12-", PowerPart.P12.getTrieIndex()); + trieBuilder.add("pow13-", PowerPart.P13.getTrieIndex()); + trieBuilder.add("pow14-", PowerPart.P14.getTrieIndex()); + trieBuilder.add("pow15-", PowerPart.P15.getTrieIndex()); + + // Add SI prefixes + for (MeasureUnit.SIPrefix siPrefix : + MeasureUnit.SIPrefix.values()) { + trieBuilder.add(siPrefix.getIdentifier(), getTrieIndex(siPrefix)); + } + + // Add simple units + for (int i = 0; i < simpleUnits.length; i++) { + trieBuilder.add(simpleUnits[i], i + UnitsData.Constants.kSimpleUnitOffset); + + } + + // TODO: Use SLOW or FAST here? + UnitsParser.savedTrie = trieBuilder.build(StringTrieBuilder.Option.FAST); + + try { + this.trie = UnitsParser.savedTrie.clone(); + } catch (CloneNotSupportedException e) { + throw new ICUCloneNotSupportedException(); + } + } + + + /** + * Construct a MeasureUnit from a CLDR Unit Identifier, defined in UTS 35. + * Validates and canonicalizes the identifier. + * + * @return MeasureUnitImpl object or null if the identifier is empty. + * @throws IllegalArgumentException in case of invalid identifier. + */ + public static MeasureUnitImpl parseForIdentifier(String identifier) { + if (identifier == null || identifier.isEmpty()) { + return null; + } + + UnitsParser parser = new UnitsParser(identifier); + return parser.parse(); + + } + + private static MeasureUnit.SIPrefix getSiPrefixFromTrieIndex(int trieIndex) { + for (MeasureUnit.SIPrefix element : + MeasureUnit.SIPrefix.values()) { + if (getTrieIndex(element) == trieIndex) + return element; + } + + throw new IllegalArgumentException("Incorrect trieIndex"); + } + + private static int getTrieIndex(MeasureUnit.SIPrefix prefix) { + return prefix.getSiPrefixPower() + UnitsData.Constants.kSIPrefixOffset; + } + + private MeasureUnitImpl parse() { + MeasureUnitImpl result = new MeasureUnitImpl(); + + if (fSource.isEmpty()) { + // The dimensionless unit: nothing to parse. return null. + return null; + } + + while (hasNext()) { + fSawAnd = false; + SingleUnitImpl singleUnit = nextSingleUnit(); + + boolean added = result.appendSingleUnit(singleUnit); + if (fSawAnd && !added) { + throw new IllegalArgumentException("Two similar units are not allowed in a mixed unit."); + } + + if ((result.singleUnits.size()) >= 2) { + // nextSingleUnit fails appropriately for "per" and "and" in the + // same identifier. It doesn't fail for other compound units + // (COMPOUND_PART_TIMES). Consequently we take care of that + // here. + MeasureUnit.Complexity complexity = + fSawAnd ? MeasureUnit.Complexity.MIXED : MeasureUnit.Complexity.COMPOUND; + if (result.getSingleUnits().size() == 2) { + // After appending two singleUnits, the complexity will be `UMEASURE_UNIT_COMPOUND` + assert result.getComplexity() == MeasureUnit.Complexity.COMPOUND; + result.setComplexity(complexity); + } else if (result.getComplexity() != complexity) { + throw new IllegalArgumentException("Can't have mixed compound units"); + } + } + } + + return result; + } + + /** + * Returns the next "single unit" via result. + *

+ * If a "-per-" was parsed, the result will have appropriate negative + * dimensionality. + *

+ * + * @throws IllegalArgumentException if we parse both compound units and "-and-", since mixed + * compound units are not yet supported - TODO(CLDR-13700). + */ + private SingleUnitImpl nextSingleUnit() { + SingleUnitImpl result = new SingleUnitImpl(); + + // state: + // 0 = no tokens seen yet (will accept power, SI prefix, or simple unit) + // 1 = power token seen (will not accept another power token) + // 2 = SI prefix token seen (will not accept a power or SI prefix token) + int state = 0; + + boolean atStart = fIndex == 0; + Token token = nextToken(); + + if (atStart) { + // Identifiers optionally start with "per-". + if (token.getType() == Token.Type.TYPE_INITIAL_COMPOUND_PART) { + assert token.getInitialCompoundPart() == InitialCompoundPart.INITIAL_COMPOUND_PART_PER; + + fAfterPer = true; + result.setDimensionality(-1); + + token = nextToken(); + } + } else { + // All other SingleUnit's are separated from previous SingleUnit's + // via a compound part: + if (token.getType() != Token.Type.TYPE_COMPOUND_PART) { + throw new IllegalArgumentException("token type must be TYPE_COMPOUND_PART"); + } + + CompoundPart compoundPart = CompoundPart.getCompoundPartFromTrieIndex(token.getMatch()); + switch (compoundPart) { + case PER: + if (fSawAnd) { + throw new IllegalArgumentException("Mixed compound units not yet supported"); + // TODO(CLDR-13700). + } + + fAfterPer = true; + result.setDimensionality(-1); + break; + + case TIMES: + if (fAfterPer) { + result.setDimensionality(-1); + } + break; + + case AND: + if (fAfterPer) { + // not yet supported, TODO(CLDR-13700). + throw new IllegalArgumentException("Can't start with \"-and-\", and mixed compound units"); + } + fSawAnd = true; + break; + } + + token = nextToken(); + } + + // Read tokens until we have a complete SingleUnit or we reach the end. + while (true) { + switch (token.getType()) { + case TYPE_POWER_PART: + if (state > 0) { + throw new IllegalArgumentException(); + } + + result.setDimensionality(result.getDimensionality() * token.getPower()); + state = 1; + break; + + case TYPE_SI_PREFIX: + if (state > 1) { + throw new IllegalArgumentException(); + } + + result.setSiPrefix(token.getSIPrefix()); + state = 2; + break; + + case TYPE_SIMPLE_UNIT: + result.setSimpleUnit(token.getSimpleUnitIndex(), simpleUnits); + return result; + + default: + throw new IllegalArgumentException(); + } + + if (!hasNext()) { + throw new IllegalArgumentException("We ran out of tokens before finding a complete single unit."); + } + + token = nextToken(); + } + } + + private boolean hasNext() { + return fIndex < fSource.length(); + } + + private Token nextToken() { + trie.reset(); + int match = -1; + // Saves the position in the fSource string for the end of the most + // recent matching token. + int previ = -1; + + // Find the longest token that matches a value in the trie: + while (fIndex < fSource.length()) { + BytesTrie.Result result = trie.next(fSource.charAt(fIndex++)); + if (result == BytesTrie.Result.NO_MATCH) { + break; + } else if (result == BytesTrie.Result.NO_VALUE) { + continue; + } + + match = trie.getValue(); + previ = fIndex; + + if (result == BytesTrie.Result.FINAL_VALUE) { + break; + } + + if (result != BytesTrie.Result.INTERMEDIATE_VALUE) { + throw new IllegalArgumentException("result must has an intermediate value"); + } + + // continue; + } + + + if (match < 0) { + throw new IllegalArgumentException("Encountered unknown token starting at index " + previ); + } else { + fIndex = previ; + } + + return new Token(match); + } + + static class Token { + + private final int fMatch; + private final Type type; + + public Token(int fMatch) { + this.fMatch = fMatch; + type = calculateType(fMatch); + } + + public Type getType() { + return this.type; + } + + public MeasureUnit.SIPrefix getSIPrefix() { + assert this.type == Type.TYPE_SI_PREFIX; + return getSiPrefixFromTrieIndex(this.fMatch); + } + + // Valid only for tokens with type TYPE_COMPOUND_PART. + public int getMatch() { + assert getType() == Type.TYPE_COMPOUND_PART; + return fMatch; + } + + // Even if there is only one InitialCompoundPart value, we have this + // function for the simplicity of code consistency. + public InitialCompoundPart getInitialCompoundPart() { + assert (this.type == Type.TYPE_INITIAL_COMPOUND_PART + && + fMatch == InitialCompoundPart.INITIAL_COMPOUND_PART_PER.getTrieIndex()); + return InitialCompoundPart.getInitialCompoundPartFromTrieIndex(fMatch); + } + + public int getPower() { + assert this.type == Type.TYPE_POWER_PART; + return PowerPart.getPowerFromTrieIndex(this.fMatch); + } + + public int getSimpleUnitIndex() { + return this.fMatch - UnitsData.Constants.kSimpleUnitOffset; + } + + // Calling calculateType() is invalid, resulting in an assertion failure, if Token + // value isn't positive. + private Type calculateType(int fMatch) { + if (fMatch <= 0) { + throw new AssertionError("fMatch must have a positive value"); + } + + if (fMatch < UnitsData.Constants.kCompoundPartOffset) { + return Type.TYPE_SI_PREFIX; + } + if (fMatch < UnitsData.Constants.kInitialCompoundPartOffset) { + return Type.TYPE_COMPOUND_PART; + } + if (fMatch < UnitsData.Constants.kPowerPartOffset) { + return Type.TYPE_INITIAL_COMPOUND_PART; + } + if (fMatch < UnitsData.Constants.kSimpleUnitOffset) { + return Type.TYPE_POWER_PART; + } + + return Type.TYPE_SIMPLE_UNIT; + } + + enum Type { + TYPE_UNDEFINED, + TYPE_SI_PREFIX, + // Token type for "-per-", "-", and "-and-". + TYPE_COMPOUND_PART, + // Token type for "per-". + TYPE_INITIAL_COMPOUND_PART, + TYPE_POWER_PART, + TYPE_SIMPLE_UNIT, + } + } + } + + class SingleUnitComparator implements Comparator { + @Override + public int compare(SingleUnitImpl o1, SingleUnitImpl o2) { + return o1.compareTo(o2); + } + } +} diff --git a/icu4j/main/classes/core/src/com/ibm/icu/impl/units/SingleUnitImpl.java b/icu4j/main/classes/core/src/com/ibm/icu/impl/units/SingleUnitImpl.java new file mode 100644 index 00000000000..52595c0fecf --- /dev/null +++ b/icu4j/main/classes/core/src/com/ibm/icu/impl/units/SingleUnitImpl.java @@ -0,0 +1,157 @@ +// © 2020 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + + +package com.ibm.icu.impl.units; + +import com.ibm.icu.util.MeasureUnit; + +public class SingleUnitImpl { + /** + * Simple unit index, unique for every simple unit, -1 for the dimensionless + * unit. This is an index into a string list in unit.txt {ConversionUnits}. + *

+ * The default value is -1, meaning the dimensionless unit: + * isDimensionless() will return true, until index is changed. + */ + private int index = -1; + /** + * SimpleUnit is the simplest form of a Unit. For example, for "square-millimeter", the simple unit would be "meter"Ò + *

+ * The default value is "", meaning the dimensionless unit: + * isDimensionless() will return true, until index is changed. + */ + private String simpleUnit = ""; + /** + * Determine the power of the `SingleUnit`. For example, for "square-meter", the dimensionality will be `2`. + *

+ * NOTE: + * Default dimensionality is 1. + */ + private int dimensionality = 1; + /** + * SI Prefix + */ + private MeasureUnit.SIPrefix siPrefix = MeasureUnit.SIPrefix.ONE; + + public SingleUnitImpl clone() { + SingleUnitImpl result = new SingleUnitImpl(); + result.index = this.index; + result.dimensionality = this.dimensionality; + result.simpleUnit = this.simpleUnit; + result.siPrefix = this.siPrefix; + + return result; + } + + public MeasureUnit build() { + MeasureUnitImpl measureUnit = new MeasureUnitImpl(this); + return measureUnit.build(); + } + + /** + * Generates an neutral identifier string for a single unit which means we do not include the dimension signal. + * + * @throws IllegalArgumentException + */ + public String getNeutralIdentifier() { + StringBuilder result = new StringBuilder(); + int posPower = Math.abs(this.getDimensionality()); + + assert posPower > 0 : "getIdentifier does not support the dimensionless"; + + if (posPower == 1) { + // no-op + } else if (posPower == 2) { + result.append("square-"); + } else if (posPower == 3) { + result.append("cubic-"); + } else if (posPower <= 15) { + result.append("pow"); + result.append(posPower); + result.append('-'); + } else { + throw new IllegalArgumentException("Unit Identifier Syntax Error"); + } + + result.append(this.getSiPrefix().getIdentifier()); + result.append(this.getSimpleUnit()); + + return result.toString(); + } + + /** + * Compare this SingleUnitImpl to another SingleUnitImpl for the sake of + * sorting and coalescing. + *

+ * Takes the sign of dimensionality into account, but not the absolute + * value: per-meter is not considered the same as meter, but meter is + * considered the same as square-meter. + *

+ * The dimensionless unit generally does not get compared, but if it did, it + * would sort before other units by virtue of index being < 0 and + * dimensionality not being negative. + */ + int compareTo(SingleUnitImpl other) { + if (dimensionality < 0 && other.dimensionality > 0) { + // Positive dimensions first + return 1; + } + if (dimensionality > 0 && other.dimensionality < 0) { + return -1; + } + if (index < other.index) { + return -1; + } + if (index > other.index) { + return 1; + } + if (this.getSiPrefix().getSiPrefixPower() < other.getSiPrefix().getSiPrefixPower()) { + return -1; + } + if (this.getSiPrefix().getSiPrefixPower() > other.getSiPrefix().getSiPrefixPower()) { + return 1; + } + return 0; + } + + /** + * Checks whether this SingleUnitImpl is compatible with another for the purpose of coalescing. + *

+ * Units with the same base unit and SI prefix should match, except that they must also have + * the same dimensionality sign, such that we don't merge numerator and denominator. + */ + boolean isCompatibleWith(SingleUnitImpl other) { + return (compareTo(other) == 0); + } + + public String getSimpleUnit() { + return simpleUnit; + } + + public void setSimpleUnit(int simpleUnitIndex, String[] simpleUnits) { + this.index = simpleUnitIndex; + this.simpleUnit = simpleUnits[simpleUnitIndex]; + } + + public int getDimensionality() { + return dimensionality; + } + + public void setDimensionality(int dimensionality) { + this.dimensionality = dimensionality; + } + + public MeasureUnit.SIPrefix getSiPrefix() { + return siPrefix; + } + + public void setSiPrefix(MeasureUnit.SIPrefix siPrefix) { + this.siPrefix = siPrefix; + } + + public int getIndex() { + return index; + } + +} diff --git a/icu4j/main/classes/core/src/com/ibm/icu/impl/units/UnitsData.java b/icu4j/main/classes/core/src/com/ibm/icu/impl/units/UnitsData.java new file mode 100644 index 00000000000..87b4e20c867 --- /dev/null +++ b/icu4j/main/classes/core/src/com/ibm/icu/impl/units/UnitsData.java @@ -0,0 +1,92 @@ +// © 2020 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + + +package com.ibm.icu.impl.units; + +import com.ibm.icu.impl.ICUData; +import com.ibm.icu.impl.ICUResourceBundle; +import com.ibm.icu.impl.UResource; +import com.ibm.icu.util.UResourceBundle; + +import java.util.ArrayList; + +/** + * Responsible for all units data operations (retriever, analysis, extraction certain data ... etc.). + */ +class UnitsData { + private volatile static String[] simpleUnits = null; + + public static String[] getSimpleUnits() { + if (simpleUnits != null) { + return simpleUnits; + } + + // Read simple units + ICUResourceBundle resource; + resource = (ICUResourceBundle) UResourceBundle.getBundleInstance(ICUData.ICU_BASE_NAME, "units"); + SimpleUnitIdentifiersSink sink = new SimpleUnitIdentifiersSink(); + resource.getAllItemsWithFallback("convertUnits", sink); + simpleUnits = sink.simpleUnits; + + return simpleUnits; + } + + public static class SimpleUnitIdentifiersSink extends UResource.Sink { + String[] simpleUnits = null; + + @Override + public void put(UResource.Key key, UResource.Value value, boolean noFallback) { + assert key.toString().equals(Constants.CONVERSION_UNIT_TABLE_NAME); + assert value.getType() == UResourceBundle.TABLE; + + UResource.Table simpleUnitsTable = value.getTable(); + ArrayList simpleUnits = new ArrayList<>(); + for (int i = 0; simpleUnitsTable.getKeyAndValue(i, key, value); i++) { + if (key.toString().equals("kilogram")) { + + // For parsing, we use "gram", the prefixless metric mass unit. We + // thus ignore the SI Base Unit of Mass: it exists due to being the + // mass conversion target unit, but not needed for MeasureUnit + // parsing. + continue; + } + + simpleUnits.add(key.toString()); + } + + this.simpleUnits = simpleUnits.toArray(new String[0]); + } + } + + /** + * Contains all the needed constants. + */ + public static class Constants { + // Trie value offset for simple units, e.g. "gram", "nautical-mile", + // "fluid-ounce-imperial". + public static final int kSimpleUnitOffset = 512; + + // Trie value offset for powers like "square-", "cubic-", "pow2-" etc. + public static final int kPowerPartOffset = 256; + + + // Trie value offset for "per-". + public final static int kInitialCompoundPartOffset = 192; + + // Trie value offset for compound parts, e.g. "-per-", "-", "-and-". + public final static int kCompoundPartOffset = 128; + + // Trie value offset for SI Prefixes. This is big enough to ensure we only + // insert positive integers into the trie. + public static final int kSIPrefixOffset = 64; + + + /* Tables Names*/ + public static final String CONVERSION_UNIT_TABLE_NAME = "convertUnits"; + public static final String UNIT_PREFERENCE_TABLE_NAME = "unitPreferenceData"; + public static final String CATEGORY_TABLE_NAME = "unitQuantities"; + public static final String DEFAULT_REGION = "001"; + public static final String DEFAULT_USAGE = "default"; + } +} diff --git a/icu4j/main/classes/core/src/com/ibm/icu/util/MeasureUnit.java b/icu4j/main/classes/core/src/com/ibm/icu/util/MeasureUnit.java index 7b9334ab99c..32c05c47d96 100644 --- a/icu4j/main/classes/core/src/com/ibm/icu/util/MeasureUnit.java +++ b/icu4j/main/classes/core/src/com/ibm/icu/util/MeasureUnit.java @@ -14,10 +14,12 @@ import java.io.ObjectInput; import java.io.ObjectOutput; import java.io.ObjectStreamException; import java.io.Serializable; +import java.util.ArrayList; import java.util.Collections; +import java.util.List; +import java.util.Map; import java.util.HashMap; import java.util.HashSet; -import java.util.Map; import java.util.Set; import com.ibm.icu.impl.CollectionSet; @@ -25,8 +27,12 @@ import com.ibm.icu.impl.ICUData; import com.ibm.icu.impl.ICUResourceBundle; import com.ibm.icu.impl.Pair; import com.ibm.icu.impl.UResource; +import com.ibm.icu.impl.units.MeasureUnitImpl; +import com.ibm.icu.impl.units.SingleUnitImpl; import com.ibm.icu.text.UnicodeSet; + + /** * A unit such as length, mass, volume, currency, etc. A unit is * coupled with a numeric amount to produce a Measure. MeasureUnit objects are immutable. @@ -48,6 +54,7 @@ public class MeasureUnit implements Serializable { private static boolean cacheIsPopulated = false; /** + * If type set to null, measureUnitImpl is in use instead of type and subType. * @internal * @deprecated This API is ICU internal only. */ @@ -55,12 +62,255 @@ public class MeasureUnit implements Serializable { protected final String type; /** + * If subType set to null, measureUnitImpl is in use instead of type and subType. * @internal * @deprecated This API is ICU internal only. */ @Deprecated protected final String subType; + /** + * Used by new draft APIs in ICU 68. + * + * @internal + */ + private MeasureUnitImpl measureUnitImpl = null; + + /** + * Enumeration for unit complexity. There are three levels: + *

+ * - SINGLE: A single unit, optionally with a power and/or SI prefix. Examples: hectare, + * square-kilometer, kilojoule, one-per-second. + * - COMPOUND: A unit composed of the product of multiple single units. Examples: + * meter-per-second, kilowatt-hour, kilogram-meter-per-square-second. + * - MIXED: A unit composed of the sum of multiple single units. Examples: foot-and-inch, + * hour-and-minute-and-second, degree-and-arcminute-and-arcsecond. + *

+ * The complexity determines which operations are available. For example, you cannot set the power + * or SI prefix of a compound unit. + * + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + public enum Complexity { + /** + * A single unit, like kilojoule. + * + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + SINGLE, + + /** + * A compound unit, like meter-per-second. + * + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + COMPOUND, + + /** + * A mixed unit, like hour-and-minute. + * + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + MIXED + } + + /** + * Enumeration for SI prefixes, such as "kilo". + * + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + public enum SIPrefix { + + /** + * SI prefix: yotta, 10^24. + * + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + YOTTA(24, "yotta"), + + /** + * SI prefix: zetta, 10^21. + * + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + ZETTA(21, "zetta"), + + /** + * SI prefix: exa, 10^18. + * + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + EXA(18, "exa"), + + /** + * SI prefix: peta, 10^15. + * + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + PETA(15, "peta"), + + /** + * SI prefix: tera, 10^12. + * + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + TERA(12, "tera"), + + /** + * SI prefix: giga, 10^9. + * + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + GIGA(9, "giga"), + + /** + * SI prefix: mega, 10^6. + * + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + MEGA(6, "mega"), + + /** + * SI prefix: kilo, 10^3. + * + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + KILO(3, "kilo"), + + /** + * SI prefix: hecto, 10^2. + * + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + HECTO(2, "hecto"), + + /** + * SI prefix: deka, 10^1. + * + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + DEKA(1, "deka"), + + /** + * The absence of an SI prefix. + * + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + ONE(0, ""), + + /** + * SI prefix: deci, 10^-1. + * + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + DECI(-1, "deci"), + + /** + * SI prefix: centi, 10^-2. + * + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + CENTI(-2, "centi"), + + /** + * SI prefix: milli, 10^-3. + * + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + MILLI(-3, "milli"), + + /** + * SI prefix: micro, 10^-6. + * + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + MICRO(-6, "micro"), + + /** + * SI prefix: nano, 10^-9. + * + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + NANO(-9, "nano"), + + /** + * SI prefix: pico, 10^-12. + * + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + PICO(-12, "pico"), + + /** + * SI prefix: femto, 10^-15. + * + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + FEMTO(-15, "femto"), + + /** + * SI prefix: atto, 10^-18. + * + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + ATTO(-18, "atto"), + + /** + * SI prefix: zepto, 10^-21. + * + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + ZEPTO(-21, "zepto"), + + /** + * SI prefix: yocto, 10^-24. + * + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + YOCTO(-24, "yocto"); + + private final int siPrefixPower; + private final String identifier; + + SIPrefix(int siPrefixPower, String identifier) { + this.siPrefixPower = siPrefixPower; + this.identifier = identifier; + } + + public String getIdentifier() { + return identifier; + } + + public int getSiPrefixPower() { + return siPrefixPower; + } + } + /** * @internal * @deprecated This API is ICU internal only. @@ -71,6 +321,52 @@ public class MeasureUnit implements Serializable { this.subType = subType; } + /** + * Construct a MeasureUnit from a CLDR Unit Identifier, defined in UTS 35. + * Validates and canonicalizes the identifier. + * + * Note: dimensionless MeasureUnit is null + * + *

+     * MeasureUnit example = MeasureUnit::forIdentifier("furlong-per-nanosecond")
+     * 
+ * + * @param identifier The CLDR Sequence Unit Identifier + * @throws IllegalArgumentException if the identifier is invalid. + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + public static MeasureUnit forIdentifier(String identifier) { + if (identifier == null || identifier.isEmpty()) { + return NoUnit.BASE; + } + + return MeasureUnitImpl.forIdentifier(identifier).build(); + } + + /** + * @internal + * @param measureUnitImpl + */ + public static MeasureUnit fromMeasureUnitImpl(MeasureUnitImpl measureUnitImpl) { + measureUnitImpl.serialize(); + String identifier = measureUnitImpl.getIdentifier(); + MeasureUnit result = MeasureUnit.findBySubType(identifier); + if (result != null) { + return result; + } + + return new MeasureUnit(measureUnitImpl); + } + + private MeasureUnit(MeasureUnitImpl measureUnitImpl) { + type = null; + subType = null; + this.measureUnitImpl = measureUnitImpl.clone(); + } + + + /** * Get the type, such as "length" * @@ -90,7 +386,187 @@ public class MeasureUnit implements Serializable { return subType; } + /** + * Gets the CLDR Unit Identifier for this MeasureUnit, as defined in UTS 35. + * + * @return The string form of this unit. + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + public String getIdentifier() { + String result = measureUnitImpl == null ? getSubtype() : measureUnitImpl.getIdentifier(); + return result == null ? "" : result; + } + /** + * Compute the complexity of the unit. See Complexity for more information. + * + * @return The unit complexity. + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + public Complexity getComplexity() { + if (measureUnitImpl == null) { + return MeasureUnitImpl.forIdentifier(getIdentifier()).getComplexity(); + } + + return measureUnitImpl.getComplexity(); + } + + /** + * Creates a MeasureUnit which is this SINGLE unit augmented with the specified SI prefix. + * For example, SI_PREFIX_KILO for "kilo". + * May return this if this unit already has that prefix. + *

+ * There is sufficient locale data to format all standard SI prefixes. + *

+ * NOTE: Only works on SINGLE units. If this is a COMPOUND or MIXED unit, an error will + * occur. For more information, see `Complexity`. + * + * @param prefix The SI prefix, from SIPrefix. + * @return A new SINGLE unit. + * @throws UnsupportedOperationException if this unit is a COMPOUND or MIXED unit. + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + public MeasureUnit withSIPrefix(SIPrefix prefix) { + SingleUnitImpl singleUnit = getSingleUnitImpl(); + singleUnit.setSiPrefix(prefix); + return singleUnit.build(); + } + + /** + * Returns the current SI prefix of this SINGLE unit. For example, if the unit has the SI prefix + * "kilo", then SI_PREFIX_KILO is returned. + *

+ * NOTE: Only works on SINGLE units. If this is a COMPOUND or MIXED unit, an error will + * occur. For more information, see `Complexity`. + * + * @return The SI prefix of this SINGLE unit, from SIPrefix. + * @throws UnsupportedOperationException if the unit is COMPOUND or MIXED. + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + public SIPrefix getSIPrefix() { + return getSingleUnitImpl().getSiPrefix(); + } + + /** + * Returns the dimensionality (power) of this MeasureUnit. For example, if the unit is square, + * then 2 is returned. + *

+ * NOTE: Only works on SINGLE units. If this is a COMPOUND or MIXED unit, an exception will be thrown. + * For more information, see `Complexity`. + * + * @return The dimensionality (power) of this simple unit. + * @throws UnsupportedOperationException if the unit is COMPOUND or MIXED. + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + public int getDimensionality() { + return getSingleUnitImpl().getDimensionality(); + } + + /** + * Creates a MeasureUnit which is this SINGLE unit augmented with the specified dimensionality + * (power). For example, if dimensionality is 2, the unit will be squared. + *

+ * NOTE: Only works on SINGLE units. If this is a COMPOUND or MIXED unit, an exception is thrown. + * For more information, see `Complexity`. + * + * @param dimensionality The dimensionality (power). + * @return A new SINGLE unit. + * @throws UnsupportedOperationException if the unit is COMPOUND or MIXED. + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + public MeasureUnit withDimensionality(int dimensionality) { + SingleUnitImpl singleUnit = getSingleUnitImpl(); + singleUnit.setDimensionality(dimensionality); + return singleUnit.build(); + } + + /** + * Computes the reciprocal of this MeasureUnit, with the numerator and denominator flipped. + *

+ * For example, if the receiver is "meter-per-second", the unit "second-per-meter" is returned. + *

+ * NOTE: Only works on SINGLE and COMPOUND units. If this is a MIXED unit, an error will + * occur. For more information, see `Complexity`. + * + * @return The reciprocal of the target unit. + * @throws UnsupportedOperationException if the unit is MIXED. + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + public MeasureUnit reciprocal() { + MeasureUnitImpl measureUnit = getCopyOfMeasureUnitImpl(); + measureUnit.takeReciprocal(); + return measureUnit.build(); + } + + /** + * Computes the product of this unit with another unit. This is a way to build units from + * constituent parts. + *

+ * The numerator and denominator are preserved through this operation. + *

+ * For example, if the receiver is "kilowatt" and the argument is "hour-per-day", then the + * unit "kilowatt-hour-per-day" is returned. + *

+ * NOTE: Only works on SINGLE and COMPOUND units. If either unit (receivee and argument) is a + * MIXED unit, an error will occur. For more information, see `Complexity`. + * + * @param other The MeasureUnit to multiply with the target. + * @return The product of the target unit with the provided unit. + * @throws UnsupportedOperationException if the unit is MIXED. + * @draft ICU 68 + * @provisional This API might change or be removed in a future release. + */ + public MeasureUnit product(MeasureUnit other) { + MeasureUnitImpl implCopy = getCopyOfMeasureUnitImpl(); + + if (other == null /* dimensionless */) { + return implCopy.build(); + } + + final MeasureUnitImpl otherImplRef = other.getMayBeReferenceOfMeasureUnitImpl(); + if (implCopy.getComplexity() == Complexity.MIXED || otherImplRef.getComplexity() == Complexity.MIXED) { + throw new UnsupportedOperationException(); + } + + for (SingleUnitImpl singleUnit : + otherImplRef.getSingleUnits()) { + implCopy.appendSingleUnit(singleUnit); + } + + return implCopy.build(); + } + + /** + * Returns the list of SINGLE units contained within a sequence of COMPOUND units. + *

+ * Examples: + * - Given "meter-kilogram-per-second", three units will be returned: "meter", + * "kilogram", and "one-per-second". + * - Given "hour+minute+second", three units will be returned: "hour", "minute", + * and "second". + *

+ * If this is a SINGLE unit, a list of length 1 will be returned. + * + * @return An unmodifiable list of single units + * @internal ICU 68 Technology Preview + * @provisional This API might change or be removed in a future release. + */ + public List splitToSingleUnits() { + final ArrayList singleUnits = getMayBeReferenceOfMeasureUnitImpl().getSingleUnits(); + List result = new ArrayList<>(singleUnits.size()); + for (SingleUnitImpl singleUnit : singleUnits) { + result.add(singleUnit.build()); + } + + return result; + } /** * {@inheritDoc} @@ -115,8 +591,8 @@ public class MeasureUnit implements Serializable { if (!(rhs instanceof MeasureUnit)) { return false; } - MeasureUnit c = (MeasureUnit) rhs; - return type.equals(c.type) && subType.equals(c.subType); + + return this.getIdentifier().equals(((MeasureUnit) rhs).getIdentifier()); } /** @@ -1546,6 +2022,39 @@ public class MeasureUnit implements Serializable { return new MeasureUnitProxy(type, subType); } + /** + * + * @return this object as a SingleUnitImpl. + * @throws UnsupportedOperationException if this object could not be converted to a single unit. + */ + private SingleUnitImpl getSingleUnitImpl() { + if (measureUnitImpl == null) { + return MeasureUnitImpl.forIdentifier(getIdentifier()).getSingleUnitImpl(); + } + + return measureUnitImpl.getSingleUnitImpl(); + } + + /** + * + * @return this object in a MeasureUnitImpl form. + */ + private MeasureUnitImpl getCopyOfMeasureUnitImpl() { + return this.measureUnitImpl == null ? + MeasureUnitImpl.forIdentifier(getIdentifier()) : + this.measureUnitImpl.clone(); + } + + /** + * + * @return this object in a MeasureUnitImpl form. + */ + private MeasureUnitImpl getMayBeReferenceOfMeasureUnitImpl(){ + return this.measureUnitImpl == null ? + MeasureUnitImpl.forIdentifier(getIdentifier()) : + this.measureUnitImpl; + } + static final class MeasureUnitProxy implements Externalizable { private static final long serialVersionUID = -3910681415330989598L; @@ -1586,4 +2095,4 @@ public class MeasureUnit implements Serializable { return MeasureUnit.internalGetInstance(type, subType); } } -} +} \ No newline at end of file diff --git a/icu4j/main/tests/core/src/com/ibm/icu/dev/test/format/MeasureUnitTest.java b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/format/MeasureUnitTest.java index 71f90cc2d62..3b1c424bbfe 100644 --- a/icu4j/main/tests/core/src/com/ibm/icu/dev/test/format/MeasureUnitTest.java +++ b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/format/MeasureUnitTest.java @@ -8,33 +8,6 @@ */ package com.ibm.icu.dev.test.format; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.ObjectInputStream; -import java.io.ObjectOutputStream; -import java.io.Serializable; -import java.lang.reflect.Field; -import java.text.FieldPosition; -import java.text.ParseException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import java.util.TreeMap; - -import org.junit.Assert; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; - import com.ibm.icu.dev.test.TestFmwk; import com.ibm.icu.dev.test.serializable.FormatHandler; import com.ibm.icu.dev.test.serializable.SerializableTestUtility; @@ -45,13 +18,17 @@ import com.ibm.icu.text.MeasureFormat; import com.ibm.icu.text.MeasureFormat.FormatWidth; import com.ibm.icu.text.NumberFormat; import com.ibm.icu.util.Currency; -import com.ibm.icu.util.CurrencyAmount; -import com.ibm.icu.util.Measure; -import com.ibm.icu.util.MeasureUnit; -import com.ibm.icu.util.NoUnit; -import com.ibm.icu.util.TimeUnit; -import com.ibm.icu.util.TimeUnitAmount; -import com.ibm.icu.util.ULocale; +import com.ibm.icu.util.*; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import java.io.*; +import java.lang.reflect.Field; +import java.text.FieldPosition; +import java.text.ParseException; +import java.util.*; /** * See https://sites.google.com/site/icusite/processes/release/tasks/standards?pli=1 @@ -3485,4 +3462,405 @@ public class MeasureUnitTest extends TestFmwk { fmt = MeasureFormat.getInstance(ULocale.forLanguageTag("da"), FormatWidth.NUMERIC); Assert.assertEquals("2.03,877", fmt.formatMeasures(fhours, fminutes)); } + + @Test + public void TestIdentifiers() { + class TestCase { + final String id; + final String normalized; + + TestCase(String id, String normalized) { + this.id = id; + this.normalized = normalized; + } + } + + TestCase cases[] = { + // Correctly normalized identifiers should not change + new TestCase("square-meter-per-square-meter", "square-meter-per-square-meter"), + new TestCase("kilogram-meter-per-square-meter-square-second", + "kilogram-meter-per-square-meter-square-second"), + new TestCase("square-mile-and-square-foot", "square-mile-and-square-foot"), + new TestCase("square-foot-and-square-mile", "square-foot-and-square-mile"), + new TestCase("per-cubic-centimeter", "per-cubic-centimeter"), + new TestCase("per-kilometer", "per-kilometer"), + + // Normalization of power and per + new TestCase( + "pow2-foot-and-pow2-mile", "square-foot-and-square-mile"), + new TestCase( + "gram-square-gram-per-dekagram", "cubic-gram-per-dekagram"), + new TestCase( + "kilogram-per-meter-per-second", "kilogram-per-meter-second"), + + // TODO(ICU-20920): Add more test cases once the proper ranking is available. + }; + + + for (TestCase testCase : cases) { + MeasureUnit unit = MeasureUnit.forIdentifier(testCase.id); + + final String actual = unit.getIdentifier(); + assertEquals(testCase.id, testCase.normalized, actual); + } + + assertEquals("for empty identifiers, the MeasureUnit will be null", + null, MeasureUnit.forIdentifier("")); + } + + @Test + public void TestInvalidIdentifiers() { + final String inputs[] = { + "kilo", + "kilokilo", + "onekilo", + "meterkilo", + "meter-kilo", + "k", + "meter-", + "meter+", + "-meter", + "+meter", + "-kilometer", + "+kilometer", + "-pow2-meter", + "+pow2-meter", + "p2-meter", + "p4-meter", + "+", + "-", + "-mile", + "-and-mile", + "-per-mile", + "one", + "one-one", + "one-per-mile", + "one-per-cubic-centimeter", + "square--per-meter", + "metersecond", // Must have compound part in between single units + + // Negative powers not supported in mixed units yet. TODO(CLDR-13701). + "per-hour-and-hertz", + "hertz-and-per-hour", + + // Compound units not supported in mixed units yet. TODO(CLDR-13700). + "kilonewton-meter-and-newton-meter", + }; + + for (String input : inputs) { + try { + MeasureUnit.forIdentifier(input); + Assert.fail("An IllegalArgumentException must be thrown"); + } catch (IllegalArgumentException e) { + continue; + } + } + } + + @Test + public void TestCompoundUnitOperations() { + MeasureUnit.forIdentifier("kilometer-per-second-joule"); + + MeasureUnit kilometer = MeasureUnit.KILOMETER; + MeasureUnit cubicMeter = MeasureUnit.CUBIC_METER; + MeasureUnit meter = kilometer.withSIPrefix(MeasureUnit.SIPrefix.ONE); + MeasureUnit centimeter1 = kilometer.withSIPrefix(MeasureUnit.SIPrefix.CENTI); + MeasureUnit centimeter2 = meter.withSIPrefix(MeasureUnit.SIPrefix.CENTI); + MeasureUnit cubicDecimeter = cubicMeter.withSIPrefix(MeasureUnit.SIPrefix.DECI); + + verifySingleUnit(kilometer, MeasureUnit.SIPrefix.KILO, 1, "kilometer"); + verifySingleUnit(meter, MeasureUnit.SIPrefix.ONE, 1, "meter"); + verifySingleUnit(centimeter1, MeasureUnit.SIPrefix.CENTI, 1, "centimeter"); + verifySingleUnit(centimeter2, MeasureUnit.SIPrefix.CENTI, 1, "centimeter"); + verifySingleUnit(cubicDecimeter, MeasureUnit.SIPrefix.DECI, 3, "cubic-decimeter"); + + assertTrue("centimeter equality", centimeter1.equals( centimeter2)); + assertTrue("kilometer inequality", !centimeter1.equals( kilometer)); + + MeasureUnit squareMeter = meter.withDimensionality(2); + MeasureUnit overCubicCentimeter = centimeter1.withDimensionality(-3); + MeasureUnit quarticKilometer = kilometer.withDimensionality(4); + MeasureUnit overQuarticKilometer1 = kilometer.withDimensionality(-4); + + verifySingleUnit(squareMeter, MeasureUnit.SIPrefix.ONE, 2, "square-meter"); + verifySingleUnit(overCubicCentimeter, MeasureUnit.SIPrefix.CENTI, -3, "per-cubic-centimeter"); + verifySingleUnit(quarticKilometer, MeasureUnit.SIPrefix.KILO, 4, "pow4-kilometer"); + verifySingleUnit(overQuarticKilometer1, MeasureUnit.SIPrefix.KILO, -4, "per-pow4-kilometer"); + + assertTrue("power inequality", quarticKilometer != overQuarticKilometer1); + + MeasureUnit overQuarticKilometer2 = quarticKilometer.reciprocal(); + MeasureUnit overQuarticKilometer3 = kilometer.product(kilometer) + .product(kilometer) + .product(kilometer) + .reciprocal(); + MeasureUnit overQuarticKilometer4 = meter.withDimensionality(4) + .reciprocal() + .withSIPrefix(MeasureUnit.SIPrefix.KILO); + + verifySingleUnit(overQuarticKilometer2, MeasureUnit.SIPrefix.KILO, -4, "per-pow4-kilometer"); + verifySingleUnit(overQuarticKilometer3, MeasureUnit.SIPrefix.KILO, -4, "per-pow4-kilometer"); + verifySingleUnit(overQuarticKilometer4, MeasureUnit.SIPrefix.KILO, -4, "per-pow4-kilometer"); + + assertTrue("reciprocal equality", overQuarticKilometer1.equals(overQuarticKilometer2)); + assertTrue("reciprocal equality", overQuarticKilometer1.equals(overQuarticKilometer3)); + assertTrue("reciprocal equality", overQuarticKilometer1.equals(overQuarticKilometer4)); + + MeasureUnit kiloSquareSecond = MeasureUnit.SECOND + .withDimensionality(2).withSIPrefix(MeasureUnit.SIPrefix.KILO); + MeasureUnit meterSecond = meter.product(kiloSquareSecond); + MeasureUnit cubicMeterSecond1 = meter.withDimensionality(3).product(kiloSquareSecond); + MeasureUnit centimeterSecond1 = meter.withSIPrefix(MeasureUnit.SIPrefix.CENTI).product(kiloSquareSecond); + MeasureUnit secondCubicMeter = kiloSquareSecond.product(meter.withDimensionality(3)); + MeasureUnit secondCentimeter = kiloSquareSecond.product(meter.withSIPrefix(MeasureUnit.SIPrefix.CENTI)); + MeasureUnit secondCentimeterPerKilometer = secondCentimeter.product(kilometer.reciprocal()); + + verifySingleUnit(kiloSquareSecond, MeasureUnit.SIPrefix.KILO, 2, "square-kilosecond"); + String meterSecondSub[] = { + "meter", "square-kilosecond" + }; + verifyCompoundUnit(meterSecond, "meter-square-kilosecond", + meterSecondSub, meterSecondSub.length); + String cubicMeterSecond1Sub[] = { + "cubic-meter", "square-kilosecond" + }; + verifyCompoundUnit(cubicMeterSecond1, "cubic-meter-square-kilosecond", + cubicMeterSecond1Sub, cubicMeterSecond1Sub.length); + String centimeterSecond1Sub[] = { + "centimeter", "square-kilosecond" + }; + verifyCompoundUnit(centimeterSecond1, "centimeter-square-kilosecond", + centimeterSecond1Sub, centimeterSecond1Sub.length); + String secondCubicMeterSub[] = { + "cubic-meter", "square-kilosecond" + }; + verifyCompoundUnit(secondCubicMeter, "cubic-meter-square-kilosecond", + secondCubicMeterSub, secondCubicMeterSub.length); + String secondCentimeterSub[] = { + "centimeter", "square-kilosecond" + }; + verifyCompoundUnit(secondCentimeter, "centimeter-square-kilosecond", + secondCentimeterSub, secondCentimeterSub.length); + String secondCentimeterPerKilometerSub[] = { + "centimeter", "square-kilosecond", "per-kilometer" + }; + verifyCompoundUnit(secondCentimeterPerKilometer, "centimeter-square-kilosecond-per-kilometer", + secondCentimeterPerKilometerSub, secondCentimeterPerKilometerSub.length); + + assertTrue("reordering equality", cubicMeterSecond1.equals(secondCubicMeter)); + assertTrue("additional simple units inequality", !secondCubicMeter.equals(secondCentimeter)); + + // Don't allow get/set power or SI prefix on compound units + try { + meterSecond.getDimensionality(); + fail("UnsupportedOperationException must be thrown"); + } catch (UnsupportedOperationException e) { + // Expecting an exception to be thrown + } + + try { + meterSecond.withDimensionality(3); + fail("UnsupportedOperationException must be thrown"); + } catch (UnsupportedOperationException e) { + // Expecting an exception to be thrown + } + + try { + meterSecond.getSIPrefix(); + fail("UnsupportedOperationException must be thrown"); + } catch (UnsupportedOperationException e) { + // Expecting an exception to be thrown + } + + try { + meterSecond.withSIPrefix(MeasureUnit.SIPrefix.CENTI); + fail("UnsupportedOperationException must be thrown"); + } catch (UnsupportedOperationException e) { + // Expecting an exception to be thrown + } + + MeasureUnit footInch = MeasureUnit.forIdentifier("foot-and-inch"); + MeasureUnit inchFoot = MeasureUnit.forIdentifier("inch-and-foot"); + + String footInchSub[] = { + "foot", "inch" + }; + verifyMixedUnit(footInch, "foot-and-inch", + footInchSub, footInchSub.length); + String inchFootSub[] = { + "inch", "foot" + }; + verifyMixedUnit(inchFoot, "inch-and-foot", + inchFootSub, inchFootSub.length); + + assertTrue("order matters inequality", !footInch.equals(inchFoot)); + + + MeasureUnit dimensionless = NoUnit.BASE; + MeasureUnit dimensionless2 = MeasureUnit.forIdentifier(""); + assertEquals("dimensionless equality", dimensionless, dimensionless2); + + // We support starting from an "identity" MeasureUnit and then combining it + // with others via product: + MeasureUnit kilometer2 = kilometer.product(dimensionless); + + verifySingleUnit(kilometer2, MeasureUnit.SIPrefix.KILO, 1, "kilometer"); + assertTrue("kilometer equality", kilometer.equals(kilometer2)); + + // Test out-of-range powers + MeasureUnit power15 = MeasureUnit.forIdentifier("pow15-kilometer"); + verifySingleUnit(power15, MeasureUnit.SIPrefix.KILO, 15, "pow15-kilometer"); + + try { + MeasureUnit.forIdentifier("pow16-kilometer"); + fail("An IllegalArgumentException must be thrown"); + } catch (IllegalArgumentException e) { + // Expecting an exception to be thrown + } + + try { + power15.product(kilometer); + fail("An IllegalArgumentException must be thrown"); + } catch (IllegalArgumentException e) { + // Expecting an exception to be thrown + } + + MeasureUnit powerN15 = MeasureUnit.forIdentifier("per-pow15-kilometer"); + verifySingleUnit(powerN15, MeasureUnit.SIPrefix.KILO, -15, "per-pow15-kilometer"); + + try { + MeasureUnit.forIdentifier("per-pow16-kilometer"); + fail("An IllegalArgumentException must be thrown"); + } catch (IllegalArgumentException e) { + // Expecting an exception to be thrown + } + + try { + powerN15.product(overQuarticKilometer1); + fail("An IllegalArgumentException must be thrown"); + } catch (IllegalArgumentException e) { + // Expecting an exception to be thrown + } + } + + @Test + public void TestDimensionlessBehaviour() { + MeasureUnit dimensionless = MeasureUnit.forIdentifier(""); + MeasureUnit dimensionless2 = NoUnit.BASE; + MeasureUnit dimensionless3 = null; + MeasureUnit dimensionless4 = MeasureUnit.forIdentifier(null); + + assertEquals("dimensionless must be equals", dimensionless, dimensionless2); + assertEquals("dimensionless must be equals", dimensionless2, dimensionless3); + assertEquals("dimensionless must be equals", dimensionless3, dimensionless4); + + // product(dimensionless) + MeasureUnit mile = MeasureUnit.MILE; + mile = mile.product(dimensionless); + verifySingleUnit(mile, MeasureUnit.SIPrefix.ONE, 1, "mile"); + } + + private void verifySingleUnit(MeasureUnit singleMeasureUnit, MeasureUnit.SIPrefix prefix, int power, String identifier) { + assertEquals(identifier + ": SI prefix", prefix, singleMeasureUnit.getSIPrefix()); + + assertEquals(identifier + ": Power", power, singleMeasureUnit.getDimensionality()); + + assertEquals(identifier + ": Identifier", identifier, singleMeasureUnit.getIdentifier()); + + assertTrue(identifier + ": Constructor", singleMeasureUnit.equals(MeasureUnit.forIdentifier(identifier))); + + assertEquals(identifier + ": Complexity", MeasureUnit.Complexity.SINGLE, singleMeasureUnit.getComplexity()); + } + + + // Kilogram is a "base unit", although it's also "gram" with a kilo- prefix. + // This tests that it is handled in the preferred manner. + @Test + public void TestKilogramIdentifier() { + // SI unit of mass + MeasureUnit kilogram = MeasureUnit.forIdentifier("kilogram"); + // Metric mass unit + MeasureUnit gram = MeasureUnit.forIdentifier("gram"); + // Microgram: still a built-in type + MeasureUnit microgram = MeasureUnit.forIdentifier("microgram"); + // Nanogram: not a built-in type at this time + MeasureUnit nanogram = MeasureUnit.forIdentifier("nanogram"); + + assertEquals("parsed kilogram equals built-in kilogram", MeasureUnit.KILOGRAM.getType(), + kilogram.getType()); + assertEquals("parsed kilogram equals built-in kilogram", MeasureUnit.KILOGRAM.getSubtype(), + kilogram.getSubtype()); + assertEquals("parsed gram equals built-in gram", MeasureUnit.GRAM.getType(), gram.getType()); + assertEquals("parsed gram equals built-in gram", MeasureUnit.GRAM.getSubtype(), + gram.getSubtype()); + assertEquals("parsed microgram equals built-in microgram", MeasureUnit.MICROGRAM.getType(), + microgram.getType()); + assertEquals("parsed microgram equals built-in microgram", MeasureUnit.MICROGRAM.getSubtype(), + microgram.getSubtype()); + assertEquals("nanogram", null, nanogram.getType()); + assertEquals("nanogram", "nanogram", nanogram.getIdentifier()); + + assertEquals("prefix of kilogram", MeasureUnit.SIPrefix.KILO, kilogram.getSIPrefix()); + assertEquals("prefix of gram", MeasureUnit.SIPrefix.ONE, gram.getSIPrefix()); + assertEquals("prefix of microgram", MeasureUnit.SIPrefix.MICRO, microgram.getSIPrefix()); + assertEquals("prefix of nanogram", MeasureUnit.SIPrefix.NANO, nanogram.getSIPrefix()); + + MeasureUnit tmp = kilogram.withSIPrefix(MeasureUnit.SIPrefix.MILLI); + assertEquals("Kilogram + milli should be milligram, got: " + tmp.getIdentifier(), + MeasureUnit.MILLIGRAM.getIdentifier(), tmp.getIdentifier()); + } + + private void verifyCompoundUnit( + MeasureUnit unit, + String identifier, + String subIdentifiers[], + int subIdentifierCount) { + assertEquals(identifier + ": Identifier", + identifier, + unit.getIdentifier()); + + assertTrue(identifier + ": Constructor", + unit.equals(MeasureUnit.forIdentifier(identifier))); + + assertEquals(identifier + ": Complexity", + MeasureUnit.Complexity.COMPOUND, + unit.getComplexity()); + + List subUnits = unit.splitToSingleUnits(); + assertEquals(identifier + ": Length", subIdentifierCount, subUnits.size()); + for (int i = 0; ; i++) { + if (i >= subIdentifierCount || i >= subUnits.size()) break; + assertEquals(identifier + ": Sub-unit #" + i, + subIdentifiers[i], + subUnits.get(i).getIdentifier()); + assertEquals(identifier + ": Sub-unit Complexity", + MeasureUnit.Complexity.SINGLE, + subUnits.get(i).getComplexity()); + } + } + + private void verifyMixedUnit( + MeasureUnit unit, + String identifier, + String subIdentifiers[], + int subIdentifierCount) { + assertEquals(identifier + ": Identifier", + identifier, + unit.getIdentifier()); + assertTrue(identifier + ": Constructor", + unit.equals(MeasureUnit.forIdentifier(identifier))); + + assertEquals(identifier + ": Complexity", + MeasureUnit.Complexity.MIXED, + unit.getComplexity()); + + List subUnits = unit.splitToSingleUnits(); + assertEquals(identifier + ": Length", subIdentifierCount, subUnits.size()); + for (int i = 0; ; i++) { + if (i >= subIdentifierCount || i >= subUnits.size()) break; + assertEquals(identifier + ": Sub-unit #" + i, + subIdentifiers[i], + subUnits.get(i).getIdentifier()); + } + } }