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) {