+ * The rotate action rotates the ViewPlatform around the point of interest
+ * when the mouse is moved with the main mouse button pressed. The
+ * rotation is in the direction of the mouse movement, with a default
+ * rotation of 0.01 radians for each pixel of mouse movement.
+ *
+ * The zoom action moves the ViewPlatform closer to or further from the
+ * point of interest when the mouse is moved with the middle mouse button
+ * pressed (or Alt-main mouse button on systems without a middle mouse button).
+ * The default zoom action is to translate the ViewPlatform 0.01 units for each
+ * pixel of mouse movement. Moving the mouse up moves the ViewPlatform closer,
+ * moving the mouse down moves the ViewPlatform further away.
+ *
+ * The translate action translates the ViewPlatform when the mouse is moved
+ * with the right mouse button pressed. The translation is in the direction
+ * of the mouse movement, with a default translation of 0.01 units for each
+ * pixel of mouse movement.
+ *
+ * The actions can be reversed using the REVERSE_
ACTION
+ * constructor flags. The default action moves the ViewPlatform around the
+ * objects in the scene. The REVERSE_
ACTION flags can
+ * make the objects in the scene appear to be moving in the direction
+ * of the mouse movement.
+ */
+public class UprightOrbiter extends ViewPlatformAWTBehavior
+{
+ private Transform3D _longitudeTransform = new Transform3D();
+ private Transform3D _latitudeTransform = new Transform3D();
+ private Transform3D _rotateTransform = new Transform3D();
+
+ // needed for integrateTransforms but don't want to new every time
+ private Transform3D _temp1 = new Transform3D();
+ private Transform3D _temp2 = new Transform3D();
+ private Transform3D _translation = new Transform3D();
+ private Vector3d _transVector = new Vector3d();
+ private Vector3d _distanceVector = new Vector3d();
+ private Vector3d _centerVector = new Vector3d();
+ private Vector3d _invertCenterVector = new Vector3d();
+
+ private double _deltaYaw = 0.0, _deltaPitch = 0.0;
+ private double _startDistanceFromCenter = 20.0;
+ private double _distanceFromCenter = 20.0;
+ private Point3d _rotationCenter = new Point3d();
+ private Matrix3d _rotMatrix = new Matrix3d();
+
+ private int _mouseX = 0, _mouseY = 0;
+
+ private double _xtrans = 0.0, _ytrans = 0.0, _ztrans = 0.0;
+
+ private static final double MIN_RADIUS = 0.0;
+
+ // the factor to be applied to wheel zooming so that it does not
+ // look much different with mouse movement zooming.
+ private static final float wheelZoomFactor = 50.0f;
+
+ private static final double NOMINAL_ZOOM_FACTOR = .01;
+ private static final double NOMINAL_ROT_FACTOR = .008;
+ private static final double NOMINAL_TRANS_FACTOR = .003;
+
+ private double _pitchAngle = 0.0;
+
+
+ /**
+ * Creates a new OrbitBehaviour
+ * @param inCanvas The Canvas3D to add the behaviour to
+ * @param inInitialPitch pitch angle in degrees
+ */
+ public UprightOrbiter(Canvas3D inCanvas, double inInitialPitch)
+ {
+ super(inCanvas, MOUSE_LISTENER | MOUSE_MOTION_LISTENER | MOUSE_WHEEL_LISTENER );
+ _pitchAngle = Math.toRadians(inInitialPitch);
+ }
+
+ protected synchronized void processAWTEvents( final AWTEvent[] events )
+ {
+ motion = false;
+ for(int i=0; i MIN_RADIUS) {
+ _distanceFromCenter -= ychange * NOMINAL_ZOOM_FACTOR;
+ }
+ else {
+ _distanceFromCenter = MIN_RADIUS;
+ }
+ }
+
+ /**
+ * Sets the ViewingPlatform for this behaviour. This method is
+ * called by the ViewingPlatform.
+ * If a sub-calls overrides this method, it must call
+ * super.setViewingPlatform(vp).
+ * NOTE: Applications should not call this method.
+ */
+ @Override
+ public void setViewingPlatform(ViewingPlatform vp)
+ {
+ super.setViewingPlatform( vp );
+
+ if (vp != null) {
+ resetView();
+ integrateTransforms();
+ }
+ }
+
+ /**
+ * Reset the orientation and distance of this behaviour to the current
+ * values in the ViewPlatform Transform Group
+ */
+ private void resetView()
+ {
+ Vector3d centerToView = new Vector3d();
+
+ targetTG.getTransform( targetTransform );
+
+ targetTransform.get( _rotMatrix, _transVector );
+ centerToView.sub( _transVector, _rotationCenter );
+ _distanceFromCenter = centerToView.length();
+ _startDistanceFromCenter = _distanceFromCenter;
+
+ targetTransform.get( _rotMatrix );
+ _rotateTransform.set( _rotMatrix );
+
+ // compute the initial x/y/z offset
+ _temp1.set(centerToView);
+ _rotateTransform.invert();
+ _rotateTransform.mul(_temp1);
+ _rotateTransform.get(centerToView);
+ _xtrans = centerToView.x;
+ _ytrans = centerToView.y;
+ _ztrans = centerToView.z;
+
+ // reset rotMatrix
+ _rotateTransform.set( _rotMatrix );
+ }
+
+ protected synchronized void integrateTransforms()
+ {
+ // Check if the transform has been changed by another behaviour
+ Transform3D currentXfm = new Transform3D();
+ targetTG.getTransform(currentXfm);
+ if (! targetTransform.equals(currentXfm))
+ resetView();
+
+ // Three-step rotation process, firstly undo the pitch and apply the delta yaw
+ _latitudeTransform.rotX(_pitchAngle);
+ _rotateTransform.mul(_rotateTransform, _latitudeTransform);
+ _longitudeTransform.rotY( _deltaYaw );
+ _rotateTransform.mul(_rotateTransform, _longitudeTransform);
+ // Now update pitch angle according to delta and apply
+ _pitchAngle = Math.min(Math.max(0.0, _pitchAngle - _deltaPitch), Math.PI/2.0);
+ _latitudeTransform.rotX(-_pitchAngle);
+ _rotateTransform.mul(_rotateTransform, _latitudeTransform);
+
+ _distanceVector.z = _distanceFromCenter - _startDistanceFromCenter;
+
+ _temp1.set(_distanceVector);
+ _temp1.mul(_rotateTransform, _temp1);
+
+ // want to look at rotationCenter
+ _transVector.x = _rotationCenter.x + _xtrans;
+ _transVector.y = _rotationCenter.y + _ytrans;
+ _transVector.z = _rotationCenter.z + _ztrans;
+
+ _translation.set(_transVector);
+ targetTransform.mul(_temp1, _translation);
+
+ // handle rotationCenter
+ _temp1.set(_centerVector);
+ _temp1.mul(targetTransform);
+
+ _invertCenterVector.x = -_centerVector.x;
+ _invertCenterVector.y = -_centerVector.y;
+ _invertCenterVector.z = -_centerVector.z;
+
+ _temp2.set(_invertCenterVector);
+ targetTransform.mul(_temp1, _temp2);
+
+ Vector3d finalTranslation = new Vector3d();
+ targetTransform.get(finalTranslation);
+
+ targetTG.setTransform(targetTransform);
+
+ // reset yaw and pitch deltas
+ _deltaYaw = 0.0;
+ _deltaPitch = 0.0;
+ }
+
+ private boolean isRotateEvent(MouseEvent evt)
+ {
+ final boolean isRightDrag = (evt.getModifiersEx() & InputEvent.BUTTON3_DOWN_MASK) > 0;
+ return !evt.isAltDown() && !isRightDrag;
+ }
+
+ private boolean isZoomEvent(MouseEvent evt)
+ {
+ if (evt instanceof java.awt.event.MouseWheelEvent) {
+ return true;
+ }
+ return evt.isAltDown() && !evt.isMetaDown();
+ }
+
+ private boolean isTranslateEvent(MouseEvent evt)
+ {
+ final boolean isRightDrag = (evt.getModifiersEx() & InputEvent.BUTTON3_DOWN_MASK) > 0;
+ return !evt.isAltDown() && isRightDrag;
+ }
+}
diff --git a/test/tim/prune/data/RangeStatsTest.java b/test/tim/prune/data/RangeStatsTest.java
new file mode 100644
index 0000000..d068ac1
--- /dev/null
+++ b/test/tim/prune/data/RangeStatsTest.java
@@ -0,0 +1,197 @@
+package tim.prune.data;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit tests for calculation of moving time of a range
+ * based on different timestamp availability
+ * @author fperrin
+ */
+class RangeStatsTest
+{
+ @Test
+ void movingTime()
+ {
+ Track track = new Track();
+ DataPoint[] points = {
+ new DataPoint(
+ new String[] {
+ "01-Jan-2020 00:00:00",
+ },
+ new FieldList(new Field[] {
+ Field.TIMESTAMP,
+ }),
+ null),
+ new DataPoint(
+ new String[] {
+ "01-Jan-2020 00:00:05",
+ },
+ new FieldList(new Field[] {
+ Field.TIMESTAMP,
+ }),
+ null),
+ new DataPoint(
+ new String[] {
+ "01-Jan-2020 00:00:07",
+ },
+ new FieldList(new Field[] {
+ Field.TIMESTAMP,
+ }),
+ null),
+ };
+ track.appendPoints(points);
+
+ RangeStats range = new RangeStats(track, 0, track.getNumPoints() - 1);
+ assertEquals(7, range.getMovingDurationInSeconds());
+ assertEquals(7, range.getTotalDurationInSeconds());
+ assertFalse(range.getTimestampsIncomplete());
+ assertFalse(range.getTimestampsOutOfSequence());
+ }
+
+ @Test
+ void movingTimeWithGap()
+ {
+ Track track = new Track();
+ DataPoint[] points = {
+ new DataPoint(
+ new String[] {
+ "01-Jan-2020 00:00:00",
+ },
+ new FieldList(new Field[] {
+ Field.TIMESTAMP,
+ }),
+ null),
+ new DataPoint(
+ new String[] {},
+ new FieldList(new Field[] {}),
+ null),
+ new DataPoint(
+ new String[] {
+ "01-Jan-2020 00:00:05",
+ },
+ new FieldList(new Field[] {
+ Field.TIMESTAMP,
+ }),
+ null),
+ new DataPoint(
+ new String[] {
+ "01-Jan-2020 00:00:07",
+ },
+ new FieldList(new Field[] {
+ Field.TIMESTAMP,
+ }),
+ null),
+ };
+ track.appendPoints(points);
+
+ RangeStats range = new RangeStats(track, 0, track.getNumPoints() - 1);
+ assertEquals(7, range.getMovingDurationInSeconds());
+ assertEquals(7, range.getTotalDurationInSeconds());
+ assertTrue(range.getTimestampsIncomplete());
+ assertFalse(range.getTimestampsOutOfSequence());
+ }
+
+ @Test
+ void movingTimeSeveralSegments()
+ {
+ Track track = new Track();
+ DataPoint[] points = {
+ new DataPoint(
+ new String[] {
+ "01-Jan-2020 00:01:00",
+ },
+ new FieldList(new Field[] {
+ Field.TIMESTAMP,
+ }),
+ null),
+ new DataPoint(
+ new String[] {},
+ new FieldList(new Field[] {}),
+ null),
+ new DataPoint(
+ new String[] {
+ "01-Jan-2020 00:01:05",
+ },
+ new FieldList(new Field[] {
+ Field.TIMESTAMP,
+ }),
+ null),
+ new DataPoint(
+ new String[] {
+ "01-Jan-2020 00:01:07",
+ },
+ new FieldList(new Field[] {
+ Field.TIMESTAMP,
+ }),
+ null),
+ // start a second segment
+ new DataPoint(
+ new String[] {
+ "01-Jan-2020 00:00:20",
+ "1",
+ },
+ new FieldList(new Field[] {
+ Field.TIMESTAMP,
+ Field.NEW_SEGMENT,
+ }),
+ null),
+ new DataPoint(
+ new String[] {
+ "01-Jan-2020 00:00:27",
+ },
+ new FieldList(new Field[] {
+ Field.TIMESTAMP,
+ }),
+ null),
+ };
+ track.appendPoints(points);
+
+ RangeStats range = new RangeStats(track, 0, track.getNumPoints() - 1);
+ assertEquals(7 + 7, range.getMovingDurationInSeconds());
+ assertEquals(47, range.getTotalDurationInSeconds());
+ assertTrue(range.getEarliestTimestamp().isEqual(new TimestampUtc("01-Jan-2020 00:00:20")));
+ assertTrue(range.getLatestTimestamp().isEqual(new TimestampUtc("01-Jan-2020 00:01:07")));
+ assertTrue(range.getTimestampsIncomplete());
+
+ // even though segment 2 is earlier than segment 1, timestamps
+ // within each segment are normally ordered
+ assertFalse(range.getTimestampsOutOfSequence());
+ }
+
+ @Test
+ void movingTimeMissingFirstTimestamp()
+ {
+ Track track = new Track();
+ DataPoint[] points = {
+ new DataPoint(
+ new String[] {},
+ new FieldList(new Field[] {}),
+ null),
+ new DataPoint(
+ new String[] {
+ "01-Jan-2020 00:00:00",
+ },
+ new FieldList(new Field[] {
+ Field.TIMESTAMP,
+ }),
+ null),
+ new DataPoint(
+ new String[] {
+ "01-Jan-2020 00:00:05",
+ },
+ new FieldList(new Field[] {
+ Field.TIMESTAMP,
+ }),
+ null),
+ };
+ track.appendPoints(points);
+
+ RangeStats range = new RangeStats(track, 0, track.getNumPoints() - 1);
+ assertEquals(5, range.getMovingDurationInSeconds());
+ assertEquals(5, range.getTotalDurationInSeconds());
+ assertTrue(range.getTimestampsIncomplete());
+ assertFalse(range.getTimestampsOutOfSequence());
+ }
+}
diff --git a/test/tim/prune/function/cache/TileSetTest.java b/test/tim/prune/function/cache/TileSetTest.java
new file mode 100644
index 0000000..4ff108c
--- /dev/null
+++ b/test/tim/prune/function/cache/TileSetTest.java
@@ -0,0 +1,44 @@
+package tim.prune.function.cache;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit tests for tile name checks
+ */
+class TileSetTest
+{
+ @Test
+ void testIsNumeric()
+ {
+ // not numeric, should be false
+ assertFalse(TileSet.isNumeric(null));
+ assertFalse(TileSet.isNumeric(""));
+ assertFalse(TileSet.isNumeric("a"));
+ assertFalse(TileSet.isNumeric(" "));
+ assertFalse(TileSet.isNumeric("155a"));
+ assertFalse(TileSet.isNumeric("-2"));
+ // numeric, should be true
+ assertTrue(TileSet.isNumeric("1"));
+ assertTrue(TileSet.isNumeric("155"));
+ }
+
+ @Test
+ void testIsNumericUntilDot()
+ {
+ // not numeric, should be false
+ assertFalse(TileSet.isNumericUntilDot(null));
+ assertFalse(TileSet.isNumericUntilDot(""));
+ assertFalse(TileSet.isNumericUntilDot("."));
+ assertFalse(TileSet.isNumericUntilDot(".abc"));
+ assertFalse(TileSet.isNumericUntilDot("a3."));
+ assertFalse(TileSet.isNumericUntilDot("4a"));
+ assertFalse(TileSet.isNumericUntilDot("215327h.png"));
+ // numeric but no dot, should be false
+ assertFalse(TileSet.isNumericUntilDot("1234"));
+ // numeric, should be true
+ System.out.println(TileSet.isNumericUntilDot("44.jpg"));
+ System.out.println(TileSet.isNumericUntilDot("0."));
+ }
+}
diff --git a/test/tim/prune/function/olc/OlcDecoderTest.java b/test/tim/prune/function/olc/OlcDecoderTest.java
new file mode 100644
index 0000000..951a081
--- /dev/null
+++ b/test/tim/prune/function/olc/OlcDecoderTest.java
@@ -0,0 +1,75 @@
+package tim.prune.function.olc;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit tests for decoding of Open Location Codes (Pluscodes)
+ */
+class OlcDecoderTest
+{
+
+ @Test
+ void testDecodeStringsTooShort()
+ {
+ OlcArea area = OlcDecoder.decode(null);
+ assertEquals(area, null, "Decoding null gives null");
+ area = OlcDecoder.decode("");
+ assertEquals(area, null, "Decoding \"\" gives null");
+ area = OlcDecoder.decode("9");
+ assertEquals(area, null, "Decoding \"9\" gives null");
+ area = OlcDecoder.decode("9999999");
+ assertEquals(area, null, "Decoding \"9999999\" gives null");
+ }
+
+ @Test
+ void testDecodeStringsInvalid()
+ {
+ OlcArea area = OlcDecoder.decode("11111111");
+ assertEquals(area, null, "Decoding lots of 1s gives null");
+ area = OlcDecoder.decode("99999991");
+ assertEquals(area, null, "Decoding with a single 1 gives null");
+ area = OlcDecoder.decode("99999999");
+ assertNotEquals(area, null, "Decoding with all 9s gives non-null");
+ area = OlcDecoder.decode("00000000");
+ assertEquals(area, null, "Decoding with all padding gives null");
+ area = OlcDecoder.decode("99000000");
+ assertNotEquals(area, null, "Decoding with some padding gives non-null");
+ }
+
+ @Test
+ void testDecodeZeroes()
+ {
+ OlcArea area = OlcDecoder.decode("22000000");
+ assertNotEquals(area, null, "Decoding with padding gives non-null");
+ assertEquals(-90.0, area.minLat, 0.0, "South 90");
+ assertEquals(-70.0, area.maxLat, 0.0, "South 70");
+ assertEquals(-180.0, area.minLon, 0.0, "West 180");
+ assertEquals(-160.0, area.maxLon, 0.0, "West 160");
+ }
+
+ @Test
+ void testDecodeZeroes2()
+ {
+ OlcArea area = OlcDecoder.decode("22220000");
+ assertNotEquals(area, null, "Decoding with padding gives non-null");
+ assertEquals(-90.0, area.minLat, 0.0, "South 90");
+ assertEquals(-89.0, area.maxLat, 0.0, "South 89");
+ assertEquals(-180.0, area.minLon, 0.0, "West 180");
+ assertEquals(-179.0, area.maxLon, 0.0, "West 179");
+ }
+
+ @Test
+ void testMountainView()
+ {
+ OlcArea area = OlcDecoder.decode("6PH57VP3+PR6");
+ assertNotEquals(area, null, "Decoding with separator gives non-null");
+ System.out.println("Min lat: " + area.minLat);
+ System.out.println("Max lat: " + area.maxLat);
+ System.out.println("Min lon: " + area.minLon);
+ System.out.println("Max lon: " + area.maxLon);
+ assertTrue(area.maxLat > area.minLat, "latitude range");
+ assertTrue(area.maxLon > area.minLon, "longitude range");
+ }
+}
diff --git a/test/tim/prune/function/weather/SingleForecastTest.java b/test/tim/prune/function/weather/SingleForecastTest.java
new file mode 100644
index 0000000..372ab47
--- /dev/null
+++ b/test/tim/prune/function/weather/SingleForecastTest.java
@@ -0,0 +1,52 @@
+package tim.prune.function.weather;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit tests for weather icons
+ */
+class SingleForecastTest
+{
+
+ @Test
+ void testWeatherIcons()
+ {
+ testIconName(null, "100", "");
+ testIconName("storm.png", "200", null);
+ testIconName("storm.png", "204", "");
+ testIconName("lightrain.png", "300", null);
+ testIconName("lightrain.png", "301", null);
+ testIconName(null, "400", null);
+ testIconName("lightrain.png", "500", null);
+ testIconName("rain.png", "501", null);
+ testIconName("rain.png", "599", null);
+ testIconName("hail.png", "511", null);
+ testIconName("snow.png", "600", null);
+ testIconName("fog.png", "700", null);
+ testIconName("clear-day.png", "800", null);
+ testIconName("clear-day.png", "800", "");
+ testIconName("clear-day.png", "800", "01d");
+ testIconName("clear-night.png", "800", "01n");
+ testIconName("clouds-day.png", "802", "01d");
+ testIconName("clouds-night.png", "802", "01n");
+ testIconName("clouds.png", "804", "01n");
+ testIconName("extreme.png", "900", "01d");
+ testIconName("hail.png", "906", "01n");
+ }
+
+ /**
+ * Test getting an icon name according to code and image
+ */
+ private static void testIconName(String inExpect, String inCode, String inImage)
+ {
+ String icon = SingleForecast.getIconName(inCode, inImage);
+ assertEquals(inExpect, icon, showString(inCode) + ", " + showString(inImage));
+ }
+
+ private static String showString(String inString)
+ {
+ return inString == null ? "null" : inString;
+ }
+}
diff --git a/test/tim/prune/gui/map/MapSourceTest.java b/test/tim/prune/gui/map/MapSourceTest.java
new file mode 100644
index 0000000..ff947c3
--- /dev/null
+++ b/test/tim/prune/gui/map/MapSourceTest.java
@@ -0,0 +1,31 @@
+package tim.prune.gui.map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit tests for manipulating base Urls
+ */
+class MapSourceTest
+{
+ @Test
+ void testFixBaseUrls()
+ {
+ // Should succeed
+ testUrlFix("8bitcities.s3.amazonaws.com", "http://8bitcities.s3.amazonaws.com/");
+ testUrlFix("8bitcities.s3.amazonaws.com/", "http://8bitcities.s3.amazonaws.com/");
+ testUrlFix("http://8bitcities.s3.amazonaws.com/", "http://8bitcities.s3.amazonaws.com/");
+ testUrlFix("something.com/ok", "http://something.com/ok/");
+
+ // These should fail and return null
+ testUrlFix("something/wrong", null);
+ testUrlFix("protocol://something.com/16/", null);
+ }
+
+ private void testUrlFix(String inStart, String inExpected)
+ {
+ String result = MapSource.fixBaseUrl(inStart);
+ assertEquals(inExpected, result);
+ }
+}
diff --git a/test/tim/prune/gui/map/SiteNameUtilsTest.java b/test/tim/prune/gui/map/SiteNameUtilsTest.java
new file mode 100644
index 0000000..e01ed52
--- /dev/null
+++ b/test/tim/prune/gui/map/SiteNameUtilsTest.java
@@ -0,0 +1,63 @@
+package tim.prune.gui.map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.HashSet;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit tests for site name utils
+ */
+class SiteNameUtilsTest
+{
+
+ @Test
+ void testPickServerNameWithoutWildcards()
+ {
+ testPickSingleUrl("abc", "abc");
+ testPickSingleUrl("ab[]c", "abc");
+ testPickSingleUrl("[]abc", "abc");
+ testPickSingleUrl("abc[]", "abc");
+ }
+
+ /**
+ * Test a pattern without wildcards which should always produce the expected result
+ * @param inPattern pattern for site name
+ * @param inExpected expected resolved name
+ */
+ private void testPickSingleUrl(String inPattern, String inExpected)
+ {
+ for (int i=0; i<20; i++)
+ {
+ String resolved = SiteNameUtils.pickServerUrl(inPattern);
+ assertEquals(inExpected, resolved, "Failed: " + inPattern);
+ }
+ }
+
+ @Test
+ void testPickUsingWildcards()
+ {
+ testRandomPick("ab[123]c", new String[]{"ab1c", "ab2c", "ab3c"});
+ testRandomPick("1234.[abcd]", new String[]{"1234.a", "1234.b", "1234.c", "1234.d"});
+ }
+
+ /**
+ * Test a pattern with wildcards which should produce several different results randomly
+ * @param inPattern pattern for site name
+ * @param inExpected array of expected resolved names
+ */
+ private void testRandomPick(String inPattern, String[] inExpected)
+ {
+ HashSet results = new HashSet();
+ for (int i=0; i<30; i++)
+ {
+ results.add(SiteNameUtils.pickServerUrl(inPattern));
+ }
+ // Check that all expected results were returned
+ assertEquals(inExpected.length, results.size());
+ for (String expec : inExpected) {
+ assertTrue(results.contains(expec));
+ }
+ }
+}
diff --git a/test/tim/prune/jpeg/drew/RationalTest.java b/test/tim/prune/jpeg/drew/RationalTest.java
new file mode 100644
index 0000000..ae97e0f
--- /dev/null
+++ b/test/tim/prune/jpeg/drew/RationalTest.java
@@ -0,0 +1,71 @@
+package tim.prune.jpeg.drew;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * JUnit tests for the Rational values used by the Exif
+ */
+class RationalTest
+{
+ @Test
+ void testManyInts()
+ {
+ testIntVal(0, 0, 0);
+ testIntVal(1, 0, 0);
+ testIntVal(0, 1, 0);
+ for (int i=0; i<16000; i++)
+ {
+ testIntVal(0, i, 0);
+ testIntVal(i, 0, 0);
+ testIntVal(i, 1, i);
+ testIntVal(-i, 1, -i);
+ testIntVal(i*2, 2, i);
+ testIntVal(i*2+1, 2, i); // rounding down the 0.5
+ testIntVal(-i*2, 2, -i);
+ testIntVal(i*2, -2, -i);
+ testIntVal(-i*2, -2, i);
+ }
+ }
+
+ /**
+ * Check that a rational converts to an integer properly
+ * @param inTop number on top of the rational (numerator)
+ * @param inBottom number on bottom of the rational (denominator)
+ * @param inExpected expected int value
+ */
+ private void testIntVal(long inTop, long inBottom, int inExpected)
+ {
+ Rational value = new Rational(inTop, inBottom);
+ assertEquals(inExpected, value.intValue(), "" + inTop + "/" + inBottom);
+ }
+
+ @Test
+ void testManyDoubles()
+ {
+ for (int i=0; i<16000; i++)
+ {
+ testDoubleVal(0, i, 0.0);
+ testDoubleVal(i, 0, 0.0);
+ testDoubleVal(i, 1, i);
+ testDoubleVal(i*2, 2, i);
+ testDoubleVal(i*2+1, 2, i+0.5);
+ testDoubleVal(i*2, -2, -i);
+ }
+
+ testDoubleVal(123, 3, 123.0/3.0);
+ }
+
+ /**
+ * Check that a rational converts to a double properly
+ * @param inTop number on top of the rational (numerator)
+ * @param inBottom number on bottom of the rational (denominator)
+ * @param inExpected expected double value (exact)
+ */
+ private void testDoubleVal(long inTop, long inBottom, double inExpected)
+ {
+ Rational value = new Rational(inTop, inBottom);
+ assertEquals(inExpected, value.doubleValue(), 0.0, "" + inTop + "/" + inBottom);
+ }
+}