From 8213309c7a5d5c0dbf71bae0b5957a7ba7fcd947 Mon Sep 17 00:00:00 2001 From: William Price Date: Sat, 3 Jan 2026 21:05:51 -0600 Subject: [PATCH 1/3] Promote CENTIBEATS_OF_BEAT to a public field and other enhancements - Javadoc - Bug fixes - InternetTime::ofInstant made private because unlike other `of` methods, it is not exact. Values can still be derived from Instant using InternetTime::from --- .../github/wrprice/inettime/InternetTime.java | 348 +++++++++++++++--- .../wrprice/inettime/InternetTimeField.java | 95 +++-- .../github/wrprice/inettime/PrivateField.java | 98 ----- .../inettime/InternetTimeFieldTest.java | 113 ++++-- .../wrprice/inettime/InternetTimeTest.java | 44 ++- .../wrprice/inettime/PrivateFieldTest.java | 69 ---- 6 files changed, 475 insertions(+), 292 deletions(-) delete mode 100644 src/main/java/io/github/wrprice/inettime/PrivateField.java delete mode 100644 src/test/java/io/github/wrprice/inettime/PrivateFieldTest.java diff --git a/src/main/java/io/github/wrprice/inettime/InternetTime.java b/src/main/java/io/github/wrprice/inettime/InternetTime.java index 7214038..afb7210 100644 --- a/src/main/java/io/github/wrprice/inettime/InternetTime.java +++ b/src/main/java/io/github/wrprice/inettime/InternetTime.java @@ -17,7 +17,6 @@ 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; @@ -29,13 +28,57 @@ /// 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. +/// +/// ----- /// /// **Implementation notes:** /// This class is immutable and thread-safe. @@ -52,11 +95,9 @@ 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; - @Serial private static final long serialVersionUID = 202512271724L; @@ -81,14 +122,43 @@ 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) { @@ -99,38 +169,104 @@ public static InternetTime from(TemporalAccessor accessor) { 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) { - return new InternetTime(LocalDateTime.ofInstant(requireNonNull(instant, "instant"), ZONE)); + private static DateTimeException cannotDeriveFrom(TemporalAccessor accessor) { + var typeName = accessor.getClass().getName(); + return new DateTimeException("Cannot derive InternetTime from " + typeName + ": " + accessor); } - 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); + /// 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 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 zone) { - return of(LocalDate.of(year, month, dayOfMonth), beat, hundredthsOfBeat, zone); + 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(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 + 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 new InternetTime(date, CENTIBEATS_PER_BEAT * beat + hundredthsOfBeat); + return date; } // @@ -142,6 +278,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 +290,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 +306,64 @@ 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. + /// + /// @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()); + return String.format("%s @%03d", toLocalDate(), getBeat()); } else { - return String.format("%s@%03d.%02d", toLocalDate(), getBeat(), partialBeats); + return String.format("%s @%03d.%02d", toLocalDate(), getBeat(), partialBeats); } } + /// {@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 +376,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 +421,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 +477,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 +522,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 +546,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 +577,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 +599,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 +609,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 +650,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 +698,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) { diff --git a/src/main/java/io/github/wrprice/inettime/InternetTimeField.java b/src/main/java/io/github/wrprice/inettime/InternetTimeField.java index 2759aa6..f97c037 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 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..24c9bb8 100644 --- a/src/test/java/io/github/wrprice/inettime/InternetTimeTest.java +++ b/src/test/java/io/github/wrprice/inettime/InternetTimeTest.java @@ -91,7 +91,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("2025-10-12 @000", it.toString(), "toString"); assertEquals(0, it.getBeat(), "beat"); assertEquals(0, it.getCentibeatOfDay(), "centibeat"); assertEquals(0, it.getCentibeatOfBeat(), "centibeat-of-beat"); @@ -273,10 +273,10 @@ void compareTo() { void toStringFormat() { var date = LocalDate.now(); it = InternetTime.of(date, 234, 56, InternetTime.ZONE); - assertEquals(date + "@234.56", it.toString(), "with fractional beats"); + assertEquals(date + " @234.56", it.toString(), "with fractional beats"); it = it.with(BEAT_OF_DAY, 789); - assertEquals(date + "@789", it.toString(), "exact whole beats"); + assertEquals(date + " @789", it.toString(), "exact whole beats"); } @ParameterizedTest @@ -325,12 +325,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 +348,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 +401,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 +418,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 +545,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 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"); - } -} From 34da9256fdc21c0e835d8a0d9be1c02e2d2b4b55 Mon Sep 17 00:00:00 2001 From: William Price Date: Sat, 3 Jan 2026 21:55:06 -0600 Subject: [PATCH 2/3] Add InternetTime formatting/parsing convenience methods --- .../github/wrprice/inettime/InternetTime.java | 248 ++++++++++++++++-- .../wrprice/inettime/InternetTimeField.java | 2 +- .../wrprice/inettime/InternetTimeTest.java | 153 ++++++++++- 3 files changed, 377 insertions(+), 26 deletions(-) diff --git a/src/main/java/io/github/wrprice/inettime/InternetTime.java b/src/main/java/io/github/wrprice/inettime/InternetTime.java index afb7210..1069e91 100644 --- a/src/main/java/io/github/wrprice/inettime/InternetTime.java +++ b/src/main/java/io/github/wrprice/inettime/InternetTime.java @@ -22,9 +22,13 @@ 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 @@ -78,6 +82,12 @@ /// 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:** @@ -98,6 +108,94 @@ /// Can also be used as a [ZoneId]. public static final ZoneOffset ZONE = ZoneOffset.ofHours(+1); + /// 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; @@ -165,6 +263,8 @@ public static InternetTime from(TemporalAccessor 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)); @@ -271,6 +371,134 @@ private static LocalDate normalizeLocalDate( // + // ------ + + /// 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; + } + 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); + } + + // + // ----------------------- @Override @@ -309,16 +537,12 @@ public int compareTo(InternetTime other) { /// 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} @@ -742,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 f97c037..b378006 100644 --- a/src/main/java/io/github/wrprice/inettime/InternetTimeField.java +++ b/src/main/java/io/github/wrprice/inettime/InternetTimeField.java @@ -245,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/test/java/io/github/wrprice/inettime/InternetTimeTest.java b/src/test/java/io/github/wrprice/inettime/InternetTimeTest.java index 24c9bb8..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 @@ -619,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"); + } } From f2876874c61fdec9369241a8a1b13085d7198237 Mon Sep 17 00:00:00 2001 From: William Price Date: Sat, 3 Jan 2026 22:03:34 -0600 Subject: [PATCH 3/3] Bump library version number to 0.8.64 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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