diff --git a/build.gradle b/build.gradle index 29e7bce..f7113f2 100644 --- a/build.gradle +++ b/build.gradle @@ -8,13 +8,16 @@ apply plugin: "maven-publish" apply plugin: "signing" group = "nl.colorize" -version = "2023.15" -sourceCompatibility = "17" -targetCompatibility = "17" +version = "2023.16" compileJava.options.encoding = "UTF-8" -sourceSets.main.java.srcDirs = ["source"] -sourceSets.main.resources.srcDirs = ["resources"] -sourceSets.test.java.srcDirs = ["test"] + +java { + sourceCompatibility = "17" + targetCompatibility = "17" + sourceSets.main.java.srcDirs = ["source"] + sourceSets.main.resources.srcDirs = ["resources"] + sourceSets.test.java.srcDirs = ["test"] +} repositories { mavenCentral() @@ -37,7 +40,7 @@ test { jacocoTestReport { afterEvaluate { classDirectories.from = files(classDirectories.files.collect { - fileTree(dir: it, exclude: ["**/swing/**"]) + fileTree(dir: it, exclude: ["**/swing/**", "**/Platform*"]) }) } } diff --git a/readme.md b/readme.md index 77a524f..380e0ed 100644 --- a/readme.md +++ b/readme.md @@ -25,13 +25,13 @@ to the dependencies section in `pom.xml`: nl.colorize colorize-java-commons - 2023.15 + 2023.16 The library can also be used in Gradle projects: dependencies { - implementation "nl.colorize:colorize-java-commons:2023.15" + implementation "nl.colorize:colorize-java-commons:2023.16" } Documentation diff --git a/source/nl/colorize/util/CSVRecord.java b/source/nl/colorize/util/CSVRecord.java index 47442f8..9aa6098 100644 --- a/source/nl/colorize/util/CSVRecord.java +++ b/source/nl/colorize/util/CSVRecord.java @@ -70,14 +70,6 @@ public String get(String column) { return cells.get(columnIndex); } - /** - * Returns the list of cells in this CSV record. The list will be sorted to - * match the order in which the cells appear in the CSV. - */ - public List getCells() { - return ImmutableList.copyOf(cells); - } - public boolean hasColumnNameInformation() { return !columns.isEmpty(); } diff --git a/source/nl/colorize/util/FileUtils.java b/source/nl/colorize/util/FileUtils.java index 10e48d0..b8c24bc 100644 --- a/source/nl/colorize/util/FileUtils.java +++ b/source/nl/colorize/util/FileUtils.java @@ -243,8 +243,12 @@ private static List getDirectoryContents(File dir) { * with only files matching the filter being returned. */ public static List walkFiles(File dir, Predicate filter) throws IOException { - if (!dir.isDirectory() && filter.test(dir)) { - return Collections.singletonList(dir); + if (!dir.isDirectory()) { + if (filter.test(dir)) { + return Collections.singletonList(dir); + } else { + return Collections.emptyList(); + } } return Files.walk(dir.toPath()) diff --git a/source/nl/colorize/util/PropertyDeserializer.java b/source/nl/colorize/util/PropertyDeserializer.java index 64d96de..3ed500c 100644 --- a/source/nl/colorize/util/PropertyDeserializer.java +++ b/source/nl/colorize/util/PropertyDeserializer.java @@ -15,6 +15,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Properties; import java.util.UUID; import java.util.function.Function; import java.util.logging.Logger; @@ -173,4 +174,53 @@ public Optional attempt(String value, Class type) { return Optional.empty(); } } + + // Convenience methods for common data types + + public String parseString(String value, String defaultValue) { + return parse(value, String.class, defaultValue); + } + + public int parseInt(String value, int defaultValue) { + return parse(value, int.class, defaultValue); + } + + public long parseLong(String value, long defaultValue) { + return parse(value, long.class, defaultValue); + } + + public float parseFloat(String value, float defaultValue) { + return parse(value, float.class, defaultValue); + } + + public double parseDouble(String value, double defaultValue) { + return parse(value, double.class, defaultValue); + } + + public boolean parseBool(String value, boolean defaultValue) { + return parse(value, boolean.class, defaultValue); + } + + /** + * Returns a {@link PropertyDeserializer} that acts as a live view for + * parsing values from the specified {@link Properties} object. + */ + public static PropertyDeserializer fromProperties(Properties properties) { + PropertyDeserializer propertyDeserializer = new PropertyDeserializer(); + propertyDeserializer.registerPreprocessor(properties::getProperty); + return propertyDeserializer; + } + + /** + * Returns a {@link PropertyDeserializer} that acts as a live view for + * parsing values from the specified CSV record. + */ + public static PropertyDeserializer fromCSV(CSVRecord record) { + Preconditions.checkArgument(record.hasColumnNameInformation(), + "CSV is missing column name information"); + + PropertyDeserializer propertyDeserializer = new PropertyDeserializer(); + propertyDeserializer.registerPreprocessor(record::get); + return propertyDeserializer; + } } diff --git a/source/nl/colorize/util/Subscribable.java b/source/nl/colorize/util/Subscribable.java index f774d9e..c4e7d1b 100644 --- a/source/nl/colorize/util/Subscribable.java +++ b/source/nl/colorize/util/Subscribable.java @@ -15,6 +15,7 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.Consumer; import java.util.function.Function; +import java.util.function.Predicate; import java.util.logging.Logger; /** @@ -62,6 +63,10 @@ private List prepareList() { return new CopyOnWriteArrayList<>(); } + /** + * Publishes the next event to all event subscribers. If subscriptions + * have already been disposed, calling this method does nothing. + */ public void next(T event) { if (disposed) { return; @@ -74,6 +79,12 @@ public void next(T event) { history.add(event); } + /** + * Publishes the next error to all error subscribers. If no error + * subscribers exist, publishing an error will result in the error + * being logged. If subscriptions have already been disposed, calling + * this method does nothing. + */ public void nextError(Exception error) { if (disposed) { return; @@ -95,8 +106,15 @@ public void nextError(Exception error) { * subscribers. If the operation completes normally, the return value is * published to subscribers. If the operation produces an exception, this * exception is published to error subscribers. + *

+ * If subscriptions have already been disposed, calling this method does + * nothing. */ public void next(Callable operation) { + if (disposed) { + return; + } + try { T event = operation.call(); next(event); @@ -105,12 +123,27 @@ public void next(Callable operation) { } } + /** + * Registers the specified callback functions as event and error subscribers, + * respectively. The subscribers will immediately be notified of previously + * published events and/or errors. + * + * @throws IllegalStateException when trying to subscribe when subscriptions + * have alreasdy been disposed. + */ public Subscribable subscribe(Consumer onEvent, Consumer onError) { subscribe(onEvent); subscribeErrors(onError); return this; } + /** + * Registers the specified callback function as event subscriber. The + * subscriber will immediately be notified of previously published events. + * + * @throws IllegalStateException when trying to subscribe when subscriptions + * have alreasdy been disposed. + */ public Subscribable subscribe(Consumer onEvent) { Preconditions.checkState(!disposed, "Subscribable has already been disposed"); eventSubscribers.add(onEvent); @@ -118,6 +151,13 @@ public Subscribable subscribe(Consumer onEvent) { return this; } + /** + * Registers the specified callback function as error subscriber. The + * subscriber will immediately be notified of previously published errors. + * + * @throws IllegalStateException when trying to subscribe when subscriptions + * have alreasdy been disposed. + */ public Subscribable subscribeErrors(Consumer onError) { Preconditions.checkState(!disposed, "Subscribable has already been disposed"); errorSubscribers.add(onError); @@ -138,6 +178,11 @@ public void dispose() { errorSubscribers.clear(); } + /** + * Returns a new {@link Subscribable} that will forward events to its own + * subscribers, but first uses the specified mapping function on each event. + * Errors will be forwarded as-is. + */ public Subscribable map(Function mapper) { Subscribable mapped = new Subscribable<>(); subscribe(event -> { @@ -152,6 +197,22 @@ public Subscribable map(Function mapper) { return mapped; } + /** + * Returns a new {@link Subscribable} that will forward events to its own + * subscribers, but only if the event matches the specified predicate. + * Errors will be forwarded as-is. + */ + public Subscribable filter(Predicate predicate) { + Subscribable filtered = new Subscribable<>(); + subscribe(event -> { + if (predicate.test(event)) { + filtered.next(event); + } + }); + subscribeErrors(filtered::nextError); + return filtered; + } + /** * Returns a {@link Promise} that is based on the first event or the first * error produced by this {@link Subscribable}. If multiple events diff --git a/source/nl/colorize/util/TextUtils.java b/source/nl/colorize/util/TextUtils.java index 339a890..dd3d7a3 100644 --- a/source/nl/colorize/util/TextUtils.java +++ b/source/nl/colorize/util/TextUtils.java @@ -404,33 +404,6 @@ public static float calculateRelativeLevenshteinDistance(String a, String b) { relativeDistance = Math.min(relativeDistance, 1f); return relativeDistance; } - - /** - * Returns all candidates that "fuzzy match" the specified string. Fuzzy matching - * is done using the - * Levenshtein distance, relative to the length of the string, against the - * provided threshold. - * - * @throws IllegalArgumentException if the threshold is outside the range between - * 0.0 (strings must be equal) and 1.0 (any string is allowed). - */ - public static List fuzzyMatch(String str, Collection candidates, float threshold) { - Preconditions.checkArgument(threshold >= 0f && threshold <= 1f, - "Threshold is outside range 0.0 - 1.0: " + threshold); - - List fuzzyMatches = new ArrayList<>(); - for (String candidate : candidates) { - if (candidate != null && isFuzzyMatch(str, candidate, threshold)) { - fuzzyMatches.add(candidate); - } - } - return fuzzyMatches; - } - - private static boolean isFuzzyMatch(String str, String candidate, float threshold) { - float distance = calculateRelativeLevenshteinDistance(str, candidate); - return distance <= threshold; - } private static BufferedReader toBufferedReader(Reader reader) { if (reader instanceof BufferedReader) { diff --git a/source/nl/colorize/util/TranslationBundle.java b/source/nl/colorize/util/TranslationBundle.java index 802aefb..bd9277e 100644 --- a/source/nl/colorize/util/TranslationBundle.java +++ b/source/nl/colorize/util/TranslationBundle.java @@ -43,7 +43,7 @@ public final class TranslationBundle { /** * Creates a {@link TranslationBundle} based on the specified default - * translation. Additional translations can be added afterwards. + * translation. Additional translations can be added afterward. *

* In most cases, translation data will be stored in {@code .properties} * files. The factory methods {@link #fromProperties(Properties)} and/or @@ -52,11 +52,23 @@ public final class TranslationBundle { * and then using this constructor. */ private TranslationBundle(Map defaultTranslation) { + //TODO cannot use Map.copyOf() until it's supported by TeaVM. this.defaultTranslation = ImmutableMap.copyOf(defaultTranslation); this.translations = new HashMap<>(); } + /** + * Adds a translation for the specified locale. The default translation + * will act as a fallback for any keys that are not included in the + * translation. + * + * @throws IllegalArgumentException if this {@link TranslationBundle} + * already includes a translation for the same locale. + */ public void addTranslation(Locale locale, TranslationBundle translation) { + Preconditions.checkArgument(!translations.containsKey(locale), + "Translation for locale already exists: " + locale); + translations.put(locale, translation); } @@ -120,12 +132,14 @@ public Set getKeys(Locale locale) { } public Set getKeys() { + //TODO cannot use Set.copyOf() until it's supported by TeaVM. return ImmutableSet.copyOf(defaultTranslation.keySet()); } /** * Factory method that creates a {@link TranslationBundle} from a map with - * key/value pairs for the default translation. + * key/value pairs for the default translation. Additional translations + * can be added afterward. */ public static TranslationBundle fromMap(Map defaultTranslation) { return new TranslationBundle(defaultTranslation); @@ -133,7 +147,8 @@ public static TranslationBundle fromMap(Map defaultTranslation) /** * Factory method that creates a {@link TranslationBundle} from an existing - * {@link Properties} instance. + * {@link Properties} instance with key/value pairs for the default + * translation. Additional translations can be added afterward. */ public static TranslationBundle fromProperties(Properties defaultTranslation) { return new TranslationBundle(LoadUtils.toMap(defaultTranslation)); diff --git a/source/nl/colorize/util/animation/KeyFrame.java b/source/nl/colorize/util/animation/KeyFrame.java index 2126ecb..eba155a 100644 --- a/source/nl/colorize/util/animation/KeyFrame.java +++ b/source/nl/colorize/util/animation/KeyFrame.java @@ -9,9 +9,10 @@ import com.google.common.base.Preconditions; /** - * Defines a property's value at a certain point in time. Key frames are placed - * on a timeline, which can then animate the property by interpolating between - * key frames. + * Defines a property's value at a certain point in time, used in conjunction + * with {@link Timeline}. The key frame's {@code time} indicates its position + * on the timeline, in seconds. The timeline will then use the key frame data, + * interpolating between key frames when necessary. */ public record KeyFrame(float time, float value) implements Comparable { @@ -23,4 +24,9 @@ public record KeyFrame(float time, float value) implements Comparable public int compareTo(KeyFrame other) { return Float.compare(time, other.time); } + + @Override + public String toString() { + return time + ": " + value; + } } diff --git a/source/nl/colorize/util/animation/Timeline.java b/source/nl/colorize/util/animation/Timeline.java index b876878..efd555b 100644 --- a/source/nl/colorize/util/animation/Timeline.java +++ b/source/nl/colorize/util/animation/Timeline.java @@ -8,35 +8,42 @@ import com.google.common.base.Preconditions; -import java.util.SortedSet; -import java.util.TreeSet; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; /** - * Animates the value of a property over time, by interpolating between key - * frames. Timelines implement the {@link Animatable} interface and can be - * played as animations, notifying a number of observers on every frame. The - * duration of the animation is equal to the position of the last key frame, - * although timelines also have the option to loop infinitely or until - * stopped manually. + * Animates the value of a property over time by interpolating between key + * frames. The duration of the animation is equal to the position of the last + * key fram. Timelines can end once its duration has been reached, or loop to + * restart playback from the beginning. */ public class Timeline implements Animatable { - private float playhead; - private SortedSet keyframes; + private List keyFrames; private Interpolation interpolationMethod; private boolean loop; + private float playhead; + private int nextKeyFrameIndex; + private static final float EPSILON = 0.001f; - + + /** + * Creates a timeline that will interpolate between key frames using the + * specified interpolation method. + */ public Timeline(Interpolation interpolationMethod, boolean loop) { - this.playhead = 0f; - this.keyframes = new TreeSet<>(); + this.keyFrames = new ArrayList<>(); this.interpolationMethod = interpolationMethod; - this.loop = loop; + this.loop = loop; + + this.playhead = 0f; + this.nextKeyFrameIndex = 0; } /** - * Creates a new timeline that uses the specified interpolation method and + * Creates a timeline that uses the specified interpolation method and * will not loop. */ public Timeline(Interpolation interpolationMethod) { @@ -44,49 +51,69 @@ public Timeline(Interpolation interpolationMethod) { } /** - * Creates a new timeline that uses the linear interpolation and will not - * loop. + * Creates a timeline that uses linear interpolation and will not loop. */ public Timeline() { this(Interpolation.LINEAR, false); } /** - * Moves the playhead to the specified position. The playhead cannot go - * out-of-bounds, it will be restricted to the range (0 - duration). - * @param position New position of the playhead, in seconds. - * @throws IllegalStateException if this timeline has no key frames. + * Moves the position of the playhead to the specified value, in seconds. + * The playhead is restricted to the range between zero and the timeline's + * duration as returned by {@link #getDuration()}. */ public void setPlayhead(float position) { - Preconditions.checkState(!keyframes.isEmpty(), "Timeline has no key frames"); - float duration = getDuration(); if (loop && position > duration) { playhead = position % duration; + scanNextKeyFrame(true); + } else if (position >= playhead) { + playhead = Math.min(position, duration); + scanNextKeyFrame(false); } else { - playhead = position; + playhead = Math.max(position, 0f); + scanNextKeyFrame(true); + } + } + + /** + * Scans the timeline for the position of the next key frame, relative + * to the current position of the playhead. If we know the playhead is + * moving forward, we don't need to scan every single key frame, which + * helps performance for huge timelines. + */ + private void scanNextKeyFrame(boolean full) { + if (keyFrames.isEmpty()) { + nextKeyFrameIndex = 0; + return; + } + + int start = full ? 0 : nextKeyFrameIndex; + + for (int i = start; i < keyFrames.size(); i++) { + nextKeyFrameIndex = i; + if (keyFrames.get(i).time() > playhead) { + break; + } } - - playhead = Math.max(playhead, 0f); - playhead = Math.min(playhead, duration); } /** - * Moves the playhead by the specified amount. The same restrictions apply - * as with {@link #setPlayhead(float)}. Passing a negative value will - * move the playhead backwards. - * @param amount Amount to move the playhead, in seconds. - * @throws IllegalStateException if this timeline has no key frames. + * Moves the playhead by the specified amount, in seconds. Passing a + * negative value will move the playhead backwards. The playhead is + * restricted to the range between zero and the timeline's duration as + * returned by {@link #getDuration()}. */ - public void movePlayhead(float amount) { - setPlayhead(playhead + amount); + public void movePlayhead(float deltaTime) { + setPlayhead(playhead + deltaTime); } - + /** - * Moves the playhead forward by {@code deltaTime}. - * @param deltaTime Amount to move the playhead, in seconds. - * @throws IllegalStateException if this timeline has no key frames. + * Moves the playhead by the specified amount, in seconds. Passing a + * negative value will move the playhead backwards. The playhead is + * restricted to the range between zero and the timeline's duration as + * returned by {@link #getDuration()}. */ @Override public void onFrame(float deltaTime) { @@ -97,51 +124,53 @@ public void onFrame(float deltaTime) { * Moves the playhead back to the start of the timeline. */ public void reset() { - if (!keyframes.isEmpty()) { - setPlayhead(0f); - } + setPlayhead(0f); } /** * Moves the playhead to the end of the timeline. */ public void end() { - if (keyframes.isEmpty()) { - setPlayhead(0f); - } else { - setPlayhead(getDuration()); - } + setPlayhead(getDuration()); } /** - * Returns the position of the playhead, in seconds. + * Returns the current position of the playhead, in seconds. The value of + * the playhead will exist somewhere in the range between zero and the + * value of {@link #getDuration()}. */ public float getPlayhead() { return playhead; } /** - * Returns the position of the last key frame, in seconds. If the timeline - * contains no key frames the duration will be 0. + * Returns this timeline's duration, which is based on the position of the + * last key frame. If the timeline does not contain any key frames, this + * method will return zero. */ public float getDuration() { - if (keyframes.isEmpty()) { - return 0; + if (keyFrames.isEmpty()) { + return 0f; } - return keyframes.last().time(); + + KeyFrame last = keyFrames.get(keyFrames.size() - 1); + return last.time(); } /** - * Returns the position of the playhead as a number between 0 and 1, where - * 0 is the beginning of the timeline and 1 is its duration. + * Returns the position of the playhead as a number between 0 and 1, + * where 0 is the beginning of the timeline and 1 is the timeline's + * duration. */ public float getDelta() { - if (isAtStart()) { + float duration = getDuration(); + + if (playhead <= 0f) { return 0f; - } else if (isCompleted()) { + } else if (playhead >= duration) { return 1f; } else { - return playhead / getDuration(); + return playhead / duration; } } @@ -156,190 +185,101 @@ public boolean isAtStart() { * Returns true if the playhead has reached the end of the timeline. */ public boolean isCompleted() { - if (keyframes.isEmpty()) { + if (keyFrames.isEmpty()) { return false; } + return playhead >= (getDuration() - EPSILON); } - - /** - * Returns true if this timeline is "playing" (e.g. if the playhead is - * positioned somewhere between the beginning and the end of the timeline). - */ - public boolean isPlaying() { - return !isAtStart() && !isCompleted(); - } - public Interpolation getInterpolationMethod() { - return interpolationMethod; - } - public boolean isLoop() { return loop; } /** - * Adds a key frame to this timeline. - * @return This timeline, for method chaining; - * @throws IllegalArgumentException if a key frame already exists at that position. - */ - public Timeline addKeyFrame(KeyFrame keyframe) { - float position = keyframe.time(); - Preconditions.checkArgument(getKeyFrameAtPosition(position) == null, - "Key frame already exists at position: " + position); - keyframes.add(keyframe); - return this; - } - - /** - * Adds a key frame with the specified position and value to this timeline. - * @param position Position of the key frame on the timeline, in seconds. - * @return This timeline, for method chaining; - * @throws IllegalArgumentException if a key frame already exists at that position. - */ - public Timeline addKeyFrame(float position, float value) { - return addKeyFrame(new KeyFrame(position, value)); - } - - public void removeKeyFrame(KeyFrame keyframe) { - keyframes.remove(keyframe); - } - - /** - * Removes the key frame at the specified position. - * @deprecated Use {@link #removeKeyFrame(KeyFrame)} instead. + * Adds a key frame to this timeline. If the key frame's position is + * located before the last key frame in this timeline, the + * new key frame will not be inserted at the end but instead at its + * appropriate location. + * + * @return This timeline, for method chaining. + * @throws IllegalStateException if this timeline already has a key + * frame at the same position. */ - @Deprecated - public void removeKeyFrame(float position) { - KeyFrame atPosition = getKeyFrameAtPosition(position); - if (atPosition != null) { - keyframes.remove(atPosition); + public Timeline addKeyFrame(KeyFrame keyFrame) { + if (keyFrames.isEmpty() || keyFrame.time() > getDuration()) { + keyFrames.add(keyFrame); + return this; } - } - - public void removeAllKeyFrames() { - keyframes.clear(); - } - - /** - * Returns all key frames that have been added. The key frames are sorted by - * their position on the timeline. - */ - public SortedSet getKeyFrames() { - return keyframes; + + // Checking all key frames is relatively expensive if the + // timeline is huge. However, the overwhelming majority + // of timelines are *not* huge, and even for the huge ones + // key frames tend to be inserted in order. + Preconditions.checkState(!checkKeyFrame(keyFrame.time()), + "Timeline already contains a key frame at position " + keyFrame.time()); + + keyFrames.add(keyFrame); + Collections.sort(keyFrames); + return this; } - /** - * Returns the key frame with the lowest position. - * - * @throws IllegalStateException if this timeline does not contain any key frames. - */ - public KeyFrame getFirstKeyFrame() { - Preconditions.checkState(!keyframes.isEmpty(), "Timeline does not contain key frames"); - return keyframes.first(); + private boolean checkKeyFrame(float position) { + return keyFrames.stream() + .map(KeyFrame::time) + .anyMatch(t -> t >= position - EPSILON && t <= position + EPSILON); } /** - * Returns the key frame with the highest position. + * Adds a key frame with the specified position (in seconds) and value. + * If the key frame's position is located before the last key + * frame in this timeline, the new key frame will not be inserted at + * the end but instead at its appropriate location. * - * @throws IllegalStateException if this timeline does not contain any key frames. + * @return This timeline, for method chaining. + * @throws IllegalStateException if this timeline already has a key + * frame at the same position. */ - public KeyFrame getLastKeyFrame() { - Preconditions.checkState(!keyframes.isEmpty(), "Timeline does not contain key frames"); - return keyframes.last(); + public Timeline addKeyFrame(float position, float value) { + KeyFrame keyFrame = new KeyFrame(position, value); + return addKeyFrame(keyFrame); } - - /** - * Returns the key frame on this timeline that is closest to and less than - * or equal to {@code position}. If there is only one key frame that will be - * returned, even if it's position is after {@code position}. - * - * @throws IllegalStateException if this timeline does not contain any key frames. - */ - protected KeyFrame getClosestKeyFrameBefore(float position) { - Preconditions.checkState(!keyframes.isEmpty(), "Timeline does not contain key frames"); - - KeyFrame closest = keyframes.first(); - for (KeyFrame keyframe : keyframes) { - if (keyframe.time() <= position) { - closest = keyframe; - } else { - break; - } - } - - return closest; - } - - private KeyFrame getKeyFrameAtPosition(float position) { - for (KeyFrame keyframe : keyframes) { - if (hasKeyFrameAtPosition(keyframe, position)) { - return keyframe; - } - } - return null; - } - - public boolean hasKeyFrameAtPosition(float position) { - return getKeyFrameAtPosition(position) != null; - } - - private boolean hasKeyFrameAtPosition(KeyFrame keyframe, float position) { - return keyframe.time() >= position - EPSILON && keyframe.time() <= position + EPSILON; + public void removeKeyFrame(KeyFrame keyFrame) { + keyFrames.remove(keyFrame); + setPlayhead(Math.min(playhead, getDuration())); } /** - * Returns the value of the property that is animated by this timeline. The - * value depends on the key frames that have been added, the current position - * of the playhead, and the used interpolation method. - * @throws IllegalStateException if this timeline does not contain any key frames. + * Returns the value of the property that is animated by this timeline, + * based on the current position of the playhead. If the playhead is + * positioned in between key frames, interpolation will be used to + * determine the current value. */ public float getValue() { - Preconditions.checkState(!keyframes.isEmpty(), "Timeline has no key frames"); - - if (keyframes.size() == 1) { - return keyframes.first().value(); - } else { - return getInterpolatedValue(); + if (keyFrames.isEmpty()) { + return 0f; } - } - - /** - * Returns the property's current value, based on the current position of - * the playhead. - */ - private float getInterpolatedValue() { - KeyFrame currentKeyFrame = null; - KeyFrame nextKeyFrame = null; - - for (KeyFrame keyframe : keyframes) { - if (keyframe.time() <= playhead) { - currentKeyFrame = keyframe; - } else { - if (currentKeyFrame == null) { - currentKeyFrame = keyframe; - } - nextKeyFrame = keyframe; - break; - } + + if (nextKeyFrameIndex == 0) { + return keyFrames.get(0).value(); } - - return interpolateValue(currentKeyFrame, nextKeyFrame); + + KeyFrame prev = keyFrames.get(nextKeyFrameIndex - 1); + KeyFrame next = keyFrames.get(nextKeyFrameIndex); + return interpolateValue(prev, next); } /** - * Interpolates between two key frames, based on the position of the playhead - * and the used interpolation method. + * Interpolates between the specified two key frames based on the current + * position of the playhead. */ private float interpolateValue(KeyFrame prev, KeyFrame next) { - if (next == null || prev == next) { - return prev.value(); - } - if (playhead <= prev.time()) { return prev.value(); - } else if (playhead >= next.time()) { + } + + if (playhead >= next.time()) { return next.value(); } @@ -350,4 +290,16 @@ private float interpolateValue(KeyFrame prev, KeyFrame next) { return interpolationMethod.interpolate(prev.value(), next.value(), relativeDelta); } + + @Override + public String toString() { + StringBuilder buffer = new StringBuilder(); + buffer.append("Timeline\n"); + for (KeyFrame keyFrame : keyFrames) { + buffer.append(" "); + buffer.append(keyFrame); + buffer.append("\n"); + } + return buffer.toString(); + } } diff --git a/source/nl/colorize/util/swing/Popups.java b/source/nl/colorize/util/swing/Popups.java index 8545f1e..3cdc2bc 100755 --- a/source/nl/colorize/util/swing/Popups.java +++ b/source/nl/colorize/util/swing/Popups.java @@ -6,8 +6,8 @@ package nl.colorize.util.swing; +import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; -import nl.colorize.util.LogHelper; import nl.colorize.util.TranslationBundle; import javax.swing.JComponent; @@ -19,7 +19,6 @@ import java.awt.BorderLayout; import java.util.Arrays; import java.util.List; -import java.util.logging.Logger; /** * Utility class for working with pop-up windows. This class can be used instead @@ -40,7 +39,6 @@ public final class Popups { private static final TranslationBundle BUNDLE = SwingUtils.getCustomComponentsBundle(); private static final String DEFAULT_OK = BUNDLE.getString("Popups.ok"); private static final String DEFAULT_CANCEL = BUNDLE.getString("Popups.cancel"); - private static final Logger LOGGER = LogHelper.getLogger(Popups.class); private Popups() { } @@ -55,7 +53,7 @@ private Popups() { * @throws IllegalArgumentException if no buttons were supplied. */ public static int message(JFrame parent, String title, JComponent panel, List buttons) { - checkDialogProperties(title, buttons); + Preconditions.checkArgument(!buttons.isEmpty(), "Missing buttons"); // Wrap the panel inside another panel so that it will be displayed // at its intended size. @@ -75,16 +73,6 @@ public static int message(JFrame parent, String title, JComponent panel, List buttons) { - if (buttons.isEmpty()) { - throw new IllegalArgumentException("Invalid number of buttons: " + buttons.size()); - } - - if (title == null || title.isEmpty()) { - LOGGER.warning("Pop-up window has no title, which is against UI guidelines"); - } - } - private static int getSelectedPopupButton(JOptionPane pane, List buttons) { Object value = pane.getValue(); for (int i = 0; i < buttons.size(); i++) { diff --git a/source/nl/colorize/util/swing/Utils2D.java b/source/nl/colorize/util/swing/Utils2D.java index 54d4189..1f965ac 100755 --- a/source/nl/colorize/util/swing/Utils2D.java +++ b/source/nl/colorize/util/swing/Utils2D.java @@ -144,6 +144,7 @@ public static void savePNG(BufferedImage image, File dest) throws IOException { */ public static void saveJPEG(BufferedImage image, OutputStream output) throws IOException { ImageWriter writer = ImageIO.getImageWritersByFormatName("JPEG").next(); + try (ImageOutputStream ios = ImageIO.createImageOutputStream(output)) { writer.setOutput(ios); ImageWriteParam param = writer.getDefaultWriteParam(); @@ -168,6 +169,7 @@ public static void saveJPEG(BufferedImage image, File dest) throws IOException { /** * Converts a {@code BufferedImage} to the specified image format. If the image * already has the correct type this method does nothing. + * * @param type Requested image format, for example {@code BufferedImage.TYPE_INT_ARGB}. */ public static BufferedImage convertImage(BufferedImage image, int type) { @@ -250,6 +252,7 @@ public static BufferedImage addPadding(BufferedImage sourceImage, int padding) { * Scales an image to the specified dimensions. The image's aspect ratio is * ignored, meaning the image will appear stretched or squashed if the * target width/height have a different aspect ratio. + * * @param highQuality Improves the quality of the scaled image, at the cost * of performance. */ @@ -265,11 +268,21 @@ public static BufferedImage scaleImage(Image original, int targetWidth, int targ g2.dispose(); return rescaled; } + + /** + * Scales an image to the specified dimensions. The image's aspect ratio is + * ignored, meaning the image will appear stretched or squashed if the + * target width/height have a different aspect ratio. + */ + public static BufferedImage scaleImage(Image original, int targetWidth, int targetHeight) { + return scaleImage(original, targetWidth, targetHeight, false); + } /** * Scales an image while maintaining its aspect ratio. This will prevent the * image from looking stretched or squashed if the target width/height have a - * different aspect ratio. + * different aspect ratio. + * * @param highQuality Improves the quality of the scaled image, at the cost * of performance. */ @@ -299,6 +312,15 @@ public static BufferedImage scaleImageProportional(Image original, int targetWid return scaled; } + /** + * Scales an image while maintaining its aspect ratio. This will prevent the + * image from looking stretched or squashed if the target width/height have a + * different aspect ratio. + */ + public static BufferedImage scaleImageProportional(Image original, int width, int height) { + return scaleImageProportional(original, width, height, false); + } + /** * Creates a data URL out of an existing image. The data URL will be based * on a PNG image. diff --git a/test/nl/colorize/util/CSVRecordTest.java b/test/nl/colorize/util/CSVRecordTest.java index fc1123b..3be380f 100644 --- a/test/nl/colorize/util/CSVRecordTest.java +++ b/test/nl/colorize/util/CSVRecordTest.java @@ -142,4 +142,9 @@ void getColumns() { assertEquals(List.of("Name", "Age"), records.get(0).getColumns()); assertEquals("[(Name, John), (Age, 38)]", records.get(0).getColumnValues().toString()); } + + @Test + void parseEmptyCSV() { + assertEquals(0, CSVRecord.parseCSV("", ";", true).size()); + } } diff --git a/test/nl/colorize/util/FileUtilsTest.java b/test/nl/colorize/util/FileUtilsTest.java index 01544fe..a3f54bf 100644 --- a/test/nl/colorize/util/FileUtilsTest.java +++ b/test/nl/colorize/util/FileUtilsTest.java @@ -17,6 +17,7 @@ import java.util.List; import static com.google.common.base.Charsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -190,6 +191,7 @@ void formatFileSize() { assertEquals("123.4 MB", FileUtils.formatFileSize(123_400_000L)); assertEquals("1.2 GB", FileUtils.formatFileSize(1_234_000_000L)); assertEquals("123.5 GB", FileUtils.formatFileSize(123_456_789_000L)); + assertEquals("2.0 TB", FileUtils.formatFileSize(1_999_999_999_999L)); } @Test @@ -207,4 +209,34 @@ void walkFiles(@TempDir File tempDir) throws IOException { assertEquals("b.txt", files.get(1).getName()); assertEquals("d1.txt", files.get(2).getName()); } + + @Test + void walkFilesOnSingleFile(@TempDir File tempDir) throws IOException { + File tempFile = new File(tempDir, "test.txt"); + FileUtils.write("1234", UTF_8, tempFile); + + assertEquals(1, FileUtils.walkFiles(tempFile, f -> f.getName().contains("test")).size()); + assertEquals(0, FileUtils.walkFiles(tempFile, f -> f.getName().contains("no")).size()); + } + + @Test + void createTempFile() throws IOException { + byte[] contents = {1, 2, 3, 4}; + File tempFile = FileUtils.createTempFile(contents); + + assertTrue(tempFile.exists()); + assertArrayEquals(contents, Files.readAllBytes(tempFile.toPath())); + assertEquals("4 bytes", FileUtils.formatFileSize(tempFile)); + } + + @Test + void writeBinaryFile(@TempDir File tempDir) throws IOException { + File tempFile = new File(tempDir, "test"); + byte[] contents = {1, 2, 3, 4}; + FileUtils.write(contents, tempFile); + + assertTrue(tempFile.exists()); + assertArrayEquals(contents, Files.readAllBytes(tempFile.toPath())); + assertEquals("4 bytes", FileUtils.formatFileSize(tempFile)); + } } diff --git a/test/nl/colorize/util/PropertyDeserializerTest.java b/test/nl/colorize/util/PropertyDeserializerTest.java index d215faa..f63723b 100644 --- a/test/nl/colorize/util/PropertyDeserializerTest.java +++ b/test/nl/colorize/util/PropertyDeserializerTest.java @@ -83,4 +83,52 @@ void preprocessor() { assertEquals(-1, withoutPreprocessor.parse("a", int.class, -1)); assertEquals(2, withPreprocessor.parse("a", int.class, -1)); } + + @Test + void fromProperties() { + Properties properties = new Properties(); + properties.setProperty("a", "2"); + properties.setProperty("b", "wrong"); + + PropertyDeserializer propertyDeserializer = PropertyDeserializer.fromProperties(properties); + + assertEquals(2, propertyDeserializer.parse("a", int.class, -1)); + assertEquals(-1, propertyDeserializer.parse("b", int.class, -1)); + assertEquals(-1, propertyDeserializer.parse("c", int.class, -1)); + } + + @Test + void shortHandForCommonDataTypes() { + Properties properties = new Properties(); + properties.setProperty("a", "2"); + properties.setProperty("b", "3.4"); + properties.setProperty("c", "true"); + properties.setProperty("d", "1234"); + properties.setProperty("e", "5.6"); + + PropertyDeserializer propertyDeserializer = PropertyDeserializer.fromProperties(properties); + + assertEquals(2, propertyDeserializer.parseInt("a", -1)); + assertEquals(3.4f, propertyDeserializer.parseFloat("b", -1f), 0.001f); + assertTrue(propertyDeserializer.parseBool("c", false)); + assertEquals(1234L, propertyDeserializer.parseLong("d", -1L)); + assertEquals(5.6, propertyDeserializer.parseDouble("e", -1f), 0.001); + assertEquals("5.6", propertyDeserializer.parseString("e", "")); + } + + @Test + void fromCSV() { + CSVRecord csv = CSVRecord.create(List.of("name", "age"), List.of("John", "38")); + PropertyDeserializer parser = PropertyDeserializer.fromCSV(csv); + + assertEquals("John", parser.parseString("name", "?")); + assertEquals(38, parser.parseInt("age", -1)); + } + + @Test + void csvMissingColumnNamesCannotBeParsed() { + CSVRecord csv = CSVRecord.create("John", "38"); + + assertThrows(IllegalArgumentException.class, () -> PropertyDeserializer.fromCSV(csv)); + } } diff --git a/test/nl/colorize/util/SubscribableTest.java b/test/nl/colorize/util/SubscribableTest.java index 8d2d185..cf71e03 100644 --- a/test/nl/colorize/util/SubscribableTest.java +++ b/test/nl/colorize/util/SubscribableTest.java @@ -171,4 +171,15 @@ void mapException() { assertEquals(0, originalErrors.size()); assertEquals(2, mappedErrors.size()); } + + @Test + void filter() { + List received = new ArrayList<>(); + + Subscribable.of("a", "b", "c") + .filter(x -> !x.equals("b")) + .subscribe(received::add); + + assertEquals("[a, c]", received.toString()); + } } diff --git a/test/nl/colorize/util/TextUtilsTest.java b/test/nl/colorize/util/TextUtilsTest.java index 1123de3..10198c5 100644 --- a/test/nl/colorize/util/TextUtilsTest.java +++ b/test/nl/colorize/util/TextUtilsTest.java @@ -112,18 +112,6 @@ public void testCalculateLevenshteinDistance() { assertEquals(1.0f, TextUtils.calculateRelativeLevenshteinDistance("", "test"), 0.01f); assertEquals(1.0f, TextUtils.calculateRelativeLevenshteinDistance("test", ""), 0.01f); } - - @Test - public void testFuzzyMatch() { - assertEquals(Arrays.asList("john smith", "john smit"), TextUtils.fuzzyMatch("john smith", - Arrays.asList("john smith", "john smit", "john", "pete"), 0.2f)); - assertEquals(Arrays.asList(), TextUtils.fuzzyMatch("john smith", Arrays.asList(), 0.1f)); - assertEquals(Arrays.asList("aaa", "aab"), TextUtils.fuzzyMatch("aaa", - Arrays.asList("aaa", "aab", "abb"), 0.35f)); - assertEquals(Arrays.asList("aaa"), TextUtils.fuzzyMatch("aaa", - Arrays.asList("aaa", "aab", "abb"), 0.1f)); - assertEquals(Arrays.asList("aaa"), TextUtils.fuzzyMatch("aaa", Arrays.asList("aaa"), 0f)); - } @Test void startsWith() { diff --git a/test/nl/colorize/util/animation/KeyFrameTest.java b/test/nl/colorize/util/animation/KeyFrameTest.java new file mode 100644 index 0000000..59bc992 --- /dev/null +++ b/test/nl/colorize/util/animation/KeyFrameTest.java @@ -0,0 +1,40 @@ +//----------------------------------------------------------------------------- +// Colorize Java Commons +// Copyright 2007-2023 Colorize +// Apache license (http://www.apache.org/licenses/LICENSE-2.0) +//----------------------------------------------------------------------------- + +package nl.colorize.util.animation; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class KeyFrameTest { + + private static final float EPSILON = 0.001f; + + @Test + void sortKeyFramesByTime() { + List keyFrames = new ArrayList<>(); + keyFrames.add(new KeyFrame(1f, 10f)); + keyFrames.add(new KeyFrame(2f, 20f)); + keyFrames.add(new KeyFrame(1.5f, 30f)); + Collections.sort(keyFrames); + + assertEquals(3, keyFrames.size()); + assertEquals(1f, keyFrames.get(0).time(), EPSILON); + assertEquals(1.5f, keyFrames.get(1).time(), EPSILON); + assertEquals(2f, keyFrames.get(2).time(), EPSILON); + } + + @Test + void stringForm() { + assertEquals("1.0: 10.0", new KeyFrame(1, 10).toString()); + assertEquals("0.25: 10.1", new KeyFrame(0.25f, 10.1f).toString()); + } +} diff --git a/test/nl/colorize/util/animation/TimelineTest.java b/test/nl/colorize/util/animation/TimelineTest.java index a6ba107..8bc29bb 100644 --- a/test/nl/colorize/util/animation/TimelineTest.java +++ b/test/nl/colorize/util/animation/TimelineTest.java @@ -6,12 +6,11 @@ package nl.colorize.util.animation; +import nl.colorize.util.Stopwatch; import nl.colorize.util.swing.AnimatedColor; import org.junit.jupiter.api.Test; import java.awt.Color; -import java.util.Iterator; -import java.util.SortedSet; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -39,14 +38,21 @@ public void testPlayhead() { Timeline timeline = new Timeline(Interpolation.LINEAR, false); timeline.addKeyFrame(5f, 0); assertEquals(0f, timeline.getPlayhead(), EPSILON); - assertEquals(0f, timeline.getDelta(), 0.01f); + assertEquals(0f, timeline.getDelta(), EPSILON); + assertTrue(timeline.isAtStart()); + timeline.setPlayhead(2f); assertEquals(2f, timeline.getPlayhead(), EPSILON); - assertEquals(0.4f, timeline.getDelta(), 0.01f); + assertEquals(0.4f, timeline.getDelta(), EPSILON); + assertFalse(timeline.isAtStart()); + timeline.reset(); assertEquals(0f, timeline.getPlayhead(), EPSILON); + assertEquals(0f, timeline.getDelta(), EPSILON); + timeline.end(); assertEquals(5f, timeline.getPlayhead(), EPSILON); + assertEquals(1f, timeline.getDelta(), EPSILON); } @Test @@ -79,8 +85,9 @@ public void testLoopingTimeline() { @Test public void testNoZeroTimeline() { Timeline timeline = new Timeline(Interpolation.LINEAR, false); + timeline.setPlayhead(1f); - assertThrows(IllegalStateException.class, () -> timeline.setPlayhead(1f)); + assertEquals(0f, timeline.getPlayhead(), EPSILON); } @Test @@ -91,21 +98,17 @@ public void testKeyFrames() { timeline.addKeyFrame(new KeyFrame(1f, 7.1f)); timeline.addKeyFrame(new KeyFrame(0.5f, 2f)); - SortedSet keyframes = timeline.getKeyFrames(); - assertEquals(4, keyframes.size()); - Iterator iterator = keyframes.iterator(); - KeyFrame keyframe = iterator.next(); - assertEquals(0f, keyframe.time(), EPSILON); - assertEquals(7f, keyframe.value(), EPSILON); - keyframe = iterator.next(); - assertEquals(0.5f, keyframe.time(), EPSILON); - assertEquals(2f, keyframe.value(), EPSILON); - keyframe = iterator.next(); - assertEquals(1f, keyframe.time(), EPSILON); - assertEquals(7.1f, keyframe.value(), 0.01f); - keyframe = iterator.next(); - assertEquals(3f, keyframe.time(), EPSILON); - assertEquals(10f, keyframe.value(), 0.01f); + timeline.setPlayhead(0f); + assertEquals(7f, timeline.getValue(), EPSILON); + + timeline.setPlayhead(0.5f); + assertEquals(2f, timeline.getValue(), EPSILON); + + timeline.setPlayhead(1f); + assertEquals(7.1f, timeline.getValue(), 0.01f); + + timeline.setPlayhead(3f); + assertEquals(10f, timeline.getValue(), 0.01f); } @Test @@ -119,21 +122,27 @@ public void testKeyFrameInterpolation() { Timeline timeline = new Timeline(Interpolation.LINEAR, false); timeline.addKeyFrame(new KeyFrame(0f, 2f)); timeline.addKeyFrame(new KeyFrame(1f, 3f)); - timeline.addKeyFrame(new KeyFrame(5f, 5f)); - assertEquals(Interpolation.LINEAR, timeline.getInterpolationMethod()); + KeyFrame last = new KeyFrame(5f, 5f); + timeline.addKeyFrame(last); assertEquals(2f, timeline.getValue(), 0.01f); + timeline.setPlayhead(0.5f); assertEquals(2.5f, timeline.getValue(), 0.01f); + timeline.setPlayhead(1f); assertEquals(3f, timeline.getValue(), 0.01f); + timeline.setPlayhead(2f); assertEquals(3.5f, timeline.getValue(), 0.01f); + timeline.setPlayhead(3f); assertEquals(4f, timeline.getValue(), 0.01f); + timeline.setPlayhead(5f); assertEquals(5f, timeline.getValue(), 0.01f); - timeline.removeKeyFrame(5f); + + timeline.removeKeyFrame(last); assertEquals(3f, timeline.getValue(), 0.01f); } @@ -142,6 +151,7 @@ public void testValueBeforeFirstKeyFrame() { Timeline timeline = new Timeline(); timeline.addKeyFrame(new KeyFrame(2f, 10f)); assertEquals(10f, timeline.getValue(), 0.01f); + timeline.addKeyFrame(new KeyFrame(0f, 7f)); assertEquals(7f, timeline.getValue(), 0.01f); } @@ -162,7 +172,7 @@ public void testBeforeTheFirstKeyFrame() { public void testNoKeyFrames() { Timeline timeline = new Timeline(); - assertThrows(IllegalStateException.class, () -> timeline.getValue()); + assertEquals(0f, timeline.getValue(), EPSILON); } @Test @@ -170,7 +180,7 @@ public void testAddKeyFrameTwice() { Timeline timeline = new Timeline(); timeline.addKeyFrame(new KeyFrame(1f, 2f)); - assertThrows(IllegalArgumentException.class, () -> timeline.addKeyFrame(new KeyFrame(1f, 3f))); + assertThrows(IllegalStateException.class, () -> timeline.addKeyFrame(new KeyFrame(1f, 3f))); } @Test @@ -179,27 +189,18 @@ public void testAddKeyFrameWhileAnimating() { timeline.addKeyFrame(0f, 10f); timeline.addKeyFrame(1f, 20f); timeline.movePlayhead(1f); + assertTrue(timeline.isCompleted()); - assertEquals(1f, timeline.getLastKeyFrame().time(), EPSILON); + assertEquals(20f, timeline.getValue(), EPSILON); + timeline.addKeyFrame(1.5f, 20f); - assertEquals(1.5f, timeline.getLastKeyFrame().time(), EPSILON); assertFalse(timeline.isCompleted()); + assertEquals(20f, timeline.getValue(), EPSILON); + timeline.movePlayhead(0.5f); assertTrue(timeline.isCompleted()); } - @Test - public void testClosestKeyFrame() { - Timeline timeline = new Timeline(); - timeline.addKeyFrame(0f, 0f); - assertEquals(0f, timeline.getClosestKeyFrameBefore(10).value(), EPSILON); - timeline.addKeyFrame(5f, 5f); - assertEquals(5f, timeline.getClosestKeyFrameBefore(10).value(), EPSILON); - timeline.addKeyFrame(11f, 11f); - assertEquals(5f, timeline.getClosestKeyFrameBefore(10).value(), EPSILON); - assertEquals(11f, timeline.getClosestKeyFrameBefore(11).value(), EPSILON); - } - @Test public void testKeyFrameComparator() { KeyFrame kf = new KeyFrame(1f, 10f); @@ -243,24 +244,107 @@ public void testAnimatedColorWithKeyFrames() { } @Test - @SuppressWarnings("deprecation") public void testReplaceKeyFrame() { Timeline timeline = new Timeline(); timeline.addKeyFrame(1f, 10f); - timeline.addKeyFrame(2f, 20f); + KeyFrame middle = new KeyFrame(2f, 20f); + timeline.addKeyFrame(middle); timeline.setPlayhead(20f); + timeline.removeKeyFrame(middle); + timeline.addKeyFrame(3f, 30f); + timeline.end(); - assertTrue(timeline.hasKeyFrameAtPosition(2f)); - assertEquals(20f, timeline.getLastKeyFrame().value(), EPSILON); - assertTrue(timeline.isCompleted()); + String expected = """ + Timeline + 1.0: 10.0 + 3.0: 30.0 + """; - timeline.removeKeyFrame(2f); - timeline.addKeyFrame(3f, 30f); + assertEquals(expected, timeline.toString()); + } - assertFalse(timeline.hasKeyFrameAtPosition(2f)); - assertTrue(timeline.hasKeyFrameAtPosition(3f)); - assertEquals(30f, timeline.getLastKeyFrame().value(), EPSILON); - assertFalse(timeline.isCompleted()); + @Test + void testTimelineWithoutKeyFrameAtZero() { + Timeline timeline = new Timeline(); + timeline.addKeyFrame(new KeyFrame(1f, 10f)); + + assertEquals(10f, timeline.getValue(), EPSILON); + + timeline.setPlayhead(0.5f); + assertEquals(10f, timeline.getValue(), EPSILON); + + timeline.setPlayhead(1f); + assertEquals(10f, timeline.getValue(), EPSILON); + } + + @Test + void insertKeyFramesInOrder() { + Timeline timeline = new Timeline(); + timeline.addKeyFrame(1.5f, 1f); + timeline.addKeyFrame(2f, 2f); + timeline.addKeyFrame(2.5f, 3f); + + String expected = """ + Timeline + 1.5: 1.0 + 2.0: 2.0 + 2.5: 3.0 + """; + + assertEquals(expected, timeline.toString()); + } + + @Test + void insertKeyFramesOutOfOrder() { + Timeline timeline = new Timeline(); + timeline.addKeyFrame(4f, 4f); + timeline.addKeyFrame(1.5f, 1f); + timeline.addKeyFrame(2.5f, 3f); + timeline.addKeyFrame(2f, 2f); + + String expected = """ + Timeline + 1.5: 1.0 + 2.0: 2.0 + 2.5: 3.0 + 4.0: 4.0 + """; + + assertEquals(expected, timeline.toString()); + } + + @Test + void cannotInsertSameKeyFrameTwice() { + Timeline timeline = new Timeline(); + timeline.addKeyFrame(1.2f, 1f); + + assertThrows(IllegalStateException.class, () -> timeline.addKeyFrame(1.2f, 2f)); + } + + @Test + void emptyTimelineIsNotConsideredCompleted() { + Timeline empty = new Timeline(); + + assertEquals(0f, empty.getPlayhead()); + assertEquals(0f, empty.getDuration()); + assertFalse(empty.isCompleted()); + } + + @Test + void performanceForHugeTimeline() { + Timeline timeline = new Timeline(); + Stopwatch timer = new Stopwatch(); + + for (int i = 0; i < 1_000_000; i++) { + timeline.addKeyFrame(i, i); + } + + for (int i = 0; i < 1_000_000; i++) { + timeline.setPlayhead(i); + } + + assertTrue(timer.tock() <= 2000L, + "Insufficient timeline performance: " + timer.tock()); } private void assertRGBA(Color color, int r, int g, int b, int a) {