diff --git a/gradle.properties b/gradle.properties
index 5a2f215..ceb24b6 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,6 +1,6 @@
group=io.github.wrprice
artifactId=inet-time
-version=0.4.20
+version=0.8.64
description=Internet Time (.beat) library for Java
isRelease=false
diff --git a/src/main/java/io/github/wrprice/inettime/InternetTime.java b/src/main/java/io/github/wrprice/inettime/InternetTime.java
index 7214038..1069e91 100644
--- a/src/main/java/io/github/wrprice/inettime/InternetTime.java
+++ b/src/main/java/io/github/wrprice/inettime/InternetTime.java
@@ -17,25 +17,78 @@
import static io.github.wrprice.inettime.InternetTimeField.*;
import static io.github.wrprice.inettime.InternetTimeUnit.*;
-import static io.github.wrprice.inettime.PrivateField.CENTIBEAT_OF_BEAT;
import static java.time.temporal.ChronoField.*;
import static java.util.Objects.requireNonNull;
import java.io.Serial;
import java.io.Serializable;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.VarHandle;
import java.time.*;
+import java.time.format.*;
import java.time.temporal.*;
import java.util.concurrent.TimeUnit;
+import java.util.function.UnaryOperator;
/// Represents a global instant according to the *Swatch® Internet Time* standard:
/// a date-time with a *fixed offset* from UTC
-/// in the ISO calendar. An example is: `2025-12-28@123.45`. As implied by the fixed offset,
+/// in the ISO calendar. An example is: `2025-12-28 @123.45`. As implied by the fixed offset,
/// DST *does not apply*.
///
-/// The goal is to provide similar functionality as Java's [OffsetDateTime]; however, unlike
-/// `OffsetDateTime`, this implementation's time-of-day resolution is limited to the granularity of
-/// a single [centibeat][InternetTimeUnit#CENTIBEATS] and the zone offset is not stored because a
-/// constant, fixed offset is implied.
+/// This implementation's time-of-day precision is limited to the granularity of a single
+/// [centibeat][InternetTimeUnit#CENTIBEATS] measured from midnight, and the zone offset is not
+/// stored because a constant, fixed UTC offset is implied. In that way, this class is somewhat
+/// of a hybrid between an [Instant] and an [OffsetDateTime], with similar functionality.
+///
+/// ## Factory methods
+///
+/// - `public static` methods starting with `of` create **exact** values in *Internet Time*.
+/// - `public static` methods starting with `now` create values **at or before** the current time
+/// given by the system default or a provided [Clock].
+/// - [#from(TemporalAccessor)] attempts to derive an *Internet Time* value **equal to or before**
+/// the provided value.
+/// - `public static` methods starting with `parse` attempt to obtain an *Internet Time* value
+/// **equal to or before** the date-time represented as text, given a corresponding formatter.
+///
+/// Attempting to "round-trip" an `InternetTime` value from an inexact method back to the original
+/// type may not result in an equivalent value due to alignment and precision differences.
+///
+/// ## Convenience methods
+///
+/// ### Alignment
+///
+/// - [#toNearestSecond()] creates an `OffsetDateTime` at the nearest whole second boundary (where
+/// all smaller units, such as millisecond-of-second, etc.) are zero(0). The nearest second can
+/// occur *before or after* the original `InternetTime` value on the time line.
+/// - [#toStartOf(InternetTimeField, Temporal)] aligns a date-time value of another type to the
+/// first millisecond-of-day of its equivalent *Internet Time* value, at the resolution of the
+/// specified [InternetTimeField] (effectively, the current *.beat* or centibeat).
+///
+/// ### Conversion
+///
+/// Various `public` instance methods beginning with `to` and named after common `java.time` API
+/// value types generate those value types from the value of the original `InternetTime` instance.
+/// Converted values are aligned to the beginning millisecond-of-day of the original value's
+/// centibeat-of-day.
+///
+/// - [#toInstant()]
+/// - [#toLocalDate()]
+/// - [#toLocalDateTime()]
+/// - [#toOffsetDateTime()]
+/// - [#toOffsetTime()]
+///
+/// The `static` methods [#timeOfBeat(int)] and [#timeOfBeat(int, int)] produce [OffsetTime]
+/// values for various *.beat* values that can then be converted to hours, minutes, seconds, etc.,
+/// in non-*Internet Time* zones with different offsets. These values are useful for time display
+/// purposes where the date context (if any) is handled separately.
+///
+/// ## Formatting & Parsing
+///
+/// Immutable, thread-safe formatters (which also support parsing operations) in a select number of
+/// predefined formats are available as `static final` fields on this class. To build your own
+/// custom format involving *.beats*, see the [#beatFormatter] method.
+///
+/// -----
///
/// **Implementation notes:**
/// This class is immutable and thread-safe.
@@ -52,10 +105,96 @@
implements Temporal, Comparable, Serializable {
/// Fixed-offset zone based on the location of Swatch's headquarters in Biel, Switzerland.
+ /// Can also be used as a [ZoneId].
public static final ZoneOffset ZONE = ZoneOffset.ofHours(+1);
- /// This Temporal type should have functionality and behavior similar to OffsetDateTime.
- private static final Temporal ANALOG = OffsetDateTime.MIN;
+ /// Used to access array elements of type `DateTimeFormatter` with `volatile` semantics.
+ /// @see #TIME_FORMATTERS_BY_STYLE
+ private static final VarHandle DATETIMEFORMAT_ARRAY_VARHANDLE =
+ MethodHandles.arrayElementVarHandle(DateTimeFormatter[].class);
+
+ /// Holds reusable time-only formatters for each [FormatStyle#ordinal()].
+ /// @see #beatFormatter(FormatStyle)
+ private static final DateTimeFormatter[] TIME_FORMATTERS_BY_STYLE =
+ new DateTimeFormatter[FormatStyle.values().length];
+
+ /// ISO-style local date-time formatter and parser with Internet Time *.beats*
+ /// but without any zone or offset: `2025-12-31 @567`
+ ///
+ /// The format consists of:
+ ///
+ /// - The [ISO_LOCAL_DATE][DateTimeFormatter#ISO_LOCAL_DATE]
+ /// - A space (ASCII 0x20)
+ /// - [beatFormatter(MEDIUM)][#beatFormatter(FormatStyle)], which is:
+ /// - The `@` symbol
+ /// - Three digits, zero-padded, for the number of whole *.beats* since Internet Time midnight
+ public static final DateTimeFormatter LOCAL_DATE_BEATS =
+ joinFormats(DateTimeFormatter.ISO_LOCAL_DATE, beatFormatter(FormatStyle.MEDIUM));
+
+ /// ISO-style local date-time formatter and parser with Internet Time fractional *.beats*
+ /// but without any zone or offset: `2025-12-31 @567.89`
+ ///
+ /// The format consists of:
+ ///
+ /// - The [ISO_LOCAL_DATE][DateTimeFormatter#ISO_LOCAL_DATE]
+ /// - A space (ASCII 0x20)
+ /// - [beatFormatter(FULL)][#beatFormatter(FormatStyle)], which is:
+ /// - The `@` symbol
+ /// - Three digits, zero-padded, for the number of whole *.beats* since Internet Time midnight
+ /// - The `.` symbol
+ /// - Two digits, zero-padded, for the fractional part of the current *.beat*.
+ public static final DateTimeFormatter LOCAL_DATE_CENTIBEATS =
+ joinFormats(DateTimeFormatter.ISO_LOCAL_DATE, beatFormatter(FormatStyle.FULL));
+
+ /// ISO-style date-time formatter and parser with Internet Time *.beats*
+ /// and an ISO-8601 extended offset date from UTC: `2025-12-31+01:00 @567`
+ ///
+ /// The format consists of:
+ ///
+ /// - The [ISO_OFFSET_DATE][DateTimeFormatter#ISO_OFFSET_DATE]
+ /// - A space (ASCII 0x20)
+ /// - [beatFormatter(MEDIUM)][#beatFormatter(FormatStyle)], which is:
+ /// - The `@` symbol
+ /// - Three digits, zero-padded, for the number of whole *.beats* since Internet Time midnight
+ public static final DateTimeFormatter OFFSET_DATE_BEATS =
+ joinFormats(DateTimeFormatter.ISO_OFFSET_DATE, beatFormatter(FormatStyle.MEDIUM));
+
+ /// ISO-style date-time formatter and parser with Internet Time fractional *.beats*
+ /// and an ISO-8601 extended offset date from UTC: `2025-12-31+01:00 @567.89`
+ ///
+ /// The format consists of:
+ ///
+ /// - The [ISO_OFFSET_DATE][DateTimeFormatter#ISO_OFFSET_DATE]
+ /// - A space (ASCII 0x20)
+ /// - [beatFormatter(FULL)][#beatFormatter(FormatStyle)], which is:
+ /// - The `@` symbol
+ /// - Three digits, zero-padded, for the number of whole *.beats* since Internet Time midnight
+ /// - The `.` symbol
+ /// - Two digits, zero-padded, for the fractional part of the current *.beat*.
+ public static final DateTimeFormatter OFFSET_DATE_CENTIBEATS =
+ joinFormats(DateTimeFormatter.ISO_OFFSET_DATE, beatFormatter(FormatStyle.FULL));
+
+ /// Late 1990s format seemingly preferred by Swatch®, though not officially documented
+ /// as a standard: `d31.12.2025 @567.89`
+ ///
+ /// NOTE: Some historical examples used two(2)-digit years, which was short-sighted given that
+ /// *Y2K* was right around the corner and on many technologists' minds. This format is configured
+ /// to always use four(4) digits for the year. The date is local to *Internet Time* (UTC+1);
+ /// no UTC offset is shown.
+ private static final DateTimeFormatter RETRO_DATE_CENTIBEATS =
+ new DateTimeFormatterBuilder()
+ .optionalStart()
+ .appendLiteral('d')
+ .optionalEnd()
+ .appendValue(DAY_OF_MONTH, 2) // FUTURE: INET_DAY_OF_MONTH?
+ .appendLiteral('.')
+ .appendValue(MONTH_OF_YEAR, 2) // FUTURE: INET_MONTH_OF_YEAR?
+ .appendLiteral('.')
+ .appendValue(YEAR, 4) // FUTURE: INET_YEAR?
+ .appendLiteral(' ')
+ .append(beatFormatter(FormatStyle.FULL))
+ .toFormatter()
+ .withZone(ZONE); // zone override will throw if values used have the incorrect zone
@Serial
private static final long serialVersionUID = 202512271724L;
@@ -81,56 +220,281 @@ private InternetTime(LocalDate date, int centibeats) {
// -------------------------------
+ /// Obtains the current date-time from the system clock in *Internet Time*. The offset is always
+ /// UTC+1, regardless of the system's default time-zone.
+ ///
+ /// Using this method will prevent the ability to use an alternate clock for testing because the
+ /// clock is hard-coded.
+ ///
+ /// @return the current date and time from the system clock
public static InternetTime now() {
return now(Clock.systemUTC());
}
+ /// Obtains the current date-time from a provided clock. The offset is always UTC+1, computed
+ /// from the offset of the time-zone in the clock.
+ ///
+ /// Using this method allows using an alternate clock for testing.
+ ///
+ /// @param clock the clock to use
+ /// @return the current date and time according to the clock
public static InternetTime now(Clock clock) {
return ofInstant(requireNonNull(clock, "clock").instant());
}
+ /// Obtains an instance of `InternetTime` from a temporal object. A `TemporalAccessor` represents
+ /// an arbitrary set of date and time information. This factory first attempts to translate
+ /// `java.time` date-time types supporting time-zone offset information, falling back to an
+ /// `Instant` if necessary.
+ ///
+ /// **Local** date-time values, date-only, and time-only values are not supported, because a
+ /// representation in *Internet Time* requires determining the offset relative to UTC+1.
+ ///
+ /// Attempting to "round-trip" the returned [InternetTime] [back to][#toInstant()] an `Instant`
+ /// will often result in a *different value* (+/- 864ms) because of the precision limitations of
+ /// `InternetTime`.
+ ///
+ /// @param accessor the temporal object to convert
+ /// @return a date-time aligned to *Internet Time*
+ /// @throws DateTimeException if unable to convert to an `InternetTime`
public static InternetTime from(TemporalAccessor accessor) {
requireNonNull(accessor, "accessor");
return switch (accessor) {
case InternetTime it -> it;
case OffsetDateTime dt -> new InternetTime(dt.withOffsetSameInstant(ZONE).toLocalDateTime());
case ZonedDateTime dt -> new InternetTime(dt.withZoneSameInstant(ZONE).toLocalDateTime());
+ case TemporalAccessor ta when
+ DateTimeFormatter.class.getPackage().equals(ta.getClass().getPackage()) -> fromParsed(ta);
default -> {
try {
yield ofInstant(Instant.from(accessor));
} catch (DateTimeException dte) {
- var typeName = accessor.getClass().getName();
- throw new DateTimeException(
- "Cannot derive InternetTime from " + typeName + ": " + accessor);
+ throw cannotDeriveFrom(accessor);
}
}
};
}
- public static InternetTime ofInstant(Instant instant) {
+ private static DateTimeException cannotDeriveFrom(TemporalAccessor accessor) {
+ var typeName = accessor.getClass().getName();
+ return new DateTimeException("Cannot derive InternetTime from " + typeName + ": " + accessor);
+ }
+
+ /// Obtains an instance of `InternetTime` from an `Instant`.
+ ///
+ /// Attempting to "round-trip" the returned [InternetTime] [back to][#toInstant()] an `Instant`
+ /// will often result in a *different value* (+/- 864ms) because of the precision limitations of
+ /// `InternetTime`.
+ ///
+ /// @param instant the instant to create the date-time from
+ /// @return the date and time in *Internet Time*
+ static InternetTime ofInstant(Instant instant) {
return new InternetTime(LocalDateTime.ofInstant(requireNonNull(instant, "instant"), ZONE));
}
+ /// Obtains an instance of `InternetTime` from a year, month, day, *.beat*, fractional hundredths
+ /// of a *.beat*, and offset.
+ ///
+ /// This method may be best suited for writing test cases. Non-test code may prefer other
+ /// methods like [#ofInstant(Instant)] and [#from(TemporalAccessor)]. For non-test code using
+ /// the concepts of [BEAT_OF_DAY][InternetTimeField#BEAT_OF_DAY] and
+ /// [CENTIBEAT_OF_BEAT][InternetTimeField#CENTIBEAT_OF_BEAT] directly, see:
+ /// [#of(LocalDate, int, int, ZoneOffset)].
+ ///
+ /// @param year the year
+ /// @param month the month of the year, from 1 (January) to 12 (December)
+ /// @param dayOfMonth the day of the month, from 1 to 31 (depending on the month)
+ /// @param beat the number of *.beats* since *Internet Time* midnight, from 0 to 999
+ /// @param hundredthsOfBeat of a *.beat*, the fractional part since the `beat` began, from 0 to 99
+ /// @param offset the zone offset of the date (the `beat` and `hundredths` are not affected)
+ /// @return the date and time in *Internet Time*
+ /// @throws DateTimeException if any field value is out of range or not valid for the month & year
public static InternetTime of(
- int year, int month, int dayOfMonth, int beat, int hundredthsOfBeat, ZoneOffset zone) {
- return of(LocalDate.of(year, month, dayOfMonth), beat, hundredthsOfBeat, zone);
+ int year, int month, int dayOfMonth, int beat, int hundredthsOfBeat, ZoneOffset offset) {
+ return of(LocalDate.of(year, month, dayOfMonth), beat, hundredthsOfBeat, offset);
+ }
+
+ /// Obtains an instance of `InternetTime` from a year, month, day, *.beat*, fractional hundredths
+ /// of a *.beat*, and offset.
+ ///
+ /// This method may be best suited for writing test cases. Non-test code may prefer other
+ /// methods like [#ofInstant(Instant)] and [#from(TemporalAccessor)]. For non-test code using
+ /// the concepts of [BEAT_OF_DAY][InternetTimeField#BEAT_OF_DAY] and
+ /// [CENTIBEAT_OF_BEAT][InternetTimeField#CENTIBEAT_OF_BEAT] directly, see:
+ /// [#of(LocalDate, int, int, ZoneOffset)].
+ ///
+ /// @param year the year
+ /// @param month the month of the year
+ /// @param dayOfMonth the day of the month, from 1 to 31 (depending on the month)
+ /// @param beat the number of *.beats* since *Internet Time* midnight, from 0 to 999
+ /// @param hundredthsOfBeat of a *.beat*, the fractional part since the `beat` began, from 0 to 99
+ /// @param offset the zone offset of the date (the `beat` and `hundredths` are not affected)
+ /// @return the date and time in *Internet Time*
+ /// @throws DateTimeException if any field value is out of range or not valid for the month & year
+ public static InternetTime of(
+ int year, Month month, int dayOfMonth, int beat, int hundredthsOfBeat, ZoneOffset offset) {
+ return of(LocalDate.of(year, month, dayOfMonth), beat, hundredthsOfBeat, offset);
+ }
+
+ /// Obtains an instance of `InternetTime` from a `LocalDate`, *.beat*, fractional hundredths
+ /// of a *.beat*, and offset.
+ ///
+ /// This method is best suited for code operating natively in [beats][InternetTimeUnit#BEATS] and
+ /// [centibeats][InternetTimeUnit#CENTIBEATS] but with other `java.time` API types for other
+ /// components like dates.
+ ///
+ /// @param date the local date
+ /// @param beat the number of *.beats* since *Internet Time* midnight, from 0 to 999
+ /// @param hundredthsOfBeat of a *.beat*, the fractional part since the `beat` began, from 0 to 99
+ /// @param offset the zone offset of the local date (the `beat` and `hundredths` are not affected)
+ /// @return the date and time in *Internet Time*
+ /// @throws DateTimeException if any field value is out of range
+ public static InternetTime of(LocalDate date, int beat, int hundredthsOfBeat, ZoneOffset offset) {
+ date = normalizeLocalDate(date, offset, beat, hundredthsOfBeat);
+ return new InternetTime(date, CENTIBEATS_PER_BEAT * beat + hundredthsOfBeat);
}
- public static InternetTime of(
- int year, Month month, int dayOfMonth, int beat, int hundredthsOfBeat, ZoneOffset zone) {
- return of(LocalDate.of(year, month, dayOfMonth), beat, hundredthsOfBeat, zone);
+ private static LocalDate normalizeLocalDate(
+ LocalDate date, ZoneOffset dateOffset, int beat, int hundredthsOfBeat) {
+ int adjustMs = 1000 * (dateOffset.getTotalSeconds() - ZONE.getTotalSeconds());
+ if (adjustMs != 0) {
+ long msOfDay = beatsToMillisOfDay(beat, hundredthsOfBeat);
+ long adjusted = msOfDay + adjustMs;
+ if (adjusted < 0) {
+ date = date.plusDays(1); // date in Biel will be the next day
+ } else if (adjusted >= MILLIS_PER_DAY) {
+ date = date.minusDays(1); // date in Biel will be the prior day
+ }
+ }
+ return date;
}
- public static InternetTime of(LocalDate date, int beat, int hundredthsOfBeat, ZoneOffset offset) {
- long msOfDay = beatsToMillisOfDay(beat, hundredthsOfBeat);
- int adjustMs = 1000 * (offset.getTotalSeconds() - ZONE.getTotalSeconds());
- long adjusted = msOfDay + adjustMs;
- if (adjusted < 0) {
- date = date.plusDays(1); // date in Biel will be the next day
- } else if (adjusted >= MILLIS_PER_DAY) {
- date = date.minusDays(1); // date in Biel will be the prior day
+ //
+
+ // ------
+
+ /// Obtains an instance of `InternetTime` from a text string using a specific formatter.
+ ///
+ /// Formatters that only support *local* date-time values will be assumed to occur in the
+ /// [Internet Time zone][#ZONE] (UTC+1). Formatters that only support dates without a time
+ /// component are not supported.
+ ///
+ /// @param text to be parsed
+ /// @param formatter the formatter to use
+ /// @return the parsed Internet Time
+ /// @throws DateTimeException if an error occurs during printing
+ public static InternetTime parse(CharSequence text, DateTimeFormatter formatter) {
+ return requireNonNull(formatter, "formatter").parse(text, InternetTime::fromParsed);
+ }
+
+ /// Formats this Internet Time value using the provided formatter.
+ ///
+ /// If the format outputs a date, it will be in the [Internet Time zone][#ZONE] (UTC+1).
+ /// Formatters that output a zone and/or offset will output the same zone. If the formatter
+ /// outputs a time, the value will be at the start of the current [beat][#getBeat()] and
+ /// [fractional beat][#getCentibeatOfBeat()].
+ ///
+ /// @param formatter the formatter to use
+ /// @return the formatted Internet Time string
+ /// @throws DateTimeException if an error occurs during printing
+ public String format(DateTimeFormatter formatter) {
+ return requireNonNull(formatter, "formatter").format(this);
+ }
+
+ /// Obtains a formatter that renders the time as **.beats**, for example: `@321.98`.
+ /// The provided `FormatStyle` determines the granularity and whether to prefix the
+ /// value with the `@` symbol traditionally used by *Swatch®*.
+ ///
+ /// | `FormatStyle` | Example |
+ /// |---------------|---------|
+ /// | `SHORT` | 457 |
+ /// | `MEDIUM` | @457 |
+ /// | `LONG` | 457.89 |
+ /// | `FULL` | @457.89 |
+ ///
+ /// The returned formatters *only* render the time, not the date. There are static fields on the
+ /// [InternetTime] class for some common formats. A [DateTimeFormatterBuilder] can join the
+ /// output of this method with a preferred date representation or pattern to create custom
+ /// formats.
+ ///
+ /// Because the *.beat* time notation is the same in all time zones for a given instant, when
+ /// joining with a date format, it can be ambiguous whather the rendered date is the standard
+ /// *Internet Time* date or the end-user's local date. Including the UTC offset, such as with
+ /// [DateTimeFormatter#ISO_OFFSET_DATE], is one possible solution (see: [#OFFSET_DATE_BEATS]).
+ ///
+ /// @param style `SHORT` and `MEDIUM` display whole *.beat* values without or with a leading `@`
+ /// symbol, repsectively. `LONG` and `FULL` are similar but add fractional centibeats.
+ /// @return formatter for time (only) displayed as *.beats*
+ public static DateTimeFormatter beatFormatter(FormatStyle style) {
+ int ordinal = requireNonNull(style, "style").ordinal();
+ return (DATETIMEFORMAT_ARRAY_VARHANDLE.getVolatile(TIME_FORMATTERS_BY_STYLE, ordinal)
+ instanceof DateTimeFormatter f) ? f : makeTimeFormat(style);
+ }
+
+ private static DateTimeFormatter makeTimeFormat(FormatStyle style) {
+ var fmt = appendTimeFormat(new DateTimeFormatterBuilder(), style).toFormatter();
+ DATETIMEFORMAT_ARRAY_VARHANDLE.setVolatile(TIME_FORMATTERS_BY_STYLE, style.ordinal(), fmt);
+ return fmt;
+ }
+
+ private static DateTimeFormatterBuilder appendTimeFormat(
+ DateTimeFormatterBuilder builder, FormatStyle style) {
+ final UnaryOperator centibeats =
+ b -> b.optionalStart()
+ .appendLiteral('.')
+ .appendFraction(CENTIBEAT_OF_BEAT, 2, 2, false) // non-i18n decimal
+ .optionalEnd();
+ return switch (style) {
+ case SHORT -> builder.appendValue(BEAT_OF_DAY, 3);
+ case MEDIUM -> builder.appendLiteral("@").appendValue(BEAT_OF_DAY, 3);
+ case LONG -> centibeats.apply(appendTimeFormat(builder, FormatStyle.SHORT));
+ case FULL -> centibeats.apply(appendTimeFormat(builder, FormatStyle.MEDIUM));
+ };
+ }
+
+ private static DateTimeFormatter joinFormats(
+ DateTimeFormatter dateFormat, DateTimeFormatter timeFormat) {
+ return new DateTimeFormatterBuilder()
+ .append(dateFormat)
+ .appendLiteral(' ')
+ .append(timeFormat)
+ .toFormatter();
+ }
+
+ private static InternetTime fromParsed(TemporalAccessor ta) {
+ LocalDate date;
+ if (ta.isSupported(DAY_OF_MONTH) && ta.isSupported(MONTH_OF_YEAR) && ta.isSupported(YEAR)) {
+ date = LocalDate.of(ta.get(YEAR), ta.get(MONTH_OF_YEAR), ta.get(DAY_OF_MONTH));
+ } else {
+ date = LocalDate.EPOCH;
}
- return new InternetTime(date, CENTIBEATS_PER_BEAT * beat + hundredthsOfBeat);
+ var offset = ta.isSupported(OFFSET_SECONDS)
+ ? ZoneOffset.ofTotalSeconds(ta.get(OFFSET_SECONDS))
+ : ZONE;
+ int centibeats;
+ if (ta.isSupported(CENTIBEAT_OF_DAY)) {
+ centibeats = (int) ta.getLong(CENTIBEAT_OF_DAY);
+ } else if (ta.isSupported(BEAT_OF_DAY)) {
+ centibeats = (int) ta.getLong(BEAT_OF_DAY) * CENTIBEATS_PER_BEAT;
+ if (ta.isSupported(CENTIBEAT_OF_BEAT)) {
+ centibeats += (int) ta.getLong(CENTIBEAT_OF_BEAT);
+ }
+ } else if (ta.isSupported(MILLI_OF_DAY)) {
+ centibeats =
+ (int) CENTIBEATS.fromMillis(
+ InternetTimeField.toNormalizedMilliOfDay(ta, offset.getTotalSeconds()));
+ } else if (ta.isSupported(INSTANT_SECONDS)) {
+ long instantSecs = INSTANT_SECONDS.getFrom(ta);
+ long msPart =
+ ta.isSupported(NANO_OF_SECOND)
+ ? TimeUnit.NANOSECONDS.toMillis(NANO_OF_SECOND.getFrom(ta))
+ : 0;
+ return ofInstant(Instant.ofEpochMilli(TimeUnit.SECONDS.toMillis(instantSecs) + msPart));
+ } else {
+ throw cannotDeriveFrom(ta);
+ }
+ date = normalizeLocalDate(date, offset, centibeats / CENTIBEATS_PER_BEAT, 0 /* fractional */);
+ return new InternetTime(date, centibeats);
}
//
@@ -142,6 +506,11 @@ public int hashCode() {
return date.hashCode() * 31 + Integer.hashCode(centibeatOfDay);
}
+ /// Checks if this date-time is equal to another `InternetTime` date-time. Other types return
+ /// `false`.
+ ///
+ /// @param other the object to check, `null` returns `false`
+ /// @return `true` if `other` is an `InternetTime` representing the same instant on the time-line
@Override
public boolean equals(Object other) {
return other instanceof InternetTime it
@@ -149,6 +518,12 @@ public boolean equals(Object other) {
&& date.equals(it.date);
}
+ /// Compares this date-time to another date-time. The comparison is based on the local date,
+ /// then the cumulative centibeats elapsed since midnight. This comparison is consistent with
+ /// `equals()`.
+ ///
+ /// @param other the other date-time to compare to
+ /// @return the comparator value: negative if less, positive if greater
@Override
public int compareTo(InternetTime other) {
requireNonNull(other);
@@ -159,44 +534,60 @@ public int compareTo(InternetTime other) {
return val;
}
+ /// Outputs this date-time as a `String` suitable for debugging.
+ ///
+ /// The format of the output is not specified and is subject to change between revisions.
+ /// For stable, deterministic values use [#format].
+ ///
+ /// @return a string representation of this `InternetTime` date and time
@Override
public String toString() {
- int partialBeats = getCentibeatOfBeat();
- if (0 == partialBeats) {
- return String.format("%s@%03d", toLocalDate(), getBeat());
- } else {
- return String.format("%s@%03d.%02d", toLocalDate(), getBeat(), partialBeats);
- }
+ return RETRO_DATE_CENTIBEATS.format(this);
}
+ /// {@return the year field as a primitive `int` value}
+ ///
+ /// The year returned by this method is proleptic as per `get(YEAR)`. To obtain the year-of-era,
+ /// use `get(YEAR_OF_ERA)`.
public int getYear() {
return date.getYear();
}
+ /// {@return the month-of-year field using the `Month` enum}
+ ///
+ /// The enum provides the primitive [int value][Month#getValue], if required.
public Month getMonth() {
return date.getMonth();
}
+ /// {@return the day-of-month field as a primitive `int` value, from 1 to 31}
public int getDayOfMonth() {
return date.getDayOfMonth();
}
+ /// {@return the day-of-year field as a primitive `int` value, from 1 to 365 (366 in leap years)}
public int getDayOfYear() {
return date.getDayOfYear();
}
+ /// {@return the day-of-week field using the `DayOfWeek` enum}
+ ///
+ /// The enum provides the primitive [int value][DayOfWeek#getValue], if required.
public DayOfWeek getDayOfWeek() {
return date.getDayOfWeek();
}
+ /// {@return the beat-of-day field as a primitive `int` value, from 0 to 999}
public int getBeat() {
return centibeatOfDay / CENTIBEATS_PER_BEAT;
}
+ /// {@return the centibeat-of-day field as a primitive `int` value, from 0 to 99,999}
public int getCentibeatOfDay() {
return centibeatOfDay;
}
+ /// {@return the centibeat-of-beat field as a primitive `int` value, from 0 to 99}
public int getCentibeatOfBeat() {
return centibeatOfDay % CENTIBEATS_PER_BEAT;
}
@@ -209,23 +600,43 @@ private long millisecondOfDay() {
// -----------------------
+ /// Converts this date-time to an `Instant`
+ /// @return an `Instant` representing the same instant on the time-line
public Instant toInstant() {
var dateEpochSecond = toLocalDate().toEpochSecond(LocalTime.MIDNIGHT, ZONE);
return Instant.ofEpochMilli(millisecondOfDay() + TimeUnit.SECONDS.toMillis(dateEpochSecond));
}
+ /// Converts this date-time to an `OffsetDateTime`.
+ ///
+ /// The produced time is always in the time-zone offset UTC+1, but it can be
+ /// [separately converted][OffsetDateTime#withOffsetSameInstant] to a date-time in another
+ /// zone given the other zone's offset.
+ ///
+ /// @return an `OffsetDateTime` representing the same instant on the time-line
public OffsetDateTime toOffsetDateTime() {
return OffsetDateTime.of(toLocalDate(), toLocalTime(), ZONE);
}
+ /// Converts this date-time to a `ZonedDateTime`.
+ ///
+ /// The produced time is always in the time-zone offset UTC+1, but it can be
+ /// [separately converted][ZonedDateTime#withZoneSameInstant] to a date-time in another
+ /// zone given the other zone's [ZoneId].
+ ///
+ /// @return a `ZonedDateTime` representing the same instant on the time-line
public ZonedDateTime toZonedDateTime() {
return ZonedDateTime.of(toLocalDate(), toLocalTime(), ZONE);
}
+ /// Gets the local date-time in the *Internet Time* zone.
+ /// @return a `LocalDateTime` representing the date-time in the *Internet Time* UTC+1 offset
public LocalDateTime toLocalDateTime() {
return LocalDateTime.of(toLocalDate(), toLocalTime());
}
+ /// Gets the local date in the *Internet Time* zone.
+ /// @return a `LocalDate` representing the date in the *Internet Time* UTC+1 offset
public LocalDate toLocalDate() {
return date;
}
@@ -234,14 +645,42 @@ private LocalTime toLocalTime() {
return LocalTime.ofNanoOfDay(TimeUnit.MILLISECONDS.toNanos(millisecondOfDay()));
}
+ /// Gets the *Internet Time* ISO time-of-day without the date.
+ ///
+ /// The produced time is always in the time-zone offset UTC+1, but it can be
+ /// [separately converted][OffsetTime#withOffsetSameInstant(ZoneOffset)] to the time in another
+ /// zone given the other zone's offset. These values are useful for time-only display purposes
+ /// where the contextual date (if any) is handled separately.
+ ///
+ /// @return an `OffsetTime` representing the time-of-day in the *Internet Time* UTC+1 offset
public OffsetTime toOffsetTime() {
return OffsetTime.of(toLocalTime(), ZONE);
}
+ /// Equivalent to `timeOfBeat(beats, 0)`.
+ /// @see #timeOfBeat(int, int)
+ ///
+ /// @param beats time of day in whole *.beats*, from 0 to 999, inclusive
+ /// @return a standard hour, minute, second, and millisecond representation of the time
+ /// in the UTC+1 time-zone
+ /// @throws DateTimeException if input value is out of range
public static OffsetTime timeOfBeat(int beats) {
return timeOfBeat(beats, 0);
}
+ /// Produces an `OffsetTime` value for the given *.beat* and fractional portion.
+ ///
+ /// The produced time is always in the time-zone offset UTC+1, but it can be
+ /// [separately converted][OffsetTime#withOffsetSameInstant(ZoneOffset)] to the time in another
+ /// zone given the other zone's offset. These values are useful for time-only display purposes
+ /// where the contextual date (if any) is handled separately.
+ ///
+ /// @param beats time of day in whole *.beats*, from 0 to 999, inclusive
+ /// @param hundredthsOfBeat partial *.beat*, in centibeats elapsed since the beginning of `beats`,
+ /// from 0 to 99, inclusive
+ /// @return a standard hour, minute, second, and millisecond representation of the time
+ /// in the UTC+1 time-zone
+ /// @throws DateTimeException if any input values are out of range
public static OffsetTime timeOfBeat(int beats, int hundredthsOfBeat) {
long msOfDay = beatsToMillisOfDay(beats, hundredthsOfBeat);
int totalSec = (int) (msOfDay / 1000);
@@ -262,18 +701,36 @@ private static long beatsToMillisOfDay(int beats, int centibeatsOfBeat) {
// -----------------------------
+ /// Aligns a date-time value of another type to the first millisecond-of-day of its equivalent
+ /// *Internet Time* value. Supported `Temporal` types must at minimum allow querying and
+ /// adjusting the [millisecond-of-day][ChronoField#MILLI_OF_DAY] field. Date-only values, such
+ /// as `LocalDate`, are not supported.
+ ///
+ /// The alignment resolution, to the *.beat* or *centibeat*, is selected by specifying one of
+ /// the [InternetTimeField] enum instances. For the purposes of this operation, the field
+ /// [InternetTimeField#CENTIBEAT_OF_BEAT] aligns to the same millisecond as
+ /// [InternetTimeField#CENTIBEAT_OF_DAY].
+ ///
+ /// @param type of `Temporal` value to align and return
+ /// @param field resolution to use for alignment
+ /// @param temporal the date-time value from which to derive the aligned result
+ /// @return a value at or just before `temporal`, and of the same type, aligned to the beginning
+ /// millisecond-of-day at the given field resolution of *Internet Time*
+ /// @throws UnsupportedTemporalTypeException if the `temporal` value type is not supported
public static R toStartOf(InternetTimeField field, R temporal) {
requireNonNull(field, "field");
TemporalLongToObj factory;
long millis;
switch (requireNonNull(temporal, "temporal")) {
- case InternetTime it when 0 == it.getCentibeatOfBeat() || CENTIBEAT_OF_DAY.equals(field) -> {
+ case InternetTime it when
+ 0 == it.getCentibeatOfBeat() || CENTIBEATS.equals(field.getBaseUnit()) -> {
return temporal; // under these conditions, already aligned to the requested field
}
case Instant i -> {
- long epochMillis = i.toEpochMilli();
- long millisOfDay = epochMillis % MILLIS_PER_DAY;
- millis = epochMillis - millisOfDay + field.truncate(millisOfDay);
+ var odt = i.atOffset(ZONE);
+ millis = field.truncate(odt.getLong(MILLI_OF_DAY));
+ millis = (millis % 1000)
+ + TimeUnit.SECONDS.toMillis(odt.with(MILLI_OF_DAY, millis).toEpochSecond());
factory = (__, val) -> Instant.ofEpochMilli(val);
}
default -> {
@@ -289,16 +746,19 @@ public static R toStartOf(InternetTimeField field, R tempor
return r;
}
+ /// Creates an `OffsetDateTime` at the nearest whole-second boundary. All smaller units, such as
+ /// millisecond-of-second, etc., will be zero(0).
+ ///
+ /// Within a day, only 0.8% of `InternetTime` values align naturally on a whole-second boundary.
+ /// The nearest second can occur *before or after* this `InternetTime` value on the time line.
+ ///
+ /// @return an `OffsetDateTime` near the current instant represented by this `InternetTime`, but
+ /// aligned on a whole-second (second-of-day) boundary
public OffsetDateTime toNearestSecond() {
var odt = toOffsetDateTime();
- long msOfDay = odt.get(MILLI_OF_DAY);
- long remainder = msOfDay % 1000;
- msOfDay += (Long.signum(499 - remainder) >>> 31) * 1000 - remainder;
- if (msOfDay >= MILLIS_PER_DAY) {
- odt = odt.plusSeconds(1);
- msOfDay = 0;
- }
- return odt.with(MILLI_OF_DAY, msOfDay);
+ int remainder = odt.get(MILLI_OF_SECOND);
+ int adjustment = (Long.signum(499 - remainder) >>> 31) * 1000 - remainder;
+ return odt.plusNanos(TimeUnit.MILLISECONDS.toNanos(adjustment));
}
//
@@ -310,7 +770,7 @@ public boolean isSupported(TemporalField field) {
return switch (field) {
case null -> false;
case InternetTimeField __ -> true;
- case ChronoField cf -> ANALOG.isSupported(cf);
+ case ChronoField cf -> OffsetDateTime.MIN.isSupported(cf); // this should behave like an ODT
default -> field.isSupportedBy(this);
};
}
@@ -341,20 +801,21 @@ public R query(TemporalQuery query) {
@Override
public long getLong(TemporalField field) {
return switch (field) {
- case InternetTimeField itf -> getFieldAsLong(itf);
- case ChronoField cf -> getFieldAsLong(cf);
+ case InternetTimeField itf -> getLong(itf);
+ case ChronoField cf -> getLong(cf);
default -> field.getFrom(this);
};
}
- private long getFieldAsLong(InternetTimeField field) {
+ long getLong(InternetTimeField field) {
return switch (field) {
- case CENTIBEAT_OF_DAY -> getCentibeatOfDay();
case BEAT_OF_DAY -> getBeat();
+ case CENTIBEAT_OF_DAY -> getCentibeatOfDay();
+ case CENTIBEAT_OF_BEAT -> getCentibeatOfBeat();
};
}
- private long getFieldAsLong(ChronoField field) {
+ private long getLong(ChronoField field) {
return switch (field) {
case NANO_OF_DAY -> millisecondOfDay() * 1_000_000;
@@ -362,8 +823,7 @@ private long getFieldAsLong(ChronoField field) {
case OFFSET_SECONDS -> ZONE.getTotalSeconds();
- case INSTANT_SECONDS ->
- date.toEpochSecond(LocalTime.MIDNIGHT, ZONE) + getFieldAsLong(SECOND_OF_DAY);
+ case INSTANT_SECONDS -> date.toEpochSecond(LocalTime.MIDNIGHT, ZONE) + getLong(SECOND_OF_DAY);
case MILLI_OF_DAY -> millisecondOfDay();
@@ -373,11 +833,11 @@ private long getFieldAsLong(ChronoField field) {
case HOUR_OF_DAY -> millisecondOfDay() / (60 * 60_000);
- case CLOCK_HOUR_OF_DAY -> { var hr = getFieldAsLong(HOUR_OF_DAY); yield 0 == hr ? 24 : hr; }
+ case CLOCK_HOUR_OF_DAY -> { var hr = getLong(HOUR_OF_DAY); yield 0 == hr ? 24 : hr; }
- case HOUR_OF_AMPM -> getFieldAsLong(HOUR_OF_DAY) % 12;
+ case HOUR_OF_AMPM -> getLong(HOUR_OF_DAY) % 12;
- case CLOCK_HOUR_OF_AMPM -> { var hr = getFieldAsLong(HOUR_OF_AMPM); yield 0 == hr ? 12 : hr; }
+ case CLOCK_HOUR_OF_AMPM -> { var hr = getLong(HOUR_OF_AMPM); yield 0 == hr ? 12 : hr; }
case AMPM_OF_DAY -> {
// if time is before (1ms before Noon) then signum will be -1, negated -> +1, and shift -> 0
@@ -414,7 +874,9 @@ public boolean isSupported(TemporalUnit unit) {
return switch (unit) {
case null -> false;
case InternetTimeUnit __ -> true;
- case ChronoUnit cu -> cu.ordinal() >= ChronoUnit.MILLIS.ordinal() && ANALOG.isSupported(cu);
+ case ChronoUnit cu ->
+ cu.ordinal() >= ChronoUnit.MILLIS.ordinal() // does not support finer resolution than ms
+ && OffsetDateTime.MIN.isSupported(cu); // otherwise this should behave like OffsetDateTime
default -> unit.isSupportedBy(this);
};
}
@@ -460,6 +922,8 @@ public InternetTime with(TemporalField field, long newValue) {
return switch (itf) {
case BEAT_OF_DAY -> new InternetTime(date, itfValue * CENTIBEATS_PER_BEAT);
case CENTIBEAT_OF_DAY -> new InternetTime(date, itfValue);
+ case CENTIBEAT_OF_BEAT ->
+ new InternetTime(date, getCentibeatOfDay() - getCentibeatOfBeat() + itfValue);
};
}
if (field instanceof ChronoField cf) {
@@ -502,18 +966,6 @@ public InternetTime plus(long amount, TemporalUnit unit) {
//
- // ----------------
-
- // TODO!
-
- //
-
- // -------------------
-
- // TODO!
-
- //
-
@FunctionalInterface
private interface TemporalLongToObj {
T apply(Temporal temporal, long value);
diff --git a/src/main/java/io/github/wrprice/inettime/InternetTimeField.java b/src/main/java/io/github/wrprice/inettime/InternetTimeField.java
index 2759aa6..b378006 100644
--- a/src/main/java/io/github/wrprice/inettime/InternetTimeField.java
+++ b/src/main/java/io/github/wrprice/inettime/InternetTimeField.java
@@ -16,7 +16,6 @@
package io.github.wrprice.inettime;
import static io.github.wrprice.inettime.InternetTimeUnit.*;
-import static java.time.temporal.ChronoUnit.DAYS;
import static java.util.concurrent.TimeUnit.SECONDS;
import static java.util.Objects.requireNonNull;
@@ -24,6 +23,7 @@
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.LocalTime; // for Javadoc reference
+import java.time.ZoneOffset;
import java.time.temporal.*;
import java.util.Locale;
@@ -52,6 +52,28 @@ public enum InternetTimeField implements TemporalField {
///
/// Counts the *centibeats* within the day, from 0 to (100,000 - 1).
CENTIBEAT_OF_DAY("CentibeatOfDay", CENTIBEATS, 999_99),
+
+ /// The [centibeats][InternetTimeUnit#CENTIBEATS] elapsed since the last
+ /// [*.beat*][#BEAT_OF_DAY]-of-day boundary, from 0 to (100 - 1).
+ CENTIBEAT_OF_BEAT("CentibeatOfBeat", CENTIBEATS, 99) {
+ @Override
+ public TemporalUnit getRangeUnit() {
+ return InternetTimeUnit.BEATS;
+ }
+
+ @Override
+ public long getFrom(TemporalAccessor temporal) {
+ return super.getFrom(temporal) % CENTIBEATS_PER_BEAT;
+ }
+
+ @Override
+ long adjustIntoMillisOfDayWithOffset(long newValue, Temporal temporal, int utcOffsetSecs) {
+ long centibeats = CENTIBEAT_OF_DAY.getFrom(temporal);
+ long priorValue = centibeats % CENTIBEATS_PER_BEAT;
+ centibeats += newValue - priorValue;
+ return super.adjustIntoMillisOfDayWithOffset(centibeats, temporal, utcOffsetSecs);
+ }
+ }
; // end of enum instances
private static final long MAX_MILLI_OF_DAY =
@@ -83,16 +105,23 @@ public String getDisplayName(Locale locale) {
return toString(); // FUTURE: localization
}
- /// {@return an [InternetTimeUnit]}
+ /// The unit this field is measured in.
+ ///
+ /// @return an [InternetTimeUnit]
@Override
public TemporalUnit getBaseUnit() {
return unit;
}
- /// {@return [ChronoUnit#DAYS]} All *Internet Time*-specific units are relative to a single day.
+ /// The unit this field in bound by.
+ ///
+ /// For example, for [#BEAT_OF_DAY] this returns [ChronoUnit#DAYS] but for [#CENTIBEAT_OF_BEAT]
+ /// this returns [InternetTimeUnit#BEATS].
+ ///
+ /// @return denominator unit that defines the range
@Override
public TemporalUnit getRangeUnit() {
- return DAYS;
+ return ChronoUnit.DAYS;
}
/// {@return a value from 0 to the upper range of the field (see enum instance documentation)}
@@ -132,7 +161,8 @@ private void throwIfNotSupported(TemporalAccessor temporal) {
}
DateTimeException exceptionForUnsupported(TemporalAccessor ta) {
- return new UnsupportedTemporalTypeException(ta.getClass() + " does not support " + this);
+ return new UnsupportedTemporalTypeException(
+ this + " not supported by " + ta.getClass() + ": " + ta);
}
/// {@return equivalent to [#range()]} *Internet Time* field ranges do not vary by date nor
@@ -147,14 +177,18 @@ public ValueRange rangeRefinedBy(TemporalAccessor temporal) {
@Override
public long getFrom(TemporalAccessor temporal) {
- if (temporal instanceof InternetTime it) {
- return switch (this) {
- case BEAT_OF_DAY -> it.getBeat();
- case CENTIBEAT_OF_DAY -> it.getCentibeatOfDay();
- };
+ final int utcOffsetSecs;
+ switch (temporal) {
+ case InternetTime it -> { return it.getLong(this); }
+ case Instant i -> {
+ utcOffsetSecs = ZoneOffset.UTC.getTotalSeconds();
+ temporal = i.atOffset(ZoneOffset.UTC);
+ }
+ default -> {
+ throwIfNotSupported(temporal);
+ utcOffsetSecs = temporal.get(ChronoField.OFFSET_SECONDS);
+ }
}
- throwIfNotSupported(temporal);
- final long utcOffsetSecs = ChronoField.OFFSET_SECONDS.getFrom(temporal);
return unit.fromMillis(toNormalizedMilliOfDay(temporal, utcOffsetSecs));
}
@@ -163,30 +197,41 @@ long getFrom(LocalDateTime temporal, int utcOffsetSecs) {
return unit.fromMillis(toNormalizedMilliOfDay(temporal, utcOffsetSecs));
}
+ static long toNormalizedMilliOfDay(TemporalAccessor temporal, int utcOffsetSecs) {
+ long milliOfDay = ChronoField.MILLI_OF_DAY.getFrom(temporal);
+ milliOfDay = Math.subtractExact(milliOfDay, SECONDS.toMillis(utcOffsetSecs)); // to UTC
+ milliOfDay = Math.addExact(milliOfDay, INET_UTC_OFFSET_MILLIS); // UTC --> INet Time
+ return wrapMilliOfDay(milliOfDay);
+ }
+
@Override
public R adjustInto(R temporal, long newValue) {
if (!range().isValidValue(newValue)) {
throw new DateTimeException("Value out of range for " + this + ": " + newValue);
}
- if (temporal instanceof InternetTime it) {
- @SuppressWarnings("unchecked")
- R r = (R) it.with(this, newValue);
- return r;
+ switch (temporal) {
+ case InternetTime it -> {
+ @SuppressWarnings("unchecked")
+ R r = (R) it.with(this, newValue);
+ return r;
+ }
+ case Instant i -> {
+ @SuppressWarnings("unchecked")
+ R r = (R) adjustInto(i.atOffset(ZoneOffset.UTC), newValue).toInstant();
+ return r;
+ }
+ default -> throwIfNotSupported(temporal);
}
- throwIfNotSupported(temporal);
- final long utcOffsetSecs = ChronoField.OFFSET_SECONDS.getFrom(temporal);
- final long millisOfDay = toOffsetMilliOfDay(unit.toMillis(newValue), utcOffsetSecs);
+ int utcOffsetSecs = temporal.get(ChronoField.OFFSET_SECONDS);
+ long millisOfDay = adjustIntoMillisOfDayWithOffset(newValue, temporal, utcOffsetSecs);
return ChronoField.MILLI_OF_DAY.adjustInto(temporal, millisOfDay);
}
- private long toNormalizedMilliOfDay(TemporalAccessor temporal, long utcOffsetSecs) {
- long milliOfDay = ChronoField.MILLI_OF_DAY.getFrom(temporal);
- milliOfDay = Math.subtractExact(milliOfDay, SECONDS.toMillis(utcOffsetSecs)); // to UTC
- milliOfDay = Math.addExact(milliOfDay, INET_UTC_OFFSET_MILLIS); // UTC --> INet Time
- return wrapMilliOfDay(milliOfDay);
+ long adjustIntoMillisOfDayWithOffset(long newValue, Temporal temporal, int utcOffsetSecs) {
+ return toOffsetMilliOfDay(unit.toMillis(newValue), utcOffsetSecs);
}
- private long toOffsetMilliOfDay(long normalizedMillisOfDay, long utcOffsetSecs) {
+ private long toOffsetMilliOfDay(long normalizedMillisOfDay, int utcOffsetSecs) {
long milliOfDay = normalizedMillisOfDay;
milliOfDay = Math.subtractExact(milliOfDay, INET_UTC_OFFSET_MILLIS); // INet Time --> UTC
milliOfDay = Math.addExact(milliOfDay, SECONDS.toMillis(utcOffsetSecs)); // UTC -> wanted offset
@@ -200,7 +245,7 @@ static long wrapMilliOfDay(long value) {
} else if (value > MAX_MILLI_OF_DAY) {
value %= MILLIS_PER_DAY;
}
- assert value >= 0 && value <= MAX_MILLI_OF_DAY;
+ assert ChronoField.MILLI_OF_DAY.range().checkValidValue(value, ChronoField.MILLI_OF_DAY) >= 0;
return value;
}
diff --git a/src/main/java/io/github/wrprice/inettime/PrivateField.java b/src/main/java/io/github/wrprice/inettime/PrivateField.java
deleted file mode 100644
index d7f8baf..0000000
--- a/src/main/java/io/github/wrprice/inettime/PrivateField.java
+++ /dev/null
@@ -1,98 +0,0 @@
-/*
- * Copyright 2025 William R. Price - All rights reserved.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package io.github.wrprice.inettime;
-
-import static io.github.wrprice.inettime.InternetTimeUnit.*;
-import static java.util.Objects.requireNonNull;
-
-import java.time.temporal.Temporal;
-import java.time.temporal.TemporalAccessor;
-import java.time.temporal.TemporalField;
-import java.time.temporal.TemporalUnit;
-import java.time.temporal.ValueRange;
-
-/**
- *
- * @author wrprice
- */
-enum PrivateField implements TemporalField {
-
- CENTIBEAT_OF_BEAT("CentibeatOfBeat", CENTIBEATS, BEATS, 99);
-
- private final String name;
- private final InternetTimeUnit unit;
- private final TemporalUnit rangeUnit;
- private final ValueRange range;
-
- private PrivateField(
- String name, InternetTimeUnit unit, TemporalUnit rangeUnit, int rangeMax) {
- this.name = name;
- this.unit = unit;
- this.rangeUnit = rangeUnit;
- this.range = ValueRange.of(0, rangeMax);
- }
-
- @Override
- public String toString() {
- return name;
- }
-
- @Override
- public TemporalUnit getBaseUnit() {
- return unit;
- }
-
- @Override
- public TemporalUnit getRangeUnit() {
- return rangeUnit;
- }
-
- @Override
- public ValueRange range() {
- return range;
- }
-
- @Override
- public boolean isDateBased() {
- return false;
- }
-
- @Override
- public boolean isTimeBased() {
- return false;
- }
-
- @Override
- public boolean isSupportedBy(TemporalAccessor ta) {
- return false;
- }
-
- @Override
- public ValueRange rangeRefinedBy(TemporalAccessor ta) {
- requireNonNull(ta);
- return range();
- }
-
- @Override
- public long getFrom(TemporalAccessor ta) {
- throw new UnsupportedOperationException();
- }
-
- @Override
- public R adjustInto(R r, long l) {
- throw new UnsupportedOperationException();
- }
-}
diff --git a/src/test/java/io/github/wrprice/inettime/InternetTimeFieldTest.java b/src/test/java/io/github/wrprice/inettime/InternetTimeFieldTest.java
index 12942aa..aeab70a 100644
--- a/src/test/java/io/github/wrprice/inettime/InternetTimeFieldTest.java
+++ b/src/test/java/io/github/wrprice/inettime/InternetTimeFieldTest.java
@@ -32,6 +32,7 @@
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.EnumSource;
/// @author William R. Price
public class InternetTimeFieldTest {
@@ -39,7 +40,6 @@ public class InternetTimeFieldTest {
@Test
void invariants() {
- assertInvariant(InternetTimeField::getRangeUnit, ChronoUnit.DAYS, "getRangeUnit");
assertInvariant(InternetTimeField::isDateBased, false, "isDateBased");
assertInvariant(InternetTimeField::isTimeBased, true, "isTimeBased");
assertComputedInvariant(
@@ -66,6 +66,7 @@ void toStringValue() {
var expected = switch(field) {
case BEAT_OF_DAY -> "BeatOfDay";
case CENTIBEAT_OF_DAY -> "CentibeatOfDay";
+ case CENTIBEAT_OF_BEAT -> "CentibeatOfBeat";
};
assertEquals(expected, field.toString(), field.name());
}
@@ -84,17 +85,31 @@ void getBaseUnit() {
var expected = switch(field) {
case BEAT_OF_DAY -> InternetTimeUnit.BEATS;
case CENTIBEAT_OF_DAY -> InternetTimeUnit.CENTIBEATS;
+ case CENTIBEAT_OF_BEAT -> InternetTimeUnit.CENTIBEATS;
};
assertEquals(expected, field.getBaseUnit(), field.name());
}
}
+ @Test
+ void geRangeUnit() {
+ for (var field : FIELDS) {
+ var expected = switch(field) {
+ case BEAT_OF_DAY -> ChronoUnit.DAYS;
+ case CENTIBEAT_OF_DAY -> ChronoUnit.DAYS;
+ case CENTIBEAT_OF_BEAT -> InternetTimeUnit.BEATS;
+ };
+ assertEquals(expected, field.getRangeUnit(), field.name());
+ }
+ }
+
@Test
void range() {
for (var field : FIELDS) {
var expected = switch(field) {
case BEAT_OF_DAY -> ValueRange.of(0, 999);
case CENTIBEAT_OF_DAY -> ValueRange.of(0, 999_99);
+ case CENTIBEAT_OF_BEAT -> ValueRange.of(0, 99);
};
assertEquals(expected, field.range(), field.name());
}
@@ -200,32 +215,52 @@ void getFromInternetTime() {
var expected = switch (field) {
case BEAT_OF_DAY -> it.getBeat();
case CENTIBEAT_OF_DAY -> it.getCentibeatOfDay();
+ case CENTIBEAT_OF_BEAT -> it.getCentibeatOfBeat();
};
assertEquals(expected, field.getFrom(it), field.name());
}
}
- @Test
- void getFromTemporal() {
+ @ParameterizedTest
+ @EnumSource(mode = EnumSource.Mode.MATCH_ALL)
+ void getFromInstant(InternetTimeField field) {
+ long msOfDay = 864 * 12_345L;
+ var instant =
+ ZonedDateTime.now(ZoneOffset.ofHours(-6))
+ .with(ChronoField.MILLI_OF_DAY, msOfDay)
+ .toInstant();
+ int utcOffsetSec = -6 * 3600;
+ long expectedNetMillisOfDay = (msOfDay - ((utcOffsetSec - 3600) * 1000L));
+ var expected = switch (field) {
+ case BEAT_OF_DAY -> expectedNetMillisOfDay / 86400;
+ case CENTIBEAT_OF_DAY -> expectedNetMillisOfDay / 864;
+ case CENTIBEAT_OF_BEAT -> expectedNetMillisOfDay / 864 % 100;
+ };
+ assertEquals(expected, field.getFrom(instant));
+ }
+
+ @ParameterizedTest
+ @EnumSource(mode = EnumSource.Mode.MATCH_ALL)
+ void getFromTemporal(InternetTimeField field) {
long msOfDay = 864 * 12_345L;
- long utcOffsetSec = -6 * 3600L;
- long expectedNetMillisOfDay = (msOfDay - ((utcOffsetSec - 3600) * 1000));
+ int utcOffsetSec = -6 * 3600;
+ long expectedNetMillisOfDay = (msOfDay - ((utcOffsetSec - 3600) * 1000L));
var temp = makeMockTemporal(msOfDay, utcOffsetSec);
- for (var field : FIELDS) {
- var expected = switch (field) {
- case BEAT_OF_DAY -> expectedNetMillisOfDay / 86400;
- case CENTIBEAT_OF_DAY -> expectedNetMillisOfDay / 864;
- };
- assertEquals(expected, field.getFrom(temp), field.name());
- }
+ var expected = switch (field) {
+ case BEAT_OF_DAY -> expectedNetMillisOfDay / 86400;
+ case CENTIBEAT_OF_DAY -> expectedNetMillisOfDay / 864;
+ case CENTIBEAT_OF_BEAT -> expectedNetMillisOfDay / 864 % 100;
+ };
+ assertEquals(expected, field.getFrom(temp));
}
- private Temporal makeMockTemporal(long milliOfDay, long offsetSec) {
+ private Temporal makeMockTemporal(long milliOfDay, int offsetSec) {
var temporal = mock(Temporal.class);
when(temporal.isSupported(ChronoField.MILLI_OF_DAY)).thenReturn(true);
when(temporal.isSupported(ChronoField.OFFSET_SECONDS)).thenReturn(true);
when(temporal.getLong(ChronoField.MILLI_OF_DAY)).thenReturn(milliOfDay);
- when(temporal.getLong(ChronoField.OFFSET_SECONDS)).thenReturn(offsetSec);
+ when(temporal.getLong(ChronoField.OFFSET_SECONDS)).thenReturn((long) offsetSec);
+ when(temporal.get(ChronoField.OFFSET_SECONDS)).thenReturn(offsetSec);
return temporal;
}
@@ -252,36 +287,42 @@ void adjustIntoOutOfRange() {
}
}
- @Test
- void adjustInto() {
+ @ParameterizedTest
+ @EnumSource(mode = EnumSource.Mode.MATCH_ALL)
+ void adjustInto(InternetTimeField field) {
var it = InternetTime.now();
var odt = it.toOffsetDateTime().withOffsetSameInstant(ZoneOffset.ofHours(-6));
+ var instant = it.toInstant();
if (it.getBeat() == 123) {
it = it.plus(1, ChronoUnit.HOURS);
odt = odt.plus(1, ChronoUnit.HOURS);
+ instant = instant.plus(1, ChronoUnit.HOURS);
}
assertEquals(odt.toInstant(), it.toInstant(), "pre-requisite");
- for (var field : FIELDS) {
- var toSet = switch (field) {
- case BEAT_OF_DAY -> 123;
- case CENTIBEAT_OF_DAY -> 123_45;
- };
- var newIt = field.adjustInto(it, toSet);
- var newOdt = field.adjustInto(odt, toSet);
- assertInstanceOf(InternetTime.class, newIt);
- assertInstanceOf(OffsetDateTime.class, newOdt);
- assertNotSame(it, newIt);
- assertNotSame(odt, newOdt);
- assertEquals(toSet, newIt.get(field), field + " InternetTime");
- assertEquals(toSet, newOdt.get(field), field + " OffsetDateTime");
- assertEquals(it.toLocalDate(), newIt.toLocalDate(), field + " InternetTime -> LocalDate");
- assertEquals(odt.toLocalDate(), newOdt.toLocalDate(), field + " OffsetDateTime -> LocalDate");
- assertEquals(
- newOdt.withOffsetSameInstant(ZoneOffset.UTC).toLocalTime(),
- newIt.toLocalDateTime().minusHours(1).toLocalTime(),
- field + " (ODT vs IT) in UTC local time (ignoring date)");
- }
+ var toSet = switch (field) {
+ case BEAT_OF_DAY -> 123;
+ case CENTIBEAT_OF_DAY -> 123_45;
+ case CENTIBEAT_OF_BEAT -> 67;
+ };
+ var newIt = field.adjustInto(it, toSet);
+ var newOdt = field.adjustInto(odt, toSet);
+ var newInstant = field.adjustInto(instant, toSet);
+ assertInstanceOf(InternetTime.class, newIt);
+ assertInstanceOf(OffsetDateTime.class, newOdt);
+ assertInstanceOf(Instant.class, newInstant);
+ assertNotEquals(it, newIt);
+ assertNotEquals(odt, newOdt);
+ assertNotEquals(instant, newInstant);
+ assertEquals(toSet, newIt.get(field), "InternetTime");
+ assertEquals(toSet, newOdt.get(field), "OffsetDateTime");
+ assertEquals(toSet, newInstant.get(field), "InternetTime");
+ assertEquals(it.toLocalDate(), newIt.toLocalDate(), "InternetTime -> LocalDate");
+ assertEquals(odt.toLocalDate(), newOdt.toLocalDate(), "OffsetDateTime -> LocalDate");
+ assertEquals(
+ newOdt.withOffsetSameInstant(ZoneOffset.UTC).toLocalTime(),
+ newIt.toLocalDateTime().minusHours(1).toLocalTime(),
+ "(ODT vs IT) in UTC local time (ignoring date)");
}
@Test
diff --git a/src/test/java/io/github/wrprice/inettime/InternetTimeTest.java b/src/test/java/io/github/wrprice/inettime/InternetTimeTest.java
index bbab652..0ae69d0 100644
--- a/src/test/java/io/github/wrprice/inettime/InternetTimeTest.java
+++ b/src/test/java/io/github/wrprice/inettime/InternetTimeTest.java
@@ -25,8 +25,11 @@
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import java.time.*;
+import java.time.format.DateTimeFormatter;
+import java.time.format.FormatStyle;
import java.time.temporal.*;
import java.util.List;
+import java.util.Map;
import java.util.function.Supplier;
import java.util.stream.LongStream;
import org.junit.jupiter.api.Test;
@@ -91,7 +94,7 @@ void wikipediaMidnightExamples(String city, int utcOffsetHours) {
var it2 = InternetTime.of(cityODT.toLocalDate(), 0, 0, cityOffset); // local date at beat @000
assertEquals(it, it2, "created from localdate + offset");
- assertEquals("2025-10-12@000", it.toString(), "toString");
+ assertEquals("d12.10.2025 @000.00", it.toString(), "toString");
assertEquals(0, it.getBeat(), "beat");
assertEquals(0, it.getCentibeatOfDay(), "centibeat");
assertEquals(0, it.getCentibeatOfBeat(), "centibeat-of-beat");
@@ -271,12 +274,8 @@ void compareTo() {
@Test
void toStringFormat() {
- var date = LocalDate.now();
- it = InternetTime.of(date, 234, 56, InternetTime.ZONE);
- assertEquals(date + "@234.56", it.toString(), "with fractional beats");
-
- it = it.with(BEAT_OF_DAY, 789);
- assertEquals(date + "@789", it.toString(), "exact whole beats");
+ it = InternetTime.of(2025, 12, 31, 234, 56, InternetTime.ZONE);
+ assertEquals("d31.12.2025 @234.56", it.toString());
}
@ParameterizedTest
@@ -325,12 +324,16 @@ void toStartOfBeatNullChecks() {
@Test
void toStartOfBeatFromInternetTime() {
assertSame(it, InternetTime.toStartOf(CENTIBEAT_OF_DAY, it), "always centibeat-aligned");
+
var it2 = InternetTime.of(it.toLocalDate(), Math.max(1, it.getBeat()), 0, InternetTime.ZONE);
assertSame(it2, InternetTime.toStartOf(BEAT_OF_DAY, it2), "any beat without fractional part");
var it3 = InternetTime.of(it.toLocalDate(), Math.max(1, it.getBeat()), 67, InternetTime.ZONE);
- assertInstanceOf(InternetTime.class, it3);
- assertEquals(it2, InternetTime.toStartOf(BEAT_OF_DAY, it3), "non-aligned");
+ var it4 = InternetTime.toStartOf(BEAT_OF_DAY, it3);
+ assertInstanceOf(InternetTime.class, it4);
+ assertEquals(Math.max(1, it.getBeat()), it4.getBeat(), "beat value");
+ assertEquals(0, it4.getCentibeatOfBeat(), "centibeat-of-beat value");
+ assertEquals(it2, it4, "non-aligned");
}
@Test
@@ -344,6 +347,20 @@ void toStartOfBeatFromInstant() {
assertEqualsWithTolerance(BEATS, in, in2, "delta w/in 1 beat");
}
+ @Test
+ void toStartOfBeatFromOffsetDateTime() {
+ var odt = InternetTime.toStartOf(CENTIBEAT_OF_DAY, it.toOffsetDateTime());
+ assertInstanceOf(OffsetDateTime.class, odt);
+ assertEquals(it.toInstant(), odt.toInstant(), "instant exact");
+ int beat = odt.get(BEAT_OF_DAY);
+
+ var odt2 = InternetTime.toStartOf(BEAT_OF_DAY, it.toOffsetDateTime());
+ assertThat("beat-aligned vs centi-aligned", odt2, lessThanOrEqualTo(odt));
+ assertEqualsWithTolerance(BEATS, odt, odt2, "delta w/in 1 beat");
+ assertEquals(beat, odt2.get(BEAT_OF_DAY), "beat value");
+ assertEquals(0, odt2.get(CENTIBEAT_OF_DAY) % CENTIBEATS_PER_BEAT, "centibeat-of-beat value");
+ }
+
@Test
void toStartOfBeatFromUnsupportedTemporals() {
var values = List.of(LocalDate.now(), YearMonth.now());
@@ -383,7 +400,11 @@ void isSupportedField() {
for (var cf : ChronoField.values()) {
assertTrue(it.isSupported(cf), cf.name());
}
- assertFalse(it.isSupported(PrivateField.CENTIBEAT_OF_BEAT), "other field type");
+
+ var otherType = mock(TemporalField.class);
+ when(otherType.isSupportedBy(it)).thenReturn(false);
+ assertFalse(it.isSupported(otherType), "other field type");
+ verify(otherType).isSupportedBy(it);
}
@Test
@@ -396,10 +417,11 @@ void range() {
for (var cf : ChronoField.values()) {
assertEquals(odt.range(cf), it.range(cf), cf.name());
}
- assertEquals(
- PrivateField.CENTIBEAT_OF_BEAT.range(),
- it.range(PrivateField.CENTIBEAT_OF_BEAT),
- "other type");
+
+ var otherRange = ValueRange.of(-1, +1);
+ var otherType = mock(TemporalField.class);
+ when(otherType.rangeRefinedBy(it)).thenReturn(otherRange);
+ assertEquals(otherRange, it.range(otherType), "other field type");
}
@Test
@@ -522,6 +544,7 @@ void withInternetTimeFields() {
it = InternetTime.of(it.toLocalDate(), 420, 69, InternetTime.ZONE);
assertEquals(113, it.with(BEAT_OF_DAY, 113).getBeat(), "beat of day");
assertEquals(70, it.with(CENTIBEAT_OF_DAY, 70).getCentibeatOfDay(), "centibeat of day");
+ assertEquals(70, it.with(CENTIBEAT_OF_BEAT, 70).getCentibeatOfBeat(), "centibeat of beat");
}
@Test
@@ -595,4 +618,144 @@ void plusChronoFields() {
}
}
}
+
+ @Test
+ void staticFormatters() {
+ it = it.with(CENTIBEAT_OF_DAY, 234_67);
+ var localDateStr = it.toLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE);
+ var cases =
+ Map.of(
+ InternetTime.LOCAL_DATE_BEATS, localDateStr + " @234",
+ InternetTime.LOCAL_DATE_CENTIBEATS, localDateStr + " @234.67",
+ InternetTime.OFFSET_DATE_BEATS, localDateStr + "+01:00 @234",
+ InternetTime.OFFSET_DATE_CENTIBEATS, localDateStr + "+01:00 @234.67");
+ cases.forEach(
+ (fmt, expected) -> {
+ assertEquals(expected, fmt.format(it), fmt + " fmt.format(it)");
+ assertEquals(expected, it.format(fmt), fmt + " it.format(fmt)");
+ });
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "SHORT, 234",
+ "MEDIUM, @234",
+ "LONG, 234.67",
+ "FULL, @234.67",
+ })
+ void beatFormatter(FormatStyle style, String expected) {
+ it = it.with(CENTIBEAT_OF_DAY, 234_67);
+ var fmt = InternetTime.beatFormatter(style);
+ assertEquals(expected, fmt.format(it), "formatter.format(it)");
+ assertEquals(expected, it.format(fmt), "it.format(formatter)");
+ }
+
+ @Test
+ void beatFormatterNullStyle() {
+ assertThrows(NullPointerException.class, () -> InternetTime.beatFormatter(null));
+ }
+
+ @Test
+ void formatNull() {
+ assertThrows(NullPointerException.class, () -> it.format(null));
+ }
+
+ @Test
+ void parseNullFormatter() {
+ assertThrows(NullPointerException.class, () -> InternetTime.parse("", null));
+ }
+
+ @Test
+ void parseNullText() {
+ var parser = InternetTime.OFFSET_DATE_BEATS;
+ assertThrows(NullPointerException.class, () -> InternetTime.parse(null, parser));
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "LOCAL_DATE_BEATS, 2026-01-02 @345, 2026-01-02, 345, 0",
+ "LOCAL_DATE_CENTIBEATS, 2026-01-02 @345.78, 2026-01-02, 345, 78",
+ "LOCAL_DATE_CENTIBEATS, 2026-01-02 @345, 2026-01-02, 345, 0", // optional fraction
+ "OFFSET_DATE_BEATS, 2026-01-02+01:00 @345, 2026-01-02, 345, 0",
+ "OFFSET_DATE_CENTIBEATS, 2026-01-02+01:00 @345.78, 2026-01-02, 345, 78",
+ "OFFSET_DATE_CENTIBEATS, 2026-01-02+01:00 @345, 2026-01-02, 345, 0", // optional fraction
+ "OFFSET_DATE_BEATS, 2026-01-02+02:00 @345, 2026-01-02, 345, 0",
+ "OFFSET_DATE_CENTIBEATS, 2026-01-02+02:00 @345.78, 2026-01-02, 345, 78",
+ "OFFSET_DATE_BEATS, 2026-01-02+00:00 @345, 2026-01-02, 345, 0",
+ "OFFSET_DATE_CENTIBEATS, 2026-01-02+00:00 @345.78, 2026-01-02, 345, 78",
+ "OFFSET_DATE_BEATS, 2026-01-02-05:00 @345, 2026-01-02, 345, 0",
+ "OFFSET_DATE_CENTIBEATS, 2026-01-02-05:00 @345.78, 2026-01-02, 345, 78",
+ "OFFSET_DATE_BEATS, 2026-01-02-12:00 @345, 2026-01-03, 345, 0",
+ "OFFSET_DATE_CENTIBEATS, 2026-01-02-12:00 @345.23, 2026-01-03, 345, 23",
+ "OFFSET_DATE_BEATS, 2026-01-02+12:00 @789, 2026-01-01, 789, 0",
+ "OFFSET_DATE_CENTIBEATS, 2026-01-02+12:00 @789.23, 2026-01-01, 789, 23",
+ })
+ void parseStaticFormats(
+ String fmtName, String input, LocalDate expectDateUtc1, int expectBeats, int expectCenti) {
+ var fmt = Map.of(
+ "LOCAL_DATE_BEATS", InternetTime.LOCAL_DATE_BEATS,
+ "LOCAL_DATE_CENTIBEATS", InternetTime.LOCAL_DATE_CENTIBEATS,
+ "OFFSET_DATE_BEATS", InternetTime.OFFSET_DATE_BEATS,
+ "OFFSET_DATE_CENTIBEATS", InternetTime.OFFSET_DATE_CENTIBEATS
+ ).get(fmtName);
+ var expect = InternetTime.of(expectDateUtc1, expectBeats, expectCenti, InternetTime.ZONE);
+ assertEquals(expect, InternetTime.parse(input, fmt), "IT.parse(str, fmt)");
+ assertEquals(expect, fmt.parse(input, InternetTime::from), "fmt.parse(str, IT::from)");
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "SHORT, 123, 123, 0",
+ "SHORT, 456, 456, 0",
+ "MEDIUM, @123, 123, 0",
+ "MEDIUM, @456, 456, 0",
+ "LONG, 123, 123, 0",
+ "LONG, 456, 456, 0",
+ "LONG, 123.98, 123, 98",
+ "LONG, 456.12, 456, 12",
+ "FULL, @123.98, 123, 98",
+ "FULL, @456.12, 456, 12",
+ "FULL, @123, 123, 0",
+ "FULL, @456, 456, 0",
+ })
+ void parseBeatFormats(
+ FormatStyle style, String input, int expectBeats, int expectCenti) {
+ var fmt = InternetTime.beatFormatter(style);
+ var expect = InternetTime.of(LocalDate.EPOCH, expectBeats, expectCenti, InternetTime.ZONE);
+ assertEquals(expect, InternetTime.parse(input, fmt), "IT.parse(str, fmt)");
+ assertEquals(expect, fmt.parse(input, InternetTime::from), "fmt.parse(str, IT::from)");
+ }
+
+ @Test
+ void parseStandardFormats() {
+ var str = "2026-01-03T12:34:56.7Z";
+ it = InternetTime.of(LocalDate.of(2026, 01, 03), 565, 93, InternetTime.ZONE);
+ assertEquals(it, InternetTime.parse(str, DateTimeFormatter.ISO_INSTANT), "ISO_INSTANT");
+ assertEquals(it, InternetTime.parse(str, DateTimeFormatter.ISO_DATE_TIME), "ISO_DATE_TIME");
+ assertEquals(
+ it.minus(1, ChronoUnit.HOURS),
+ InternetTime.parse(
+ str.replace("Z", ""), DateTimeFormatter.ISO_LOCAL_DATE_TIME), "ISO_LOCAL_DATE_TIME");
+ assertEquals(
+ it.plus(4167, CENTIBEATS),
+ InternetTime.parse(
+ str.replace("Z", "-01:00"),
+ DateTimeFormatter.ISO_OFFSET_DATE_TIME),
+ "ISO_OFFSET_DATE_TIME");
+
+ str = "12:34:56.7-00:00";
+ it = InternetTime.of(LocalDate.EPOCH, 565, 93, InternetTime.ZONE);
+ assertEquals(it, InternetTime.parse(str, DateTimeFormatter.ISO_OFFSET_TIME), "ISO_OFFSET_TIME");
+ assertEquals(
+ it.minus(1, ChronoUnit.HOURS),
+ InternetTime.parse(str.replace("-00:00", ""), DateTimeFormatter.ISO_TIME), "ISO_TIME");
+
+ assertThrows(
+ DateTimeException.class,
+ () -> InternetTime.parse(
+ "2026-01-03-06:00", DateTimeFormatter.ISO_OFFSET_DATE), "ISO_OFFSET_DATE");
+ assertThrows(
+ DateTimeException.class,
+ () -> InternetTime.parse("2026-01-03", DateTimeFormatter.ISO_DATE), "ISO_DATE");
+ }
}
diff --git a/src/test/java/io/github/wrprice/inettime/PrivateFieldTest.java b/src/test/java/io/github/wrprice/inettime/PrivateFieldTest.java
deleted file mode 100644
index a71ac05..0000000
--- a/src/test/java/io/github/wrprice/inettime/PrivateFieldTest.java
+++ /dev/null
@@ -1,69 +0,0 @@
-/*
- * Copyright 2025 William R. Price - All rights reserved.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package io.github.wrprice.inettime;
-
-import static io.github.wrprice.inettime.InternetTimeUnit.*;
-import static org.junit.jupiter.api.Assertions.*;
-
-import java.time.Instant;
-import java.time.temporal.ValueRange;
-import org.junit.jupiter.api.Test;
-
-/// This test suite mostly exists for the benefit of the code coverage metrics. :-)
-/// @author William R. Price
-public class PrivateFieldTest {
- private static final PrivateField pf = PrivateField.CENTIBEAT_OF_BEAT;
-
- @Test
- void toStringFormat() {
- assertEquals("CentibeatOfBeat", pf.toString());
- }
-
- @Test
- void baseUnit() {
- assertEquals(CENTIBEATS, pf.getBaseUnit());
- }
-
- @Test
- void rangeUnit() {
- assertEquals(BEATS, pf.getRangeUnit());
- }
-
- @Test
- void range() {
- assertEquals(ValueRange.of(0, 99), pf.range());
- }
-
- @Test
- void rangeRefinedBy() {
- assertThrows(NullPointerException.class, () -> pf.rangeRefinedBy(null));
- assertEquals(ValueRange.of(0, 99), pf.rangeRefinedBy(Instant.now()));
- }
-
- @Test
- void isDateTimeBased() {
- assertFalse(pf.isDateBased(), "date");
- assertFalse(pf.isTimeBased(), "time");
- }
-
- @Test
- void unimplemented() {
- assertThrows(
- UnsupportedOperationException.class, () -> pf.getFrom(Instant.now()), "getFrom");
- assertThrows(
- UnsupportedOperationException.class, () -> pf.adjustInto(Instant.now(), 0), "adjustInto");
- }
-}