From 6397add00e6ca488d05bd0a006cb96786593435a Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Wed, 21 Oct 2026 16:14:21 -0600 Subject: [PATCH] Adds decoding and the validity checks Uxid.decode reads an id back to a sealed DecodeResult: a Decoded record with the prefix, size, time form, deterministic flag and time, or an Invalid record whose InvalidReason carries the wire format's refusal name. Malformed input is answered, never thrown, and the refusals are checked in the spec's order: empty, invalid_length, invalid_character, with the length counted in UTF-8 bytes. DecodeOptions takes the delimiter and a java.time.Clock. A compact time takes its high bits from the clock and steps back one 2^40 ms window when it would be more than a day ahead, with no clamp at zero, so a clock in the first window gives a negative time. The tests pin both ends of the corpus clock window from the corpus README's arithmetic. Uxid.isValid takes a delimiter and an optional required prefix and Uxid.isDeterministic takes a delimiter; neither reads a clock. Refs: uxjv-nyt --- CLAUDE.md | 7 +- changelog.d/uxjv-nyt.md | 6 + .../java/systems/riddler/uxid/Base32.java | 18 ++ .../systems/riddler/uxid/DecodeOptions.java | 109 ++++++++++++ .../systems/riddler/uxid/DecodeResult.java | 75 +++++++++ .../java/systems/riddler/uxid/Decoder.java | 134 +++++++++++++++ .../systems/riddler/uxid/InvalidReason.java | 36 ++++ src/main/java/systems/riddler/uxid/Uxid.java | 148 ++++++++++++++++- .../systems/riddler/uxid/ClockWindow.java | 30 ++++ .../systems/riddler/uxid/DecodeClockTest.java | 96 +++++++++++ .../riddler/uxid/DecodeCorpusTest.java | 98 +++++++++++ .../systems/riddler/uxid/UxidDecodeTest.java | 156 ++++++++++++++++++ .../riddler/uxid/UxidValidityTest.java | 68 ++++++++ 13 files changed, 976 insertions(+), 5 deletions(-) create mode 100644 changelog.d/uxjv-nyt.md create mode 100644 src/main/java/systems/riddler/uxid/DecodeOptions.java create mode 100644 src/main/java/systems/riddler/uxid/DecodeResult.java create mode 100644 src/main/java/systems/riddler/uxid/Decoder.java create mode 100644 src/main/java/systems/riddler/uxid/InvalidReason.java create mode 100644 src/test/java/systems/riddler/uxid/ClockWindow.java create mode 100644 src/test/java/systems/riddler/uxid/DecodeClockTest.java create mode 100644 src/test/java/systems/riddler/uxid/DecodeCorpusTest.java create mode 100644 src/test/java/systems/riddler/uxid/UxidDecodeTest.java create mode 100644 src/test/java/systems/riddler/uxid/UxidValidityTest.java diff --git a/CLAUDE.md b/CLAUDE.md index eb7a9b5..ec6231b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -162,9 +162,10 @@ package it exports, `LICENSE`, `CHANGELOG.md` with the fragment rule in `changelog.d/README.md`, and the two workflows: CI (`.github/workflows/ci.yml`) and the release workflow (`.github/workflows/release.yml`). The package holds time-based and -deterministic generation (`Uxid.generate` and `UxidOptions`). Decoding, the -validity checks, the monotonic generator and the vendored corpus arrive -with later changes; until they do, the sentences in this file about the +deterministic generation (`Uxid.generate` and `UxidOptions`), decoding +(`Uxid.decode`, `DecodeOptions` and `DecodeResult`) and the validity checks +(`Uxid.isValid` and `Uxid.isDeterministic`). The monotonic generator and the +vendored corpus arrive with later changes; until they do, the sentences in this file about the 1.0.0 package and its corpus describe the target, not the current tree. - **The package is `systems.riddler:uxid`.** Version 1.0.0 is its first diff --git a/changelog.d/uxjv-nyt.md b/changelog.d/uxjv-nyt.md new file mode 100644 index 0000000..b05ad69 --- /dev/null +++ b/changelog.d/uxjv-nyt.md @@ -0,0 +1,6 @@ +### Added + +- `Uxid.decode(String)` and `Uxid.decode(String, DecodeOptions)` read an id back to a `DecodeResult`: a `DecodeResult.Decoded` with the prefix, the size, whether the time is compact, whether the body is deterministic and the time in milliseconds, or a `DecodeResult.Invalid` whose `InvalidReason` is `empty`, `invalid_length` or `invalid_character`, the first rule the body breaks in that order. Malformed input is answered, never thrown. +- `DecodeOptions` takes the delimiter and a `java.time.Clock` that supplies a compact time's high bits; a compact time more than one day ahead of the clock is read from the 2^40 ms window before, which with a clock in the first window after the Unix epoch is a negative time. +- The body lengths 10 and 18 always decode as standard time, so a compact `xs` or compact `m` id decodes to its size, not compact, with a time that is not the one it was generated at. +- `Uxid.isValid(String)` and `Uxid.isValid(String, String delimiter, String requiredPrefix)` answer whether a string is a well-formed id and, given a prefix, carries exactly that prefix; `Uxid.isDeterministic(String)` and `Uxid.isDeterministic(String, String delimiter)` answer whether it is a well-formed deterministic id. Neither reads a clock. diff --git a/src/main/java/systems/riddler/uxid/Base32.java b/src/main/java/systems/riddler/uxid/Base32.java index 6cee47d..1e0de39 100644 --- a/src/main/java/systems/riddler/uxid/Base32.java +++ b/src/main/java/systems/riddler/uxid/Base32.java @@ -14,6 +14,24 @@ private static String alphabet(LetterCase letterCase) { return letterCase == LetterCase.UPPER ? UPPER : LOWER; } + /** + * The 5-bit value of a symbol in either case, or -1 for any other character: the letters {@code + * I}, {@code L}, {@code O} and {@code U} are not symbols and are not read as {@code 1} or {@code + * 0}. + */ + static int value(char c) { + if (c >= 'A' && c <= 'Z') { + c = (char) (c + ('a' - 'A')); + } + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'a' && c <= 'z') { + return LOWER.indexOf(c); + } + return -1; + } + /** * Appends {@code chars} characters holding {@code value}, whose width is at most {@code chars x * 5} bits; the first group is the short one when the width is not a multiple of 5. diff --git a/src/main/java/systems/riddler/uxid/DecodeOptions.java b/src/main/java/systems/riddler/uxid/DecodeOptions.java new file mode 100644 index 0000000..8271ec2 --- /dev/null +++ b/src/main/java/systems/riddler/uxid/DecodeOptions.java @@ -0,0 +1,109 @@ +package systems.riddler.uxid; + +import java.time.Clock; +import java.util.Objects; + +/** + * The options of one {@link Uxid#decode(String, DecodeOptions) decode} call beside the string, + * built with {@link #builder()}. An instance is immutable and safe to share between threads and + * calls. + * + *

The defaults are what {@link Uxid#decode(String)} uses: the delimiter {@code _} and the system + * clock in UTC, {@link Clock#systemUTC()}. + * + *

{@code
+ * DecodeOptions options =
+ *     DecodeOptions.builder()
+ *         .delimiter("--")
+ *         .clock(Clock.fixed(Instant.ofEpochMilli(1_700_000_000_000L), ZoneOffset.UTC))
+ *         .build();
+ * DecodeResult result = Uxid.decode("usr--hf7yat0004hm5", options);
+ * }
+ */ +public final class DecodeOptions { + /** Every default: the delimiter {@code _} and the system clock in UTC. */ + static final DecodeOptions DEFAULTS = builder().build(); + + private final String delimiter; + private final Clock clock; + + private DecodeOptions(Builder builder) { + this.delimiter = builder.delimiter; + this.clock = builder.clock; + } + + /** + * Returns a builder holding every default. + * + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + String delimiter() { + return delimiter; + } + + Clock clock() { + return clock; + } + + /** Refuses a null or empty delimiter, the one check every delimiter-taking call shares. */ + static String requireDelimiter(String delimiter) { + Objects.requireNonNull(delimiter, "delimiter"); + if (delimiter.isEmpty()) { + throw new IllegalArgumentException("the delimiter must not be empty"); + } + return delimiter; + } + + /** + * Builds a {@link DecodeOptions}. Each setter refuses a value that is wrong on its own. A builder + * is not safe to share between threads. + */ + public static final class Builder { + private String delimiter = "_"; + private Clock clock = Clock.systemUTC(); + + private Builder() {} + + /** + * Sets the delimiter between the prefix and the body; the default is {@code _}. It must be the + * one the id was generated with, since an id does not record it. A delimiter made of Crockford + * Base32 characters makes the split ambiguous and is not supported. + * + * @param delimiter the delimiter, not empty + * @return this builder + * @throws IllegalArgumentException when {@code delimiter} is empty + * @throws NullPointerException when {@code delimiter} is null + */ + public Builder delimiter(String delimiter) { + this.delimiter = requireDelimiter(delimiter); + return this; + } + + /** + * Sets the decoder's clock, which supplies a compact time's high bits; the default is {@link + * Clock#systemUTC()}. Only its {@link Clock#millis() millis} are read, once per call, and only + * for a compact body. A fixed clock makes the decoding of compact ids reproducible. + * + * @param clock the clock + * @return this builder + * @throws NullPointerException when {@code clock} is null + */ + public Builder clock(Clock clock) { + this.clock = Objects.requireNonNull(clock, "clock"); + return this; + } + + /** + * Builds the options. + * + * @return the options + */ + public DecodeOptions build() { + return new DecodeOptions(this); + } + } +} diff --git a/src/main/java/systems/riddler/uxid/DecodeResult.java b/src/main/java/systems/riddler/uxid/DecodeResult.java new file mode 100644 index 0000000..001cc59 --- /dev/null +++ b/src/main/java/systems/riddler/uxid/DecodeResult.java @@ -0,0 +1,75 @@ +package systems.riddler.uxid; + +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalLong; + +/** + * What {@link Uxid#decode(String, DecodeOptions) decode} answers for a string: a {@link Decoded} + * for a well-formed id, or an {@link Invalid} naming the rule the string broke. Decoding never + * throws on malformed input; the caller inspects the result. + * + *
{@code
+ * DecodeResult result = Uxid.decode("cus_01hf7yat0004h1");
+ * if (result instanceof DecodeResult.Decoded decoded) {
+ *   decoded.prefix(); // Optional[cus]
+ *   decoded.size(); // Size.S
+ *   decoded.time(); // OptionalLong[1700000000000]
+ * } else if (result instanceof DecodeResult.Invalid invalid) {
+ *   invalid.reason().wireName(); // such as "invalid_length"
+ * }
+ * }
+ */ +public sealed interface DecodeResult permits DecodeResult.Decoded, DecodeResult.Invalid { + + /** + * What a well-formed id says about itself: its prefix, the size and time form its body length + * gives, whether its body is deterministic, and the time it carries. The random field, and a + * deterministic body's hash, are not read back. + * + * @param prefix everything before the delimiter the body follows, or empty when the id has no + * delimiter. An id that starts with the delimiter, which no producer emits, has the prefix + * {@code ""}, best read as no prefix + * @param size the size the body length gives + * @param compact whether the body length is a compact-time one (13, 21 or 24). The lengths 10 and + * 18 are always read as standard time, so a compact {@code xs} or compact {@code m} id + * decodes with this {@code false} + * @param deterministic whether the body starts with the deterministic marker {@code z} or {@code + * Z} + * @param time the time the body carries, in milliseconds since the Unix epoch, or empty for a + * deterministic body, which carries none. A compact time's high bits come from the decoder's + * clock, so it can be before the epoch + */ + record Decoded( + Optional prefix, Size size, boolean compact, boolean deterministic, OptionalLong time) + implements DecodeResult { + + /** + * Creates a decoded value. + * + * @throws NullPointerException when {@code prefix}, {@code size} or {@code time} is null + */ + public Decoded { + Objects.requireNonNull(prefix, "prefix"); + Objects.requireNonNull(size, "size"); + Objects.requireNonNull(time, "time"); + } + } + + /** + * A refused string, with the first rule it broke. + * + * @param reason the rule the body broke + */ + record Invalid(InvalidReason reason) implements DecodeResult { + + /** + * Creates a refusal. + * + * @throws NullPointerException when {@code reason} is null + */ + public Invalid { + Objects.requireNonNull(reason, "reason"); + } + } +} diff --git a/src/main/java/systems/riddler/uxid/Decoder.java b/src/main/java/systems/riddler/uxid/Decoder.java new file mode 100644 index 0000000..b76a7c4 --- /dev/null +++ b/src/main/java/systems/riddler/uxid/Decoder.java @@ -0,0 +1,134 @@ +package systems.riddler.uxid; + +import java.util.Optional; +import java.util.OptionalLong; +import java.util.function.LongSupplier; + +/** Reads a string to a {@link DecodeResult} under the wire format's decoding rules. */ +final class Decoder { + /** The span of a compact time field, 2^40 milliseconds. */ + static final long COMPACT_WINDOW = 1L << 40; + + /** + * How far ahead of the decoder's clock a compact time may be before it is read as one from the + * window before: one day in milliseconds. + */ + static final long ONE_DAY = 86_400_000L; + + private Decoder() {} + + /** + * Decodes {@code id} under {@code delimiter}, a non-empty string, calling {@code clock} only for + * a compact body. + */ + static DecodeResult decode(String id, String delimiter, LongSupplier clock) { + int at = lastDelimiter(id, delimiter); + Optional prefix = at < 0 ? Optional.empty() : Optional.of(id.substring(0, at)); + String body = at < 0 ? id : id.substring(at + delimiter.length()); + + if (body.isEmpty()) { + return new DecodeResult.Invalid(InvalidReason.EMPTY); + } + int length = utf8Length(body); + Size size = sizeOf(length); + if (size == null) { + return new DecodeResult.Invalid(InvalidReason.INVALID_LENGTH); + } + for (int i = 0; i < body.length(); i++) { + if (Base32.value(body.charAt(i)) < 0) { + return new DecodeResult.Invalid(InvalidReason.INVALID_CHARACTER); + } + } + + boolean compact = length == 13 || length == 21 || length == 24; + char first = body.charAt(0); + boolean deterministic = first == 'z' || first == 'Z'; + OptionalLong time; + if (deterministic) { + time = OptionalLong.empty(); + } else if (compact) { + time = OptionalLong.of(compactTime(read(body, 0, 8), clock.getAsLong())); + } else { + time = OptionalLong.of((read(body, 0, 1) & 7) << 45 | read(body, 1, 10)); + } + return new DecodeResult.Decoded(prefix, size, compact, deterministic, time); + } + + /** + * The index of the last delimiter a left-to-right scan finds, its matches not overlapping, or -1 + * when there is none. Under {@code --}, {@code a---b} splits after {@code a}, as the reference's + * split does, not at the last {@code --}. + */ + private static int lastDelimiter(String id, String delimiter) { + int at = -1; + int from = 0; + while (true) { + int found = id.indexOf(delimiter, from); + if (found < 0) { + return at; + } + at = found; + from = found + delimiter.length(); + } + } + + /** + * The length of {@code text} in UTF-8 bytes. An unpaired surrogate counts as three bytes, the + * width of the replacement character U+FFFD. + */ + private static int utf8Length(String text) { + int bytes = 0; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c < 0x80) { + bytes += 1; + } else if (c < 0x800) { + bytes += 2; + } else if (Character.isHighSurrogate(c) + && i + 1 < text.length() + && Character.isLowSurrogate(text.charAt(i + 1))) { + bytes += 4; + i++; + } else { + bytes += 3; + } + } + return bytes; + } + + /** The size a body length gives, or null for a length a producer never emits. */ + private static Size sizeOf(int length) { + return switch (length) { + case 10 -> Size.XS; + case 13, 14 -> Size.S; + case 18 -> Size.M; + case 21, 22 -> Size.L; + case 24, 26 -> Size.XL; + default -> null; + }; + } + + /** Reads the symbols from {@code start} to {@code end} as one integer, 5 bits each. */ + private static long read(String body, int start, int end) { + long value = 0; + for (int i = start; i < end; i++) { + value = value << 5 | Base32.value(body.charAt(i)); + } + return value; + } + + /** + * The time of the compact field {@code low} under the clock {@code now}: the clock's bits above + * bit 40 with {@code low}, less one window when that is more than one day ahead of the clock. The + * window is the clock's rounded down, so with the clock in the first window after the epoch (or + * before the epoch) the window before ends at or before the epoch and the time can be negative; + * nothing clamps it at zero. + */ + static long compactTime(long low, long now) { + long time = Math.floorDiv(now, COMPACT_WINDOW) * COMPACT_WINDOW + low; + if (time - now > ONE_DAY) { + time -= COMPACT_WINDOW; + } + return time; + } +} diff --git a/src/main/java/systems/riddler/uxid/InvalidReason.java b/src/main/java/systems/riddler/uxid/InvalidReason.java new file mode 100644 index 0000000..30fcbe3 --- /dev/null +++ b/src/main/java/systems/riddler/uxid/InvalidReason.java @@ -0,0 +1,36 @@ +package systems.riddler.uxid; + +/** + * Why {@link Uxid#decode(String, DecodeOptions) decode} refused a string: the decode refusals of + * the wire format, a closed set. Each constant's {@link #wireName()} is the name the wire format + * and the conformance corpus give the refusal. + * + *

The constants are declared in the order decode checks them: a body that breaks more than one + * rule is refused for the first it breaks. + */ +public enum InvalidReason { + /** The body is empty: an empty string, or one that ends in the delimiter. */ + EMPTY("empty"), + /** + * The body's length in UTF-8 bytes is not one of the body lengths a producer emits: 10, 13, 14, + * 18, 21, 22, 24 or 26. + */ + INVALID_LENGTH("invalid_length"), + /** A body character is not a Crockford Base32 symbol in either case. */ + INVALID_CHARACTER("invalid_character"); + + private final String wireName; + + InvalidReason(String wireName) { + this.wireName = wireName; + } + + /** + * Returns the refusal's name in the wire format's closed set, such as {@code invalid_length}. + * + * @return the wire name + */ + public String wireName() { + return wireName; + } +} diff --git a/src/main/java/systems/riddler/uxid/Uxid.java b/src/main/java/systems/riddler/uxid/Uxid.java index 63578b2..424b8d2 100644 --- a/src/main/java/systems/riddler/uxid/Uxid.java +++ b/src/main/java/systems/riddler/uxid/Uxid.java @@ -2,10 +2,13 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.util.Objects; +import java.util.Optional; /** - * Generates UXIDs: prefixed, K-sortable identifiers a person can read, in the wire format of the - * Elixir {@code uxid} package. + * Generates and decodes UXIDs: prefixed, K-sortable identifiers a person can read, in the wire + * format of the Elixir {@code uxid} package. * *

A generated id is an optional prefix and the delimiter, then a body: a time field (a standard * 48-bit time in 10 characters, or a compact 40-bit time in 8) followed by a random field, in @@ -24,6 +27,11 @@ * Uxid.generate(UxidOptions.builder().prefix("usr").from("alice@example.com").build()); * } * + *

{@link #decode(String, DecodeOptions) Decoding} reads an id back to what it says about itself + * and answers a {@link DecodeResult} rather than throwing on malformed input; {@link + * #isValid(String, String, String)} and {@link #isDeterministic(String, String)} answer the two + * questions most callers ask of a string. + * *

Every method is static and safe to call from several threads at once; the class holds no * configuration. */ @@ -129,6 +137,142 @@ public static String generate(UxidOptions options) { return id.toString(); } + /** + * Decodes {@code id} under the delimiter {@code _}, reading a compact time against the system + * clock in UTC: {@link #decode(String, DecodeOptions)} with the default {@link DecodeOptions}. + * + * @param id the string to decode + * @return a {@link DecodeResult.Decoded} for a well-formed id, or a {@link DecodeResult.Invalid} + * naming the first rule the string breaks + * @throws NullPointerException when {@code id} is null + */ + public static DecodeResult decode(String id) { + return decode(id, DecodeOptions.DEFAULTS); + } + + /** + * Decodes {@code id} under the options' delimiter, reading a compact time against the options' + * clock. + * + *

The body is everything after the last delimiter a left-to-right scan finds, its matches not + * overlapping, and the prefix everything before it; a string with no delimiter is a body with no + * prefix. Under {@code --}, {@code usr---01hf7yat00} splits after {@code usr}, not at the last + * {@code --}, and its body starts with {@code -}. The body length in UTF-8 bytes gives the size + * and the time form: + * + * + * + * + * + * + * + * + * + * + * + * + *
Body length to size and time form
Body lengthSizeTime
10{@link Size#XS}standard
13{@link Size#S}compact
14{@link Size#S}standard
18{@link Size#M}standard
21{@link Size#L}compact
22{@link Size#L}standard
24{@link Size#XL}compact
26{@link Size#XL}standard
+ * + *

The lengths 10 and 18 are also those of a compact {@code xs} and a compact {@code m} body, + * and they are always read as standard time: such an id decodes to its size, not compact, with a + * time read from its first 10 characters, which is not the time it was generated at. + * + *

A body that starts with {@code z} or {@code Z} is deterministic and carries no time. A + * standard time is the first 10 characters, the first one's low 3 bits then nine groups of 5. A + * compact body carries only the low 40 bits of its time in its first 8 characters; the high bits + * are the clock's, less one 2^40 ms window when the result would be more than one day ahead of + * the clock. So the time comes back exact while the id and the clock fall in the same 2^40 ms + * window (about 34.8 years) and the id is no more than one day ahead of the clock. With the clock + * in the first window after the Unix epoch, the window before ends at the epoch, so a time read + * from it is negative: nothing clamps it at zero. The clock is read only for a compact body. + * + *

A string that is not a well-formed id is refused with the first rule its body breaks, + * checked in this order: {@link InvalidReason#EMPTY} for an empty body, {@link + * InvalidReason#INVALID_LENGTH} for a body whose length in UTF-8 bytes is not in the table above + * (an unpaired surrogate counts as three bytes), and {@link InvalidReason#INVALID_CHARACTER} for + * a body character that is not a Crockford Base32 symbol in either case. Any string gets an + * answer: malformed input never throws. + * + * @param id the string to decode + * @param options the delimiter and the clock + * @return a {@link DecodeResult.Decoded} for a well-formed id, or a {@link DecodeResult.Invalid} + * naming the first rule the string breaks + * @throws NullPointerException when {@code id} or {@code options} is null + */ + public static DecodeResult decode(String id, DecodeOptions options) { + Objects.requireNonNull(id, "id"); + Clock clock = options.clock(); + return Decoder.decode(id, options.delimiter(), clock::millis); + } + + /** + * Whether {@code id} is a well-formed id under the delimiter {@code _}: {@link #isValid(String, + * String, String)} with no required prefix. + * + * @param id the string to check + * @return whether {@link #decode(String) decode} answers a {@link DecodeResult.Decoded} for it + * @throws NullPointerException when {@code id} is null + */ + public static boolean isValid(String id) { + return isValid(id, "_", null); + } + + /** + * Whether {@code id} is a well-formed id under {@code delimiter} and, when {@code requiredPrefix} + * is neither null nor empty, carries exactly that prefix: whether {@link #decode(String, + * DecodeOptions) decode} answers a {@link DecodeResult.Decoded} for it whose prefix is the one + * required. A null or empty required prefix accepts any prefix or none; an id with no delimiter + * has no prefix, so it fails any other. Validity does not depend on the clock, so this reads + * none. + * + * @param id the string to check + * @param delimiter the delimiter the id was generated with, not empty + * @param requiredPrefix the prefix the id must carry, or null or {@code ""} for any + * @return whether the id is well formed and carries the required prefix + * @throws IllegalArgumentException when {@code delimiter} is empty + * @throws NullPointerException when {@code id} or {@code delimiter} is null + */ + public static boolean isValid(String id, String delimiter, String requiredPrefix) { + Objects.requireNonNull(id, "id"); + DecodeResult result = Decoder.decode(id, DecodeOptions.requireDelimiter(delimiter), () -> 0L); + if (!(result instanceof DecodeResult.Decoded decoded)) { + return false; + } + return requiredPrefix == null + || requiredPrefix.isEmpty() + || decoded.prefix().equals(Optional.of(requiredPrefix)); + } + + /** + * Whether {@code id} is a well-formed deterministic id under the delimiter {@code _}: {@link + * #isDeterministic(String, String)} with {@code _}. + * + * @param id the string to check + * @return whether the id is well formed and its body starts with the marker + * @throws NullPointerException when {@code id} is null + */ + public static boolean isDeterministic(String id) { + return isDeterministic(id, "_"); + } + + /** + * Whether {@code id} is a well-formed deterministic id under {@code delimiter}: one {@link + * #decode(String, DecodeOptions) decode} answers a {@link DecodeResult.Decoded} for whose body + * starts with the marker {@code z} or {@code Z}. A string that is not well formed is not one, + * whatever its first body character. It reads no clock. + * + * @param id the string to check + * @param delimiter the delimiter the id was generated with, not empty + * @return whether the id is well formed and its body starts with the marker + * @throws IllegalArgumentException when {@code delimiter} is empty + * @throws NullPointerException when {@code id} or {@code delimiter} is null + */ + public static boolean isDeterministic(String id, String delimiter) { + Objects.requireNonNull(id, "id"); + DecodeResult result = Decoder.decode(id, DecodeOptions.requireDelimiter(delimiter), () -> 0L); + return result instanceof DecodeResult.Decoded decoded && decoded.deterministic(); + } + private static String deterministic(UxidOptions options, byte[] from) { int count = options.size().hashChars(); MessageDigest sha256 = sha256(); diff --git a/src/test/java/systems/riddler/uxid/ClockWindow.java b/src/test/java/systems/riddler/uxid/ClockWindow.java new file mode 100644 index 0000000..e75ceff --- /dev/null +++ b/src/test/java/systems/riddler/uxid/ClockWindow.java @@ -0,0 +1,30 @@ +package systems.riddler.uxid; + +/** + * The decoder clocks the compact decode cases of the conformance corpus hold for, computed with the + * arithmetic of the corpus README ("Compact times and the decoder's clock") at uxid-ex v2.10.0. + */ +final class ClockWindow { + /** One day in milliseconds, how far ahead of the clock a compact time may be. */ + static final long ONE_DAY = 86_400_000L; + + /** The time the corpus's compact cases carry, except the guard case. */ + static final long CASE_TIME = 1_700_000_000_000L; + + /** The largest compact time field the guard allows, the guard case's low 40 bits. */ + static final long GUARD_LAST_VALID = 1_065_151_889_407L; + + /** + * The first clock that recovers every case, inclusive: a clock no more than one day behind the + * cases at {@link #CASE_TIME}. + */ + static final long START = CASE_TIME - ONE_DAY; + + /** + * The first clock past the window, exclusive: from here 2^40 + {@link #GUARD_LAST_VALID} is no + * longer more than one day ahead of the clock, so the guard case is not stepped back. + */ + static final long END = (1L << 40) + GUARD_LAST_VALID - ONE_DAY; + + private ClockWindow() {} +} diff --git a/src/test/java/systems/riddler/uxid/DecodeClockTest.java b/src/test/java/systems/riddler/uxid/DecodeClockTest.java new file mode 100644 index 0000000..7347e12 --- /dev/null +++ b/src/test/java/systems/riddler/uxid/DecodeClockTest.java @@ -0,0 +1,96 @@ +package systems.riddler.uxid; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.OptionalLong; +import org.junit.jupiter.api.Test; + +class DecodeClockTest { + private static final String COMPACT_S = "usr_hf7yat0004hm5"; + private static final String GUARD = "usr_yzzzzzzz04hm5"; + private static final long WINDOW = 1L << 40; + + @Test // (sabotage: END minus a day dropped -> the END assertion failed) + void theWindowEndsAreTheOnesTheCorpusReadmeStates() { + assertEquals(1_699_913_600_000L, ClockWindow.START); + assertEquals(2_164_577_117_183L, ClockWindow.END); + } + + @Test // (sabotage: > ONE_DAY made >= -> the START assertion failed) + void theWindowStartIsInclusiveAndTheMillisecondBeforeItMissesTheCase() { + assertEquals(OptionalLong.of(ClockWindow.CASE_TIME), timeAt(COMPACT_S, ClockWindow.START)); + assertEquals( + OptionalLong.of(ClockWindow.CASE_TIME - WINDOW), timeAt(COMPACT_S, ClockWindow.START - 1)); + } + + @Test // (sabotage: > ONE_DAY made >= -> the END - 1 assertion failed) + void theWindowEndIsExclusiveAndItsLastMillisecondRecoversTheGuardCase() { + assertEquals(OptionalLong.of(ClockWindow.GUARD_LAST_VALID), timeAt(GUARD, ClockWindow.END - 1)); + assertEquals( + OptionalLong.of(WINDOW + ClockWindow.GUARD_LAST_VALID), timeAt(GUARD, ClockWindow.END)); + } + + @Test // (sabotage: time clamped at 0 -> the negative assertion failed) + void aClockInTheFirstWindowStepsBackToANegativeTimeWithNoClamp() { + long low = ClockWindow.CASE_TIME - WINDOW; + assertEquals(OptionalLong.of(low - WINDOW), timeAt(COMPACT_S, 0)); + assertEquals(OptionalLong.of(-499_023_255_552L), timeAt(COMPACT_S, 0)); + assertEquals(OptionalLong.of(low), timeAt(COMPACT_S, low)); + } + + @Test // (sabotage: floorDiv made / -> the -WINDOW / 2 assertion failed) + void aClockBeforeTheEpochReadsFromTheWindowEndingAtTheEpoch() { + long low = ClockWindow.CASE_TIME - WINDOW; + assertEquals(OptionalLong.of(low - WINDOW), timeAt(COMPACT_S, -1)); + assertEquals(OptionalLong.of(low - 2 * WINDOW), timeAt(COMPACT_S, -WINDOW / 2)); + } + + @Test // (sabotage: clock read up front -> the refusing clock failed it) + void theClockIsReadOnlyForACompactBody() { + DecodeOptions refusing = DecodeOptions.builder().clock(new RefusingClock()).build(); + assertEquals( + OptionalLong.of(ClockWindow.CASE_TIME), decoded("usr_01hf7yat0004h1", refusing).time()); + assertEquals(OptionalLong.empty(), decoded("cus_zt9vnyfd85bff0", refusing).time()); + assertInstanceOf(DecodeResult.Invalid.class, Uxid.decode("usr_hf7yat0004hm", refusing)); + } + + private static OptionalLong timeAt(String id, long now) { + DecodeOptions options = + DecodeOptions.builder() + .clock(Clock.fixed(Instant.ofEpochMilli(now), ZoneOffset.UTC)) + .build(); + return decoded(id, options).time(); + } + + private static DecodeResult.Decoded decoded(String id, DecodeOptions options) { + return assertInstanceOf(DecodeResult.Decoded.class, Uxid.decode(id, options), id); + } + + /** A clock that fails the test if it is ever read. */ + private static final class RefusingClock extends Clock { + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + throw new AssertionError("the clock was read"); + } + + @Override + public long millis() { + throw new AssertionError("the clock was read"); + } + } +} diff --git a/src/test/java/systems/riddler/uxid/DecodeCorpusTest.java b/src/test/java/systems/riddler/uxid/DecodeCorpusTest.java new file mode 100644 index 0000000..4659499 --- /dev/null +++ b/src/test/java/systems/riddler/uxid/DecodeCorpusTest.java @@ -0,0 +1,98 @@ +package systems.riddler.uxid; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; +import java.util.OptionalLong; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +/** + * Cases copied from the conformance corpus at uxid-ex v2.10.0 ({@code conformance/decode.json} and + * the decode refusals of {@code conformance/invalid.json}), each named by its case id. The full + * corpus runner replaces this hand-picked subset. + */ +class DecodeCorpusTest { + + @ParameterizedTest(name = "{0}") // (sabotage: time read from char 2 -> cases failed) + @CsvSource( + nullValues = "null", + value = { + "decode/xl-standard, usr_01hf7yat0004hmasw9nf6yy093, usr, xl, false, false, 1700000000000", + "decode/xl-compact, usr_hf7yat0004hmasw9nf6yy093, usr, xl, true, false, 1700000000000", + "decode/l-standard, usr_01hf7yat0004hmasw9nf61, usr, l, false, false, 1700000000000", + "decode/l-compact, usr_hf7yat0004hmasw9nf6yf, usr, l, true, false, 1700000000000", + "decode/m-standard, usr_01hf7yat0004hmasw9, usr, m, false, false, 1700000000000", + "decode/m-compact-reads-as-standard, usr_hf7yat0004hmasw9n3, usr, m, false, false," + + " 51950139736068", + "decode/s-standard, usr_01hf7yat0004h1, usr, s, false, false, 1700000000000", + "decode/s-compact, usr_hf7yat0004hm5, usr, s, true, false, 1700000000000", + "decode/xs-standard, usr_01hf7yat00, usr, xs, false, false, 1700000000000", + "decode/xs-compact-reads-as-standard, usr_hf7yat0001, usr, xs, false, false," + + " 51950139736065", + "decode/xl-standard-upper, usr_01HF7YAT0004HMASW9NF6YY093, usr, xl, false, false," + + " 1700000000000", + "decode/xl-standard-no-prefix, 01hf7yat0004hmasw9nf6yy093, null, xl, false, false," + + " 1700000000000", + "decode/s-standard-prefix-with-delimiter, a_b_01hf7yat000001, a_b, s, false, false," + + " 1700000000000", + "decode/s-compact-guard-last-valid, usr_yzzzzzzz04hm5, usr, s, true, false, 1065151889407", + "decode/deterministic-usr-xl, usr_zcvt7epac0t1ebcsjfyf7cwz25, usr, xl, false, true, null", + "decode/deterministic-cus-s, cus_zt9vnyfd85bff0, cus, s, false, true, null", + "decode/deterministic-no-prefix-m, zqz4cwzgyw8ks97869, null, m, false, true, null", + "decode/deterministic-upper-marker, usr_ZCVT7EPAC0T1EBCSJFYF7CWZ25, usr, xl, false, true," + + " null", + }) + void aDecodeCaseReadsTheCorpusAnswerAcrossTheClockWindow( + String id, + String input, + String prefix, + String size, + boolean compact, + boolean deterministic, + Long time) { + DecodeResult.Decoded expected = + new DecodeResult.Decoded( + Optional.ofNullable(prefix), + Size.parse(size), + compact, + deterministic, + time == null ? OptionalLong.empty() : OptionalLong.of(time)); + for (long now : new long[] {ClockWindow.START, ClockWindow.END - 1}) { + DecodeOptions options = + DecodeOptions.builder() + .clock(Clock.fixed(Instant.ofEpochMilli(now), ZoneOffset.UTC)) + .build(); + assertEquals(expected, Uxid.decode(input, options), id + " at " + now); + } + } + + @ParameterizedTest(name = "{0}") // (sabotage: 2-byte char as 1 byte -> non-ascii cases failed) + @CsvSource({ + "invalid/decode-empty-string, '', empty", + "invalid/decode-ends-in-delimiter, usr_, empty", + "invalid/decode-prefix-with-delimiter-ends-in-delimiter, a_b_, empty", + "invalid/decode-delimiter-only, _, empty", + "invalid/decode-length-11, usr_01hf7yat000, invalid_length", + "invalid/decode-length-25, usr_01hf7yat00000g40r40m30e20, invalid_length", + "invalid/decode-length-27, usr_01hf7yat00000g40r40m30e2090, invalid_length", + "invalid/decode-no-delimiter-short, usr, invalid_length", + "invalid/decode-character-in-time, usr_01hu7yat00000g40r40m30e209, invalid_character", + "invalid/decode-character-in-random, usr_01hf7yat00000g4lr40m30e209, invalid_character", + "invalid/decode-upper-o-in-random, usr_01hf7yat00000g40r40mO0e209, invalid_character", + "invalid/decode-hyphen-in-body, usr_01hf7yat00-00g40r40m30e209, invalid_character", + "invalid/decode-character-in-deterministic, usr_zcvt7epac0t1ebcsjfyf7cwzi5, invalid_character", + "invalid/decode-non-ascii-table-length, 01hf7yat00000g40r40m30e2é, invalid_character", + "invalid/decode-non-ascii-off-table-length, 01hf7yat00000g40r40m30e20é, invalid_length", + "invalid/decode-length-and-character, usr_01hf7yat00u, invalid_length", + }) + void aDecodeRefusalCaseIsRefusedWithTheCorpusReason(String id, String input, String reason) { + DecodeResult.Invalid invalid = + assertInstanceOf(DecodeResult.Invalid.class, Uxid.decode(input), id); + assertEquals(reason, invalid.reason().wireName(), id); + } +} diff --git a/src/test/java/systems/riddler/uxid/UxidDecodeTest.java b/src/test/java/systems/riddler/uxid/UxidDecodeTest.java new file mode 100644 index 0000000..7782a56 --- /dev/null +++ b/src/test/java/systems/riddler/uxid/UxidDecodeTest.java @@ -0,0 +1,156 @@ +package systems.riddler.uxid; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import java.util.OptionalLong; +import org.junit.jupiter.api.Test; + +class UxidDecodeTest { + private static final long TIME = 1_700_000_000_000L; + private static final Clock AT_TIME = Clock.fixed(Instant.ofEpochMilli(TIME), ZoneOffset.UTC); + + @Test // (sabotage: wrong wire name on INVALID_CHARACTER -> it failed) + void theReasonsAreTheWireNamesInRefusalOrder() { + assertEquals( + List.of(InvalidReason.EMPTY, InvalidReason.INVALID_LENGTH, InvalidReason.INVALID_CHARACTER), + List.of(InvalidReason.values())); + assertEquals("empty", InvalidReason.EMPTY.wireName()); + assertEquals("invalid_length", InvalidReason.INVALID_LENGTH.wireName()); + assertEquals("invalid_character", InvalidReason.INVALID_CHARACTER.wireName()); + } + + @Test // (sabotage: surrogate bound check removed -> assertDoesNotThrow failed) + void malformedInputIsAnsweredNeverThrown() { + List inputs = + List.of( + "\ud800", + "usr_\udc00", + "usr_01hf7yat0\ud83d\ude00", + "usr_01hf7yat00\ud83d", + "__________", + "usr_" + "0".repeat(10_000), + "usr_01hf7yat0\u0000", + "usr_\u00e9\u00e9\u00e9\u00e9\u00e9"); + for (String input : inputs) { + DecodeResult result = assertDoesNotThrow(() -> Uxid.decode(input), input); + assertInstanceOf(DecodeResult.Invalid.class, result, input); + } + } + + @Test // (sabotage: 2-byte char counted as 1 -> the first assertion failed) + void theBodyLengthIsCountedInUtf8Bytes() { + assertEquals(reason(InvalidReason.INVALID_CHARACTER), Uxid.decode("01hf7yat\u00e9")); + assertEquals(reason(InvalidReason.INVALID_CHARACTER), Uxid.decode("01hf7ya\u20ac")); + assertEquals(reason(InvalidReason.INVALID_CHARACTER), Uxid.decode("01hf7y\ud83d\ude00")); + assertEquals(reason(InvalidReason.INVALID_CHARACTER), Uxid.decode("01hf7ya\ud800")); + } + + @Test // (sabotage: length 21 not compact -> the l compact assertion failed) + void aGeneratedIdDecodesToItsSizeAndTimeForm() { + for (Size size : Size.values()) { + for (boolean compact : new boolean[] {false, true}) { + String id = + Uxid.generate( + UxidOptions.builder() + .prefix("cus") + .size(size) + .compactTime(compact) + .time(TIME) + .build()); + DecodeResult.Decoded decoded = decoded(id); + boolean ambiguous = compact && (size == Size.XS || size == Size.M); + assertEquals(size, decoded.size(), id); + assertEquals(compact && !ambiguous, decoded.compact(), id); + assertEquals(Optional.of("cus"), decoded.prefix(), id); + assertFalse(decoded.deterministic(), id); + if (!ambiguous) { + assertEquals(OptionalLong.of(TIME), decoded.time(), id); + } + } + } + } + + @Test // (sabotage: length 18 read compact -> the compact assertion failed) + void bodyLengthsTenAndEighteenDecodeToTheStandardReading() { + for (Size size : List.of(Size.XS, Size.M)) { + String id = + Uxid.generate(UxidOptions.builder().size(size).compactTime(true).time(TIME).build()); + DecodeResult.Decoded decoded = decoded(id); + assertEquals(size, decoded.size(), id); + assertFalse(decoded.compact(), id); + long standard = (Base32.value(id.charAt(0)) & 7L) << 45; + for (int i = 1; i < 10; i++) { + standard |= (long) Base32.value(id.charAt(i)) << (5 * (9 - i)); + } + assertEquals(OptionalLong.of(standard), decoded.time(), id); + } + } + + @Test // (sabotage: upper-case folding removed -> the equality failed) + void eitherCaseDecodesToTheSameValue() { + assertEquals(decoded("usr_hf7yat0004hm5"), decoded("usr_HF7YAT0004HM5")); + assertEquals(decoded("usr_01hf7yat0004h1"), decoded("usr_01Hf7YaT0004h1")); + } + + @Test // (sabotage: scan resumed at found + 1 -> the --- assertion failed) + void theDelimiterIsTheOptionsOneSplitWithoutOverlap() { + DecodeOptions dashes = DecodeOptions.builder().delimiter("--").clock(AT_TIME).build(); + DecodeResult.Decoded decoded = decoded("usr--01hf7yat00", dashes); + assertEquals(Optional.of("usr"), decoded.prefix()); + assertEquals(Optional.of("a--b"), decoded("a--b--01hf7yat00", dashes).prefix()); + assertEquals(reason(InvalidReason.INVALID_LENGTH), Uxid.decode("usr---01hf7yat00", dashes)); + assertEquals(reason(InvalidReason.INVALID_LENGTH), Uxid.decode("usr--01hf7yat00")); + } + + @Test // (sabotage: at <= 0 read as no prefix -> the first assertion failed) + void anIdThatStartsWithTheDelimiterHasTheEmptyPrefix() { + assertEquals(Optional.of(""), decoded("_01hf7yat00").prefix()); + assertEquals(Optional.empty(), decoded("01hf7yat00").prefix()); + } + + @Test // (sabotage: clock null check removed -> assertThrows failed) + void theOptionsRefuseAnEmptyOrNullDelimiterAndANullClock() { + assertThrows(IllegalArgumentException.class, () -> DecodeOptions.builder().delimiter("")); + assertThrows(NullPointerException.class, () -> DecodeOptions.builder().delimiter(null)); + assertThrows(NullPointerException.class, () -> DecodeOptions.builder().clock(null)); + assertThrows(NullPointerException.class, () -> Uxid.decode(null)); + assertThrows(NullPointerException.class, () -> Uxid.decode("01hf7yat00", null)); + } + + @Test // (sabotage: size null check removed -> assertThrows failed) + void theResultRecordsRefuseNullComponents() { + assertThrows(NullPointerException.class, () -> new DecodeResult.Invalid(null)); + assertThrows( + NullPointerException.class, + () -> new DecodeResult.Decoded(null, Size.S, false, false, OptionalLong.empty())); + assertThrows( + NullPointerException.class, + () -> new DecodeResult.Decoded(Optional.empty(), null, false, false, OptionalLong.empty())); + assertThrows( + NullPointerException.class, + () -> new DecodeResult.Decoded(Optional.empty(), Size.S, false, false, null)); + assertTrue(new DecodeResult.Invalid(InvalidReason.EMPTY).equals(reason(InvalidReason.EMPTY))); + } + + private static DecodeResult reason(InvalidReason reason) { + return new DecodeResult.Invalid(reason); + } + + private static DecodeResult.Decoded decoded(String id) { + return decoded(id, DecodeOptions.builder().clock(AT_TIME).build()); + } + + private static DecodeResult.Decoded decoded(String id, DecodeOptions options) { + return assertInstanceOf(DecodeResult.Decoded.class, Uxid.decode(id, options), id); + } +} diff --git a/src/test/java/systems/riddler/uxid/UxidValidityTest.java b/src/test/java/systems/riddler/uxid/UxidValidityTest.java new file mode 100644 index 0000000..ba3fa16 --- /dev/null +++ b/src/test/java/systems/riddler/uxid/UxidValidityTest.java @@ -0,0 +1,68 @@ +package systems.riddler.uxid; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class UxidValidityTest { + + @Test // (sabotage: an Invalid answered true -> a malformed assertion failed) + void aWellFormedIdIsValidAndAMalformedOneIsNot() { + assertTrue(Uxid.isValid("cus_01hf7yat0004h1")); + assertTrue(Uxid.isValid("cus_hf7yat0004hm5")); + assertTrue(Uxid.isValid("usr_zcvt7epac0t1ebcsjfyf7cwz25")); + assertFalse(Uxid.isValid("cus_01hf7yat0004h12")); + assertFalse(Uxid.isValid("cus_01hu7yat0004h1")); + assertFalse(Uxid.isValid("cus_")); + assertFalse(Uxid.isValid("")); + } + + @Test // (sabotage: any present prefix accepted -> the usr assertion failed) + void aRequiredPrefixMustBeTheIdsPrefix() { + assertTrue(Uxid.isValid("cus_01hf7yat0004h1", "_", "cus")); + assertFalse(Uxid.isValid("cus_01hf7yat0004h1", "_", "usr")); + assertFalse(Uxid.isValid("01hf7yat0004h1", "_", "cus")); + assertFalse(Uxid.isValid("a_cus_01hf7yat0004h1", "_", "cus")); + assertTrue(Uxid.isValid("a_cus_01hf7yat0004h1", "_", "a_cus")); + } + + @Test // (sabotage: isEmpty check removed -> the empty prefix assertion failed) + void aNullOrEmptyRequiredPrefixAcceptsAnyPrefixOrNone() { + assertTrue(Uxid.isValid("cus_01hf7yat0004h1", "_", null)); + assertTrue(Uxid.isValid("cus_01hf7yat0004h1", "_", "")); + assertTrue(Uxid.isValid("01hf7yat0004h1", "_", "")); + assertFalse(Uxid.isValid("cus_01hf7yat0004h12", "_", null)); + } + + @Test // (sabotage: delimiter replaced by _ -> the -- assertion failed) + void validityReadsTheDelimiterGiven() { + assertTrue(Uxid.isValid("cus--01hf7yat0004h1", "--", "cus")); + assertFalse(Uxid.isValid("cus--01hf7yat0004h1", "_", null)); + assertThrows( + IllegalArgumentException.class, () -> Uxid.isValid("cus_01hf7yat0004h1", "", null)); + assertThrows(NullPointerException.class, () -> Uxid.isValid(null)); + } + + @Test // (sabotage: !compact() for deterministic() -> a false assertion failed) + void aDeterministicIdIsAWellFormedOneStartingWithTheMarker() { + assertTrue(Uxid.isDeterministic("usr_zcvt7epac0t1ebcsjfyf7cwz25")); + assertTrue(Uxid.isDeterministic("usr_ZCVT7EPAC0T1EBCSJFYF7CWZ25")); + assertTrue(Uxid.isDeterministic("zqz4cwzgyw8ks97869")); + assertFalse(Uxid.isDeterministic("usr_01hf7yat0004h1")); + assertFalse(Uxid.isDeterministic("usr_zcvt7epac")); + assertFalse(Uxid.isDeterministic("usr_")); + } + + @Test // (sabotage: delimiter replaced by _ -> the -- assertion failed) + void deterministicReadsTheDelimiterGiven() { + assertTrue(Uxid.isDeterministic("usr--zcvt7epac0t1ebcsjfyf7cwz25", "--")); + assertFalse(Uxid.isDeterministic("usr--01hf7yat0004h1", "--")); + assertFalse(Uxid.isDeterministic("usr--zcvt7epac0t1ebcsjfyf7cwz25", "_")); + assertThrows( + IllegalArgumentException.class, + () -> Uxid.isDeterministic("usr_zcvt7epac0t1ebcsjfyf7cwz25", "")); + assertThrows(NullPointerException.class, () -> Uxid.isDeterministic(null)); + } +}