From 8bebb7184ca1bece3228d017d9a9c88afaac8b1b Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Wed, 21 Oct 2026 16:58:09 -0600 Subject: [PATCH] Vendors the uxid conformance corpus and runs every case The corpus is the arbiter of conformance, so the port now carries it and the gate runs it. conformance/ holds the uxid-ex corpus at v2.10.0 (the case files, manifest.json, the schemas and README.md, byte for byte; the generator sources are not copied), with SOURCE.json recording the repo, tag, commit and corpus hash. .gitattributes keeps its line endings as committed. CorpusHashTest recomputes the corpus hash and each case count by the corpus README's rule and fails on a mismatch. CorpusRunnerTest runs one parameterised test per case file over every case, with no skip list. A case is read strictly: a key the runner does not know, or a missing one, fails it, and a null prefix reads as no prefix. The JSON reader is a small one in the test sources, so the library and the pom gain no dependency. The hand-copied corpus rows in the four earlier corpus test classes are removed: the runner covers exactly the same cases, including the decode refusals, generate refusals and monotonic refusals they held. The targeted tests that pin behaviour the corpus does not stay. Refs: uxjv-baj --- .gitattributes | 2 + CLAUDE.md | 43 ++-- conformance/README.md | 124 +++++++++ conformance/SOURCE.json | 6 + conformance/decode.json | 18 ++ conformance/deterministic.json | 12 + conformance/encode.json | 35 +++ conformance/invalid.json | 25 ++ conformance/manifest.json | 1 + conformance/monotonic.json | 6 + conformance/schema/decode.schema.json | 48 ++++ conformance/schema/deterministic.schema.json | 40 +++ conformance/schema/encode.schema.json | 51 ++++ conformance/schema/invalid.schema.json | 90 +++++++ conformance/schema/manifest.schema.json | 47 ++++ conformance/schema/monotonic.schema.json | 77 ++++++ .../java/systems/riddler/uxid/Corpus.java | 83 +++++++ .../systems/riddler/uxid/CorpusCases.java | 235 ++++++++++++++++++ .../systems/riddler/uxid/CorpusHashTest.java | 71 ++++++ .../riddler/uxid/CorpusRunnerTest.java | 67 +++++ .../riddler/uxid/CorpusStrictnessTest.java | 89 +++++++ .../riddler/uxid/DecodeCorpusTest.java | 98 -------- .../riddler/uxid/DeterministicCorpusTest.java | 67 ----- .../java/systems/riddler/uxid/Fields.java | 167 +++++++++++++ .../riddler/uxid/GenerateCorpusTest.java | 128 ---------- src/test/java/systems/riddler/uxid/Json.java | 182 ++++++++++++++ .../riddler/uxid/MonotonicCorpusTest.java | 117 --------- 27 files changed, 1500 insertions(+), 429 deletions(-) create mode 100644 .gitattributes create mode 100644 conformance/README.md create mode 100644 conformance/SOURCE.json create mode 100644 conformance/decode.json create mode 100644 conformance/deterministic.json create mode 100644 conformance/encode.json create mode 100644 conformance/invalid.json create mode 100644 conformance/manifest.json create mode 100644 conformance/monotonic.json create mode 100644 conformance/schema/decode.schema.json create mode 100644 conformance/schema/deterministic.schema.json create mode 100644 conformance/schema/encode.schema.json create mode 100644 conformance/schema/invalid.schema.json create mode 100644 conformance/schema/manifest.schema.json create mode 100644 conformance/schema/monotonic.schema.json create mode 100644 src/test/java/systems/riddler/uxid/Corpus.java create mode 100644 src/test/java/systems/riddler/uxid/CorpusCases.java create mode 100644 src/test/java/systems/riddler/uxid/CorpusHashTest.java create mode 100644 src/test/java/systems/riddler/uxid/CorpusRunnerTest.java create mode 100644 src/test/java/systems/riddler/uxid/CorpusStrictnessTest.java delete mode 100644 src/test/java/systems/riddler/uxid/DecodeCorpusTest.java delete mode 100644 src/test/java/systems/riddler/uxid/DeterministicCorpusTest.java create mode 100644 src/test/java/systems/riddler/uxid/Fields.java delete mode 100644 src/test/java/systems/riddler/uxid/GenerateCorpusTest.java create mode 100644 src/test/java/systems/riddler/uxid/Json.java delete mode 100644 src/test/java/systems/riddler/uxid/MonotonicCorpusTest.java diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1c29522 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# The vendored corpus is compared byte for byte; no line-ending conversion. +conformance/** -text diff --git a/CLAUDE.md b/CLAUDE.md index 67ce573..3db3984 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,7 +116,7 @@ namespace verified on the Central Portal, a signing key with its public half on a public keyserver, a Central Portal user token, and the four secrets set on this repository. Merging the prep follows this file's merge row, and nothing else this file reserves for the operator changes. A prep is cut only -on a green full gate, which will include the conformance corpus check. +on a green full gate, which includes the conformance corpus check. This repository keeps no decision records (`docs/adr`). A release or gating decision is recorded here, in the authority table and the Release preps @@ -124,10 +124,10 @@ paragraphs, and in the header of the workflow it governs; a change a user of the API could notice is a changelog fragment (Conventions, below). The ID format itself is not decided here: it is uxid-ex's wire-format spec, `guides/wire-format.md` in https://github.com/riddler/uxid-ex, read at the -tag the vendored corpus names (`conformance/SOURCE.json`, once the corpus is -vendored). That this repository keeps no decision records was decided by the -conductor under a standing consent, 2026-10-21; a future API decision may -start a `docs/adr` directory. +tag the vendored corpus names (`conformance/SOURCE.json`). That this +repository keeps no decision records was decided by the conductor under a +standing consent, 2026-10-21; a future API decision may start a `docs/adr` +directory. Widening this section is a decision for the operator to make and record here. An agent may draft the change; it does not adopt it. @@ -159,24 +159,27 @@ The tree on `main` today holds these agent instructions, the wurk manifest (`.claude/wurk.json`), the tracker files under `.beads/`, the build (`pom.xml`, `mise.toml` and `.mvn/jvm.config`), the module descriptor, the 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 +`changelog.d/README.md`, the vendored conformance corpus under `conformance/` +(with `.gitattributes`, which keeps its line endings as committed), 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 (`Uxid.decode`, `DecodeOptions` and `DecodeResult`), the validity checks (`Uxid.isValid` and `Uxid.isDeterministic`) and the monotonic generator -(`Monotonic`, with its `DrawSource`). The vendored corpus arrives with a later -change; until it does, the sentences in this file about the 1.0.0 package and -its corpus describe the target, not the current tree. +(`Monotonic`, with its `DrawSource`). The sentences in this file about the +1.0.0 package describe the target, not the current tree: it is not yet +published. - **The package is `systems.riddler:uxid`.** Version 1.0.0 is its first release on Maven Central. -- **The corpus is the arbiter.** `conformance/` will hold the corpus - vendored from uxid-ex at a release tag, its tag, commit and hash recorded in - `conformance/SOURCE.json`. The vendored files are never edited, and every - case passes with no skip list: a case this port cannot pass is reported - against uxid-ex with its evidence, never skipped or worked round here. An - answer the corpus does not fix follows uxid-ex's spec at that tag. +- **The corpus is the arbiter.** `conformance/` holds the corpus vendored from + uxid-ex at the release tag `v2.10.0` (case files, `manifest.json`, `schema/` + and `README.md`, byte for byte; not the generator `source/`), its tag, + commit and hash recorded in `conformance/SOURCE.json`. The vendored files + are never edited, and every case passes with no skip list: a case this port + cannot pass is reported against uxid-ex with its evidence, never skipped or + worked round here. An answer the corpus does not fix follows uxid-ex's spec + at that tag. `README.md`, which a later change adds, is the user reference; `CHANGELOG.md` records each release. @@ -196,9 +199,11 @@ verify`: the enforcer rules (Maven 3.9 or later, every plugin version pinned, no dependency outside test scope), the compile with every javac lint and Error Prone, a warning failing it, the JUnit 5 tests, the jar and the sources and javadoc jars, the Spotless format check (google-java-format) and the JaCoCo -check at 90 percent of lines; the conformance corpus check joins it once the -corpus is vendored. The inner loop, `mise run gate:loop`, is `mvn -B -ntp -test`: the compile and the tests. +check at 90 percent of lines. The conformance corpus check is part of the +tests: a test recomputes the corpus hash and each case count by the +reference's rule, and a runner (`CorpusRunnerTest`) runs every case of every +case file, with no skip list, rejecting a case key it does not know. The inner +loop, `mise run gate:loop`, is `mvn -B -ntp test`: the compile and the tests. The gate runs on the JDK `mise.toml` pins (Temurin 25, with Maven 3.9). The toolchain floor is declared separately: `maven.compiler.release` 17 in diff --git a/conformance/README.md b/conformance/README.md new file mode 100644 index 0000000..988ae14 --- /dev/null +++ b/conformance/README.md @@ -0,0 +1,124 @@ +# UXID conformance corpus + +Test vectors for an implementation of UXID in another language. Each case is +one input and the answer this library gives for it, generated by calling the +library. A port vendors this directory at a release tag and runs every case +unchanged; a port that passes them all produces and reads the same strings as +this library for those inputs. + +The rules the cases instance are in the wire format guide +([`guides/wire-format.md`](../guides/wire-format.md)). Where a case and the +guide disagree, the guide states the format and the case is a defect. + +This directory is not part of the Hex package. + +## Never edited by hand + +Every case file and `manifest.json` are written by `mix uxid.corpus` from the +authored source under `source/`. The quality gate's Corpus stage runs +`mix uxid.corpus --check`, which builds the files in memory and fails on any +byte difference from the checked-in ones. A hand edit to a case file, or to +the hash, fails the gate; the fix is to change the source (or the library) +and regenerate: + + mix uxid.corpus # write the case files and the manifest + mix uxid.corpus --check # build in memory and byte-compare; write nothing + +The generator refuses to write anything when a case fails to build: the +library disagreeing with a golden vector (a case in `source/` that carries its +expected id), an encode case that does not decode back, an invalid case the +library does not refuse with the error the case names, a monotonic case whose +draws break the monotonic rule, or a duplicate id. + +## Layout + +| File | What it holds | +|------|---------------| +| `manifest.json` | The corpus version, the case files in order with their kind and case count, and the corpus hash | +| `encode.json` | Time-based ids: inputs to the id a producer emits | +| `deterministic.json` | Deterministic ids: inputs to the id a producer emits | +| `decode.json` | Strings to what a decoder reads from them | +| `invalid.json` | Strings a decoder refuses and options a producer refuses, to the error's name | +| `monotonic.json` | One monotonic generator's calls and draws to the ids it emits | +| `schema/` | One JSON Schema per case file (describing one line) and one for the manifest | +| `source/` | The authored source the generator reads; not needed to run the cases | + +## The format + +- A case file holds one JSON object per line, each line ended by a newline. + Object keys are sorted and there is no whitespace between tokens. +- Every case has a stable `id`, its kind and a slug (`encode/xl-standard-lower`), + its `input`, and either its `expected` answer or, in `invalid.json`, its + `expected_error`. A case id never changes meaning; a case that has to change + gets a new id. +- An error is a name from a closed set, never a message: the decode refusal + reasons and the generation errors the wire format guide lists ("Refusals" + and "Generation errors"). +- Every integer is at most 2^53 - 1, so a JSON parser that reads numbers as + doubles reads each one exactly. +- Bytes are lowercase hex. Times are integer milliseconds since the Unix + epoch. Sizes are `xs`, `s`, `m`, `l` and `xl`. A missing prefix is `null`. +- The delimiter is `_` in every case. +- `manifest.json` is one line: `corpus_version` (1), `files` (each with + `kind`, `file` and `case_count`, in a fixed order) and `corpus_hash`, the + string `sha256:` followed by the lowercase hex SHA-256 of the case files' + bytes concatenated in the order of `files`. + +## Running the cases + +1. Read `manifest.json`. Check `corpus_version` is one your runner knows. + Concatenate the bytes of each file in `files`, in order, hash them with + SHA-256 and compare to `corpus_hash`; a mismatch means the vendored copy is + not the one the manifest describes. +2. For each file, split on newlines and parse each line as one case. Check the + line count against `case_count`. +3. Run each case by its kind: + - **encode**: build an id from `input` with the random field set to the + bytes of `rand` (not drawn from a random source) and compare it to + `expected`. `prefix` `""` and `null` are both no prefix. + - **deterministic**: build the deterministic id of `input.from` under + `input.prefix` at `input.size` in `input.case` and compare it to + `expected`. + - **decode**: decode `input` and compare the prefix, size, compact flag, + deterministic flag and time to `expected`. The random field is not read + back on decode, so no case carries one. + - **invalid**: for `input.decode`, decode the string and check it is + refused with the reason `expected_error` (the body length is counted in + bytes). For `input.generate`, make the request with those options (an + absent option takes its default) and check it is refused with the + generation error `expected_error`; how a port maps its own refusal to + the name is the port's. + - **monotonic**: run a monotonic generator, lower case, for `input.prefix`, + `input.size` and `input.compact`, with its random source replaced by the + case's draws, and call it once per entry of `input.times` with that time. + The first call draws `initial_seed`; each later call draws the next of + `steps`; the call whose step overflows the field also draws + `overflow_seed` (a case overflows at most once). Compare the ids to + `expected` in order. The draws are values, not a seed for a pseudorandom + generator, so no generator algorithm is part of the corpus. No time after + the first is later than the last id's time, so no other fresh value is + drawn. +4. Report each failing case by its `id`. + +## Compact times and the decoder's clock + +A compact body carries only the low 40 bits of its time; the decoder supplies +the rest from its own clock (the guide's "Decoding the time"). The compact +decode cases carry the full time this library recovers, and they all hold for +a decoder clock from 1699913600000 ms (2023-11-13T22:13:20.000Z) inclusive +to 2164577117183 ms (2038-08-04T23:25:17.183Z) exclusive: + +- the cases at 1700000000000 ms are recovered by a clock no more than one day + (86400000 ms) behind them, which sets the start; +- `decode/s-compact-guard-last-valid` is the compact guard's last valid + millisecond (the largest compact time field, 1065151889407) from the window + before, and is recovered by the one-window subtraction only while + 2^40 + 1065151889407 is more than one day ahead of the clock, which sets + the end. + +A runner testing compact decode on a clock outside that range injects the +clock rather than reading the system time. + +The compact xs and compact m cases decode to the standard reading the guide +names (their lengths are those of standard xs and m): the same size, `compact` +false, and a time read from the first 10 body characters as a standard time. diff --git a/conformance/SOURCE.json b/conformance/SOURCE.json new file mode 100644 index 0000000..cc20f1c --- /dev/null +++ b/conformance/SOURCE.json @@ -0,0 +1,6 @@ +{ + "repo": "riddler/uxid-ex", + "tag": "v2.10.0", + "sha": "57235777cb29c70f751552ce7aa20ab5fe71fb74", + "corpus_hash": "sha256:4f7572c8c1a60134b2955f02b34c82a839d0c3ab95d4218cc2df5a85eeb2bebb" +} diff --git a/conformance/decode.json b/conformance/decode.json new file mode 100644 index 0000000..c7da21e --- /dev/null +++ b/conformance/decode.json @@ -0,0 +1,18 @@ +{"expected":{"compact":false,"deterministic":false,"prefix":"usr","size":"xl","time":1700000000000},"id":"decode/xl-standard","input":"usr_01hf7yat0004hmasw9nf6yy093"} +{"expected":{"compact":true,"deterministic":false,"prefix":"usr","size":"xl","time":1700000000000},"id":"decode/xl-compact","input":"usr_hf7yat0004hmasw9nf6yy093"} +{"expected":{"compact":false,"deterministic":false,"prefix":"usr","size":"l","time":1700000000000},"id":"decode/l-standard","input":"usr_01hf7yat0004hmasw9nf61"} +{"expected":{"compact":true,"deterministic":false,"prefix":"usr","size":"l","time":1700000000000},"id":"decode/l-compact","input":"usr_hf7yat0004hmasw9nf6yf"} +{"expected":{"compact":false,"deterministic":false,"prefix":"usr","size":"m","time":1700000000000},"id":"decode/m-standard","input":"usr_01hf7yat0004hmasw9"} +{"expected":{"compact":false,"deterministic":false,"prefix":"usr","size":"m","time":51950139736068},"id":"decode/m-compact-reads-as-standard","input":"usr_hf7yat0004hmasw9n3"} +{"expected":{"compact":false,"deterministic":false,"prefix":"usr","size":"s","time":1700000000000},"id":"decode/s-standard","input":"usr_01hf7yat0004h1"} +{"expected":{"compact":true,"deterministic":false,"prefix":"usr","size":"s","time":1700000000000},"id":"decode/s-compact","input":"usr_hf7yat0004hm5"} +{"expected":{"compact":false,"deterministic":false,"prefix":"usr","size":"xs","time":1700000000000},"id":"decode/xs-standard","input":"usr_01hf7yat00"} +{"expected":{"compact":false,"deterministic":false,"prefix":"usr","size":"xs","time":51950139736065},"id":"decode/xs-compact-reads-as-standard","input":"usr_hf7yat0001"} +{"expected":{"compact":false,"deterministic":false,"prefix":"usr","size":"xl","time":1700000000000},"id":"decode/xl-standard-upper","input":"usr_01HF7YAT0004HMASW9NF6YY093"} +{"expected":{"compact":false,"deterministic":false,"prefix":null,"size":"xl","time":1700000000000},"id":"decode/xl-standard-no-prefix","input":"01hf7yat0004hmasw9nf6yy093"} +{"expected":{"compact":false,"deterministic":false,"prefix":"a_b","size":"s","time":1700000000000},"id":"decode/s-standard-prefix-with-delimiter","input":"a_b_01hf7yat000001"} +{"expected":{"compact":true,"deterministic":false,"prefix":"usr","size":"s","time":1065151889407},"id":"decode/s-compact-guard-last-valid","input":"usr_yzzzzzzz04hm5"} +{"expected":{"compact":false,"deterministic":true,"prefix":"usr","size":"xl","time":null},"id":"decode/deterministic-usr-xl","input":"usr_zcvt7epac0t1ebcsjfyf7cwz25"} +{"expected":{"compact":false,"deterministic":true,"prefix":"cus","size":"s","time":null},"id":"decode/deterministic-cus-s","input":"cus_zt9vnyfd85bff0"} +{"expected":{"compact":false,"deterministic":true,"prefix":null,"size":"m","time":null},"id":"decode/deterministic-no-prefix-m","input":"zqz4cwzgyw8ks97869"} +{"expected":{"compact":false,"deterministic":true,"prefix":"usr","size":"xl","time":null},"id":"decode/deterministic-upper-marker","input":"usr_ZCVT7EPAC0T1EBCSJFYF7CWZ25"} diff --git a/conformance/deterministic.json b/conformance/deterministic.json new file mode 100644 index 0000000..93553ef --- /dev/null +++ b/conformance/deterministic.json @@ -0,0 +1,12 @@ +{"expected":"usr_zcvt7epac0t1ebcsjfyf7cwz25","id":"deterministic/golden-usr-xl","input":{"case":"lower","from":"alice@example.com","prefix":"usr","size":"xl"}} +{"expected":"cus_zt9vnyfd85bff0","id":"deterministic/golden-cus-s","input":{"case":"lower","from":"acme-corp","prefix":"cus","size":"s"}} +{"expected":"zqz4cwzgyw8ks97869","id":"deterministic/golden-no-prefix-m","input":{"case":"lower","from":"alice@example.com","prefix":null,"size":"m"}} +{"expected":"usr_z1yhw20vag","id":"deterministic/usr-xs","input":{"case":"lower","from":"alice@example.com","prefix":"usr","size":"xs"}} +{"expected":"usr_z5rtsrkhx2h4dr","id":"deterministic/usr-s","input":{"case":"lower","from":"alice@example.com","prefix":"usr","size":"s"}} +{"expected":"usr_zt8naccefxhwnrmscd","id":"deterministic/usr-m","input":{"case":"lower","from":"alice@example.com","prefix":"usr","size":"m"}} +{"expected":"usr_zwbz6myvgz7ehe3nceap5n","id":"deterministic/usr-l","input":{"case":"lower","from":"alice@example.com","prefix":"usr","size":"l"}} +{"expected":"usr_zcvt7epac0t1ebcsjfyf7cwz25","id":"deterministic/usr-xl","input":{"case":"lower","from":"alice@example.com","prefix":"usr","size":"xl"}} +{"expected":"z4krgegf6drn563pvqhya4tsx9","id":"deterministic/empty-prefix-xl","input":{"case":"lower","from":"alice@example.com","prefix":"","size":"xl"}} +{"expected":"z4krgegf6drn563pvqhya4tsx9","id":"deterministic/no-prefix-xl","input":{"case":"lower","from":"alice@example.com","prefix":null,"size":"xl"}} +{"expected":"usr_ZCVT7EPAC0T1EBCSJFYF7CWZ25","id":"deterministic/usr-xl-upper","input":{"case":"upper","from":"alice@example.com","prefix":"usr","size":"xl"}} +{"expected":"cus_zfsx9se75671f9kdjv","id":"deterministic/non-ascii-from-m","input":{"case":"lower","from":"café","prefix":"cus","size":"m"}} diff --git a/conformance/encode.json b/conformance/encode.json new file mode 100644 index 0000000..54759f9 --- /dev/null +++ b/conformance/encode.json @@ -0,0 +1,35 @@ +{"expected":"usr_01hf7yat00","id":"encode/xs-standard-lower","input":{"case":"lower","compact":false,"prefix":"usr","rand":"","size":"xs","time":1700000000000}} +{"expected":"usr_01HF7YAT00","id":"encode/xs-standard-upper","input":{"case":"upper","compact":false,"prefix":"usr","rand":"","size":"xs","time":1700000000000}} +{"expected":"usr_hf7yat0001","id":"encode/xs-compact-lower","input":{"case":"lower","compact":true,"prefix":"usr","rand":"01","size":"xs","time":1700000000000}} +{"expected":"usr_HF7YAT0001","id":"encode/xs-compact-upper","input":{"case":"upper","compact":true,"prefix":"usr","rand":"01","size":"xs","time":1700000000000}} +{"expected":"usr_01hf7yat0004h1","id":"encode/s-standard-lower","input":{"case":"lower","compact":false,"prefix":"usr","rand":"0123","size":"s","time":1700000000000}} +{"expected":"usr_01HF7YAT0004H1","id":"encode/s-standard-upper","input":{"case":"upper","compact":false,"prefix":"usr","rand":"0123","size":"s","time":1700000000000}} +{"expected":"usr_hf7yat0004hm5","id":"encode/s-compact-lower","input":{"case":"lower","compact":true,"prefix":"usr","rand":"012345","size":"s","time":1700000000000}} +{"expected":"usr_HF7YAT0004HM5","id":"encode/s-compact-upper","input":{"case":"upper","compact":true,"prefix":"usr","rand":"012345","size":"s","time":1700000000000}} +{"expected":"usr_01hf7yat0004hmasw9","id":"encode/m-standard-lower","input":{"case":"lower","compact":false,"prefix":"usr","rand":"0123456789","size":"m","time":1700000000000}} +{"expected":"usr_01HF7YAT0004HMASW9","id":"encode/m-standard-upper","input":{"case":"upper","compact":false,"prefix":"usr","rand":"0123456789","size":"m","time":1700000000000}} +{"expected":"usr_hf7yat0004hmasw9n3","id":"encode/m-compact-lower","input":{"case":"lower","compact":true,"prefix":"usr","rand":"0123456789ab","size":"m","time":1700000000000}} +{"expected":"usr_HF7YAT0004HMASW9N3","id":"encode/m-compact-upper","input":{"case":"upper","compact":true,"prefix":"usr","rand":"0123456789ab","size":"m","time":1700000000000}} +{"expected":"usr_01hf7yat0004hmasw9nf61","id":"encode/l-standard-lower","input":{"case":"lower","compact":false,"prefix":"usr","rand":"0123456789abcd","size":"l","time":1700000000000}} +{"expected":"usr_01HF7YAT0004HMASW9NF61","id":"encode/l-standard-upper","input":{"case":"upper","compact":false,"prefix":"usr","rand":"0123456789abcd","size":"l","time":1700000000000}} +{"expected":"usr_hf7yat0004hmasw9nf6yf","id":"encode/l-compact-lower","input":{"case":"lower","compact":true,"prefix":"usr","rand":"0123456789abcdef","size":"l","time":1700000000000}} +{"expected":"usr_HF7YAT0004HMASW9NF6YF","id":"encode/l-compact-upper","input":{"case":"upper","compact":true,"prefix":"usr","rand":"0123456789abcdef","size":"l","time":1700000000000}} +{"expected":"usr_01hf7yat0004hmasw9nf6yy093","id":"encode/xl-standard-lower","input":{"case":"lower","compact":false,"prefix":"usr","rand":"0123456789abcdef0123","size":"xl","time":1700000000000}} +{"expected":"usr_01HF7YAT0004HMASW9NF6YY093","id":"encode/xl-standard-upper","input":{"case":"upper","compact":false,"prefix":"usr","rand":"0123456789abcdef0123","size":"xl","time":1700000000000}} +{"expected":"usr_hf7yat0004hmasw9nf6yy093","id":"encode/xl-compact-lower","input":{"case":"lower","compact":true,"prefix":"usr","rand":"0123456789abcdef0123","size":"xl","time":1700000000000}} +{"expected":"usr_HF7YAT0004HMASW9NF6YY093","id":"encode/xl-compact-upper","input":{"case":"upper","compact":true,"prefix":"usr","rand":"0123456789abcdef0123","size":"xl","time":1700000000000}} +{"expected":"cus_hf7yat00z7","id":"encode/xs-compact-rand-all-ones","input":{"case":"lower","compact":true,"prefix":"cus","rand":"ff","size":"xs","time":1700000000000}} +{"expected":"cus_01hf7yat00zzz1","id":"encode/s-standard-rand-all-ones","input":{"case":"lower","compact":false,"prefix":"cus","rand":"ffff","size":"s","time":1700000000000}} +{"expected":"cus_hf7yat00zzzzf","id":"encode/s-compact-rand-all-ones","input":{"case":"lower","compact":true,"prefix":"cus","rand":"ffffff","size":"s","time":1700000000000}} +{"expected":"cus_01hf7yat00zzzzzzzz","id":"encode/m-standard-rand-all-ones","input":{"case":"lower","compact":false,"prefix":"cus","rand":"ffffffffff","size":"m","time":1700000000000}} +{"expected":"cus_hf7yat00zzzzzzzzz7","id":"encode/m-compact-rand-all-ones","input":{"case":"lower","compact":true,"prefix":"cus","rand":"ffffffffffff","size":"m","time":1700000000000}} +{"expected":"cus_01hf7yat00zzzzzzzzzzz1","id":"encode/l-standard-rand-all-ones","input":{"case":"lower","compact":false,"prefix":"cus","rand":"ffffffffffffff","size":"l","time":1700000000000}} +{"expected":"cus_hf7yat00zzzzzzzzzzzzf","id":"encode/l-compact-rand-all-ones","input":{"case":"lower","compact":true,"prefix":"cus","rand":"ffffffffffffffff","size":"l","time":1700000000000}} +{"expected":"cus_01hf7yat00zzzzzzzzzzzzzzzz","id":"encode/xl-standard-rand-all-ones","input":{"case":"lower","compact":false,"prefix":"cus","rand":"ffffffffffffffffffff","size":"xl","time":1700000000000}} +{"expected":"cus_hf7yat00zzzzzzzzzzzzzzzz","id":"encode/xl-compact-rand-all-ones","input":{"case":"lower","compact":true,"prefix":"cus","rand":"ffffffffffffffffffff","size":"xl","time":1700000000000}} +{"expected":"0000000000","id":"encode/xs-standard-time-zero","input":{"case":"lower","compact":false,"prefix":null,"rand":"","size":"xs","time":0}} +{"expected":"7zzzzzzzzz","id":"encode/xs-standard-time-max","input":{"case":"lower","compact":false,"prefix":null,"rand":"","size":"xs","time":281474976710655}} +{"expected":"usr_yzzzzzzz04hm5","id":"encode/s-compact-guard-last-valid","input":{"case":"lower","compact":true,"prefix":"usr","rand":"012345","size":"s","time":1065151889407}} +{"expected":"01hf7yat0004hmasw9nf6yy093","id":"encode/xl-standard-no-prefix","input":{"case":"lower","compact":false,"prefix":null,"rand":"0123456789abcdef0123","size":"xl","time":1700000000000}} +{"expected":"01hf7yat0004hmasw9nf6yy093","id":"encode/xl-standard-empty-prefix","input":{"case":"lower","compact":false,"prefix":"","rand":"0123456789abcdef0123","size":"xl","time":1700000000000}} +{"expected":"a_b_01hf7yat0004h1","id":"encode/s-standard-prefix-with-delimiter","input":{"case":"lower","compact":false,"prefix":"a_b","rand":"0123","size":"s","time":1700000000000}} diff --git a/conformance/invalid.json b/conformance/invalid.json new file mode 100644 index 0000000..5a9184b --- /dev/null +++ b/conformance/invalid.json @@ -0,0 +1,25 @@ +{"expected_error":"empty","id":"invalid/decode-empty-string","input":{"decode":""}} +{"expected_error":"empty","id":"invalid/decode-ends-in-delimiter","input":{"decode":"usr_"}} +{"expected_error":"empty","id":"invalid/decode-prefix-with-delimiter-ends-in-delimiter","input":{"decode":"a_b_"}} +{"expected_error":"empty","id":"invalid/decode-delimiter-only","input":{"decode":"_"}} +{"expected_error":"invalid_length","id":"invalid/decode-length-11","input":{"decode":"usr_01hf7yat000"}} +{"expected_error":"invalid_length","id":"invalid/decode-length-25","input":{"decode":"usr_01hf7yat00000g40r40m30e20"}} +{"expected_error":"invalid_length","id":"invalid/decode-length-27","input":{"decode":"usr_01hf7yat00000g40r40m30e2090"}} +{"expected_error":"invalid_length","id":"invalid/decode-no-delimiter-short","input":{"decode":"usr"}} +{"expected_error":"invalid_character","id":"invalid/decode-character-in-time","input":{"decode":"usr_01hu7yat00000g40r40m30e209"}} +{"expected_error":"invalid_character","id":"invalid/decode-character-in-random","input":{"decode":"usr_01hf7yat00000g4lr40m30e209"}} +{"expected_error":"invalid_character","id":"invalid/decode-upper-o-in-random","input":{"decode":"usr_01hf7yat00000g40r40mO0e209"}} +{"expected_error":"invalid_character","id":"invalid/decode-hyphen-in-body","input":{"decode":"usr_01hf7yat00-00g40r40m30e209"}} +{"expected_error":"invalid_character","id":"invalid/decode-character-in-deterministic","input":{"decode":"usr_zcvt7epac0t1ebcsjfyf7cwzi5"}} +{"expected_error":"invalid_character","id":"invalid/decode-non-ascii-table-length","input":{"decode":"01hf7yat00000g40r40m30e2é"}} +{"expected_error":"invalid_length","id":"invalid/decode-non-ascii-off-table-length","input":{"decode":"01hf7yat00000g40r40m30e20é"}} +{"expected_error":"invalid_length","id":"invalid/decode-length-and-character","input":{"decode":"usr_01hf7yat00u"}} +{"expected_error":"compact_time_out_of_range","id":"invalid/generate-compact-time-at-guard","input":{"generate":{"compact":true,"prefix":"usr","rand":"010203","size":"s","time":2164663517184}}} +{"expected_error":"compact_time_out_of_range","id":"invalid/generate-compact-time-at-guard-previous-window","input":{"generate":{"compact":true,"prefix":"usr","rand":"010203","size":"s","time":1065151889408}}} +{"expected_error":"rand_width_mismatch","id":"invalid/generate-rand-too-wide","input":{"generate":{"prefix":"usr","rand":"010203","size":"s","time":1700000000000}}} +{"expected_error":"rand_width_mismatch","id":"invalid/generate-rand-too-narrow","input":{"generate":{"prefix":"usr","rand":"010203040506070809","size":"xl","time":1700000000000}}} +{"expected_error":"rand_width_mismatch","id":"invalid/generate-rand-on-xs-standard","input":{"generate":{"prefix":"usr","rand":"01","size":"xs","time":1700000000000}}} +{"expected_error":"rand_size_too_large","id":"invalid/generate-rand-size-11","input":{"generate":{"prefix":"usr","rand_size":11,"size":"xl","time":1700000000000}}} +{"expected_error":"from_with_monotonic","id":"invalid/generate-from-with-monotonic","input":{"generate":{"from":"alice@example.com","monotonic":true,"prefix":"usr"}}} +{"expected_error":"rand_with_from","id":"invalid/generate-rand-with-from","input":{"generate":{"from":"alice@example.com","prefix":"usr","rand":"00010203040506070809"}}} +{"expected_error":"rand_with_monotonic","id":"invalid/generate-rand-with-monotonic","input":{"generate":{"monotonic":true,"prefix":"usr","rand":"0102","size":"s","time":1700000000000}}} diff --git a/conformance/manifest.json b/conformance/manifest.json new file mode 100644 index 0000000..1ddc14f --- /dev/null +++ b/conformance/manifest.json @@ -0,0 +1 @@ +{"corpus_hash":"sha256:4f7572c8c1a60134b2955f02b34c82a839d0c3ab95d4218cc2df5a85eeb2bebb","corpus_version":1,"files":[{"case_count":35,"file":"encode.json","kind":"encode"},{"case_count":12,"file":"deterministic.json","kind":"deterministic"},{"case_count":18,"file":"decode.json","kind":"decode"},{"case_count":25,"file":"invalid.json","kind":"invalid"},{"case_count":6,"file":"monotonic.json","kind":"monotonic"}]} diff --git a/conformance/monotonic.json b/conformance/monotonic.json new file mode 100644 index 0000000..8c31dc3 --- /dev/null +++ b/conformance/monotonic.json @@ -0,0 +1,6 @@ +{"expected":["usr_01hf7yat0000000000003nqk8n","usr_01hf7yat0000000000003nqk8p","usr_01hf7yat0000000001003nqk8p","usr_01hf7yat0000000001003nqka0"],"id":"monotonic/xl-same-millisecond","input":{"compact":false,"initial_seed":123456789,"overflow_seed":null,"prefix":"usr","size":"xl","steps":[1,1099511627776,42],"times":[1700000000000,1700000000000,1700000000000,1700000000000]}} +{"expected":["usr_01hf7yat00000000z8","usr_01hf7yat00000000zd","usr_01hf7yat00000100zd","usr_01hf7yat00000100zm"],"id":"monotonic/m-clock-moves-back","input":{"compact":false,"initial_seed":1000,"overflow_seed":null,"prefix":"usr","size":"m","steps":[5,1048576,7],"times":[1700000000000,1699999999999,1699999999990,1700000000000]}} +{"expected":["usr_01hf7yat00zqm0","usr_01hf7yat00ztr0","usr_01hf7yat00zyn0","usr_01hf7yat010k90","usr_01hf7yat010kc1","usr_01hf7yat010ke0"],"id":"monotonic/s-overflow-bumps-time","input":{"compact":false,"initial_seed":65000,"overflow_seed":1234,"prefix":"usr","size":"s","steps":[200,250,100,7,3],"times":[1700000000000,1700000000000,1700000000000,1700000000000,1700000000000,1700000000001]}} +{"expected":["usr_hf7yat00z2","usr_hf7yat00z7","usr_hf7yat0111"],"id":"monotonic/xs-compact-overflow-clock-back","input":{"compact":true,"initial_seed":250,"overflow_seed":9,"prefix":"usr","size":"xs","steps":[5,16],"times":[1700000000000,1700000000000,1699999999999]}} +{"expected":["usr_hf7yat000000000000000"],"id":"monotonic/l-compact-single","input":{"compact":true,"initial_seed":0,"overflow_seed":null,"prefix":"usr","size":"l","steps":[],"times":[1700000000000]}} +{"expected":["hf7yat0000000","hf7yat0000800"],"id":"monotonic/s-compact-no-prefix-largest-step","input":{"compact":true,"initial_seed":0,"overflow_seed":null,"prefix":null,"size":"s","steps":[4096],"times":[1700000000000,1700000000000]}} diff --git a/conformance/schema/decode.schema.json b/conformance/schema/decode.schema.json new file mode 100644 index 0000000..abd6f52 --- /dev/null +++ b/conformance/schema/decode.schema.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "decode.schema.json", + "title": "UXID conformance case: decode", + "description": "One line of decode.json: a string and what a decoder reads from it. The delimiter is `_`. The random field is not read back on decode.", + "type": "object", + "additionalProperties": false, + "required": ["id", "input", "expected"], + "properties": { + "id": { + "description": "Stable case id, `decode/` and a slug.", + "type": "string", + "pattern": "^decode/[a-z0-9][a-z0-9-]*$" + }, + "input": { + "description": "The string to decode.", + "type": "string" + }, + "expected": { + "type": "object", + "additionalProperties": false, + "required": ["prefix", "size", "compact", "deterministic", "time"], + "properties": { + "prefix": { + "description": "Everything before the last delimiter; null when there is no delimiter.", + "type": ["string", "null"] + }, + "size": { + "description": "The size the body length gives.", + "enum": ["xs", "s", "m", "l", "xl"] + }, + "compact": { + "description": "Whether the body length is a compact one.", + "type": "boolean" + }, + "deterministic": { + "description": "Whether the body starts with the marker `z` or `Z`.", + "type": "boolean" + }, + "time": { + "description": "Milliseconds since the Unix epoch; null for a deterministic body. A compact time depends on the decoder's clock (see the README).", + "type": ["integer", "null"], + "minimum": 0 + } + } + } + } +} diff --git a/conformance/schema/deterministic.schema.json b/conformance/schema/deterministic.schema.json new file mode 100644 index 0000000..ea54115 --- /dev/null +++ b/conformance/schema/deterministic.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "deterministic.schema.json", + "title": "UXID conformance case: deterministic", + "description": "One line of deterministic.json: the inputs of a deterministic (name-based) id and the id a producer emits for them. The delimiter is `_`.", + "type": "object", + "additionalProperties": false, + "required": ["id", "input", "expected"], + "properties": { + "id": { + "description": "Stable case id, `deterministic/` and a slug.", + "type": "string", + "pattern": "^deterministic/[a-z0-9][a-z0-9-]*$" + }, + "input": { + "type": "object", + "additionalProperties": false, + "required": ["prefix", "from", "size", "case"], + "properties": { + "prefix": { + "description": "The prefix; null for none. An empty string is no prefix too.", + "type": ["string", "null"] + }, + "from": { + "description": "The string the body is a hash of, hashed as its UTF-8 bytes.", + "type": "string" + }, + "size": { "enum": ["xs", "s", "m", "l", "xl"] }, + "case": { + "description": "The letter case of the body, the marker included.", + "enum": ["lower", "upper"] + } + } + }, + "expected": { + "description": "The id the producer emits.", + "type": "string" + } + } +} diff --git a/conformance/schema/encode.schema.json b/conformance/schema/encode.schema.json new file mode 100644 index 0000000..a6756ed --- /dev/null +++ b/conformance/schema/encode.schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "encode.schema.json", + "title": "UXID conformance case: encode", + "description": "One line of encode.json: the inputs of a time-based id and the id a producer emits for them. The delimiter is `_`.", + "type": "object", + "additionalProperties": false, + "required": ["id", "input", "expected"], + "properties": { + "id": { + "description": "Stable case id, `encode/` and a slug.", + "type": "string", + "pattern": "^encode/[a-z0-9][a-z0-9-]*$" + }, + "input": { + "type": "object", + "additionalProperties": false, + "required": ["prefix", "size", "compact", "case", "time", "rand"], + "properties": { + "prefix": { + "description": "The prefix; null for none. An empty string is no prefix too.", + "type": ["string", "null"] + }, + "size": { "enum": ["xs", "s", "m", "l", "xl"] }, + "compact": { + "description": "Compact time (40 bits) when true, standard time (48 bits) when false.", + "type": "boolean" + }, + "case": { + "description": "The letter case of the body.", + "enum": ["lower", "upper"] + }, + "time": { + "description": "Milliseconds since the Unix epoch.", + "type": "integer", + "minimum": 0, + "maximum": 281474976710655 + }, + "rand": { + "description": "The random field's bytes as lowercase hex, exactly the random width of the size and mode.", + "type": "string", + "pattern": "^([0-9a-f]{2}){0,10}$" + } + } + }, + "expected": { + "description": "The id the producer emits.", + "type": "string" + } + } +} diff --git a/conformance/schema/invalid.schema.json b/conformance/schema/invalid.schema.json new file mode 100644 index 0000000..fed08da --- /dev/null +++ b/conformance/schema/invalid.schema.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "invalid.schema.json", + "title": "UXID conformance case: invalid", + "description": "One line of invalid.json: a string a decoder refuses, or options a producer refuses to build an id from, and the name of the error. The delimiter is `_`. Error names are a closed set (the wire format guide's Refusals and Generation errors), never messages.", + "type": "object", + "additionalProperties": false, + "required": ["id", "input", "expected_error"], + "properties": { + "id": { + "description": "Stable case id, `invalid/` and a slug.", + "type": "string", + "pattern": "^invalid/[a-z0-9][a-z0-9-]*$" + }, + "input": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["decode"], + "properties": { + "decode": { + "description": "The string to decode. Its body length is counted in bytes.", + "type": "string" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["generate"], + "properties": { + "generate": { + "description": "The options of the refused request, spelt as an encode case spells them; an absent option takes its default.", + "type": "object", + "additionalProperties": false, + "properties": { + "prefix": { "type": ["string", "null"] }, + "size": { "enum": ["xs", "s", "m", "l", "xl"] }, + "compact": { + "description": "Compact time (40 bits) when true, standard time (48 bits) when false.", + "type": "boolean" + }, + "case": { "enum": ["lower", "upper"] }, + "time": { + "description": "Milliseconds since the Unix epoch.", + "type": "integer", + "minimum": 0, + "maximum": 281474976710655 + }, + "rand": { + "description": "A fixed random field as lowercase hex.", + "type": "string", + "pattern": "^([0-9a-f]{2})*$" + }, + "rand_size": { + "description": "An explicit random field width in bytes.", + "type": "integer", + "minimum": 0 + }, + "from": { + "description": "The input string of a deterministic id.", + "type": "string" + }, + "monotonic": { + "description": "Monotonic generation.", + "type": "boolean" + } + } + } + } + } + ] + }, + "expected_error": { + "description": "A decode input's refusal reason, or a generate input's generation error.", + "enum": [ + "empty", + "invalid_length", + "invalid_character", + "compact_time_out_of_range", + "rand_width_mismatch", + "rand_size_too_large", + "from_with_monotonic", + "rand_with_from", + "rand_with_monotonic" + ] + } + } +} diff --git a/conformance/schema/manifest.schema.json b/conformance/schema/manifest.schema.json new file mode 100644 index 0000000..33f7959 --- /dev/null +++ b/conformance/schema/manifest.schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "manifest.schema.json", + "title": "UXID conformance corpus manifest", + "description": "manifest.json: the corpus version, the case files in a fixed order, and a hash over their bytes.", + "type": "object", + "additionalProperties": false, + "required": ["corpus_version", "files", "corpus_hash"], + "properties": { + "corpus_version": { + "description": "The version of the corpus shape.", + "const": 1 + }, + "files": { + "description": "The case files, in the order the hash reads them.", + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "file", "case_count"], + "properties": { + "kind": { + "description": "The case kind; every case id in the file starts with it and a slash.", + "type": "string", + "pattern": "^[a-z]+$" + }, + "file": { + "description": "The case file, relative to the manifest.", + "type": "string", + "pattern": "^[a-z]+\\.json$" + }, + "case_count": { + "description": "The number of lines (cases) in the file.", + "type": "integer", + "minimum": 0 + } + } + } + }, + "corpus_hash": { + "description": "`sha256:` and the lowercase hex SHA-256 of the case files' bytes concatenated in the order of `files`.", + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + } + } +} diff --git a/conformance/schema/monotonic.schema.json b/conformance/schema/monotonic.schema.json new file mode 100644 index 0000000..54556e1 --- /dev/null +++ b/conformance/schema/monotonic.schema.json @@ -0,0 +1,77 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "monotonic.schema.json", + "title": "UXID conformance case: monotonic", + "description": "One line of monotonic.json: one monotonic generator's calls, the draws its random source made, and the ids it emits. The delimiter is `_` and the body is lower case. Every integer is at most 2^53 - 1.", + "type": "object", + "additionalProperties": false, + "required": ["id", "input", "expected"], + "properties": { + "id": { + "description": "Stable case id, `monotonic/` and a slug.", + "type": "string", + "pattern": "^monotonic/[a-z0-9][a-z0-9-]*$" + }, + "input": { + "type": "object", + "additionalProperties": false, + "required": [ + "prefix", + "size", + "compact", + "times", + "initial_seed", + "steps", + "overflow_seed" + ], + "properties": { + "prefix": { + "description": "The prefix; null for none.", + "type": ["string", "null"] + }, + "size": { "enum": ["xs", "s", "m", "l", "xl"] }, + "compact": { + "description": "Compact time when true; it fixes the random field's width w with the size. A monotonic xs is compact.", + "type": "boolean" + }, + "times": { + "description": "The current time of each call, in milliseconds since the Unix epoch, one per id. No time after the first is later than the last id's time.", + "type": "array", + "minItems": 1, + "items": { + "type": "integer", + "minimum": 0, + "maximum": 281474976710655 + } + }, + "initial_seed": { + "description": "The first id's random field, the fresh secure value, as an unsigned integer below 2^(8w).", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "steps": { + "description": "The step drawn for each id after the first, each from 1 to 2^(4w).", + "type": "array", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "overflow_seed": { + "description": "The fresh secure value the id whose step overflows the field takes; null when no step overflows. A case overflows at most once.", + "type": ["integer", "null"], + "minimum": 0, + "maximum": 9007199254740991 + } + } + }, + "expected": { + "description": "The ids the generator emits, one per call, in call order.", + "type": "array", + "minItems": 1, + "items": { "type": "string" } + } + } +} diff --git a/src/test/java/systems/riddler/uxid/Corpus.java b/src/test/java/systems/riddler/uxid/Corpus.java new file mode 100644 index 0000000..8ceef1f --- /dev/null +++ b/src/test/java/systems/riddler/uxid/Corpus.java @@ -0,0 +1,83 @@ +package systems.riddler.uxid; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; + +/** + * Reads the vendored conformance corpus from {@code conformance/}, relative to the project + * directory the tests run in, and computes its hash by the corpus README's rule. + */ +final class Corpus { + private static final Path DIR = Path.of("conformance"); + + private Corpus() {} + + /** One file entry of {@code manifest.json}. */ + record Entry(String kind, String file, long caseCount) {} + + /** The parsed {@code manifest.json}. */ + record Manifest(long version, String corpusHash, List files) {} + + static byte[] bytes(String name) { + try { + return Files.readAllBytes(DIR.resolve(name)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + static String text(String name) { + return new String(bytes(name), StandardCharsets.UTF_8); + } + + /** + * The lines of a case file: it is split on newlines and each line is ended by one, so the text + * after the last newline is empty and is not a case. + */ + static List lines(String name) { + String text = text(name); + if (!text.endsWith("\n")) { + throw new AssertionError(name + " does not end with a newline"); + } + List lines = new ArrayList<>(List.of(text.split("\n", -1))); + lines.remove(lines.size() - 1); + return lines; + } + + static Manifest manifest() { + Fields root = Fields.parse(text("manifest.json")); + long version = root.integer("corpus_version"); + String hash = root.string("corpus_hash"); + List files = new ArrayList<>(); + for (Fields entry : root.objects("files")) { + files.add(new Entry(entry.string("kind"), entry.string("file"), entry.integer("case_count"))); + entry.done(); + } + root.done(); + return new Manifest(version, hash, files); + } + + /** + * The corpus hash of these files: {@code sha256:} and the lowercase hex SHA-256 of their bytes + * concatenated in the order given. + */ + static String hash(List files) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + for (String file : files) { + digest.update(bytes(file)); + } + return "sha256:" + HexFormat.of().formatHex(digest.digest()); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/test/java/systems/riddler/uxid/CorpusCases.java b/src/test/java/systems/riddler/uxid/CorpusCases.java new file mode 100644 index 0000000..f283580 --- /dev/null +++ b/src/test/java/systems/riddler/uxid/CorpusCases.java @@ -0,0 +1,235 @@ +package systems.riddler.uxid; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; +import java.util.OptionalLong; + +/** + * Runs one conformance case, given as its line of a case file, by the corpus README's rule for its + * kind. Each case is read strictly (see {@link Fields}): a key the runner does not know fails the + * case, and so does a missing one. A {@code null} or empty prefix in a case is no prefix. The + * corpus does not fix the order in which a generation request's refusals are checked, so no case + * here relies on one. + */ +final class CorpusCases { + private CorpusCases() {} + + /** The id of a case line, for naming a test. */ + static String id(String line) { + return Fields.parse(line).string("id"); + } + + /** + * An encode case: the id built from {@code input} with the random field fixed to {@code rand}. + */ + static void encode(String line) { + Fields root = Fields.parse(line); + String id = root.string("id"); + Fields input = root.object("input"); + UxidOptions.Builder builder = + UxidOptions.builder() + .size(Size.parse(input.string("size"))) + .compactTime(input.bool("compact")) + .letterCase(letterCase(input.string("case"))) + .time(input.integer("time")) + .rand(HexFormat.of().parseHex(input.string("rand"))) + .byteSource(TestSources.refusing()); + prefix(builder, input.nullableString("prefix")); + String expected = root.string("expected"); + input.done(); + root.done(); + + assertEquals(expected, Uxid.generate(builder.build()), id); + } + + /** A deterministic case: the id of {@code input.from} under the prefix, size and case. */ + static void deterministic(String line) { + Fields root = Fields.parse(line); + String id = root.string("id"); + Fields input = root.object("input"); + UxidOptions.Builder builder = + UxidOptions.builder() + .from(input.string("from")) + .size(Size.parse(input.string("size"))) + .letterCase(letterCase(input.string("case"))) + .byteSource(TestSources.refusing()); + prefix(builder, input.nullableString("prefix")); + String expected = root.string("expected"); + input.done(); + root.done(); + + assertEquals(expected, Uxid.generate(builder.build()), id); + } + + /** + * A decode case, run at both ends of the decoder clock window the corpus README gives for its + * compact cases: the first clock inclusive and the last before the end. + */ + static void decode(String line) { + Fields root = Fields.parse(line); + String id = root.string("id"); + String text = root.string("input"); + Fields answer = root.object("expected"); + Decoded expected = + new Decoded( + answer.nullableString("prefix"), + Size.parse(answer.string("size")), + answer.bool("compact"), + answer.bool("deterministic"), + answer.nullableInteger("time")); + answer.done(); + root.done(); + + for (long now : new long[] {ClockWindow.START, ClockWindow.END - 1}) { + DecodeOptions options = + DecodeOptions.builder() + .clock(Clock.fixed(Instant.ofEpochMilli(now), ZoneOffset.UTC)) + .build(); + DecodeResult.Decoded decoded = + assertInstanceOf(DecodeResult.Decoded.class, Uxid.decode(text, options), id); + assertEquals(expected.prefix(), decoded.prefix().orElse(null), id + " prefix at " + now); + assertEquals(expected.size(), decoded.size(), id + " size at " + now); + assertEquals(expected.compact(), decoded.compact(), id + " compact at " + now); + assertEquals( + expected.deterministic(), decoded.deterministic(), id + " deterministic at " + now); + assertEquals(expected.time(), timeOf(decoded.time()), id + " time at " + now); + } + } + + /** + * An invalid case: a string the decoder refuses with the reason, or generation options refused + * with the error, by name. + */ + static void invalid(String line) { + Fields root = Fields.parse(line); + String id = root.string("id"); + Fields input = root.object("input"); + String expectedError = root.string("expected_error"); + root.done(); + + if (input.has("decode")) { + String text = input.string("decode"); + input.done(); + DecodeResult.Invalid invalid = + assertInstanceOf(DecodeResult.Invalid.class, Uxid.decode(text), id); + assertEquals(expectedError, invalid.reason().wireName(), id); + return; + } + + Fields request = input.object("generate"); + input.done(); + UxidOptions.Builder builder = UxidOptions.builder().byteSource(TestSources.refusing()); + prefix(builder, request.optionalNullableString("prefix")); + String size = request.optionalString("size"); + if (size != null) { + builder.size(Size.parse(size)); + } + Boolean compact = request.optionalBool("compact"); + if (compact != null) { + builder.compactTime(compact); + } + String letterCase = request.optionalString("case"); + if (letterCase != null) { + builder.letterCase(letterCase(letterCase)); + } + Long time = request.optionalInteger("time"); + if (time != null) { + builder.time(time); + } + String rand = request.optionalString("rand"); + if (rand != null) { + builder.rand(HexFormat.of().parseHex(rand)); + } + Long randSize = request.optionalInteger("rand_size"); + if (randSize != null) { + builder.randSize(Math.toIntExact(randSize)); + } + String from = request.optionalString("from"); + if (from != null) { + builder.from(from); + } + Boolean monotonic = request.optionalBool("monotonic"); + request.done(); + + UxidOptions options = builder.build(); + UxidException refusal = + assertThrows( + UxidException.class, + () -> { + if (Boolean.TRUE.equals(monotonic)) { + new Monotonic(TestSources.refusingDraws()).generate(options); + } else { + Uxid.generate(options); + } + }, + id); + assertEquals(expectedError, refusal.code().wireName(), id); + } + + /** A monotonic case: one generator replayed with the case's draws, called once per time. */ + static void monotonic(String line) { + Fields root = Fields.parse(line); + String id = root.string("id"); + Fields input = root.object("input"); + boolean compact = input.bool("compact"); + String prefix = input.nullableString("prefix"); + Size size = Size.parse(input.string("size")); + List times = input.integers("times"); + List seeds = new ArrayList<>(List.of(input.integer("initial_seed"))); + List steps = input.integers("steps"); + Long overflowSeed = input.nullableInteger("overflow_seed"); + if (overflowSeed != null) { + seeds.add(overflowSeed); + } + List expected = root.strings("expected"); + input.done(); + root.done(); + + TestSources.Replay draws = new TestSources.Replay(seeds, steps); + Monotonic generator = new Monotonic(draws); + List ids = new ArrayList<>(); + for (long time : times) { + UxidOptions.Builder builder = + UxidOptions.builder() + .size(size) + .compactTime(compact) + .time(time) + .byteSource(TestSources.refusing()); + prefix(builder, prefix); + ids.add(generator.generate(builder.build())); + } + + assertEquals(expected, ids, id); + assertTrue(draws.drained(), id + ": every draw the case carries is drawn"); + } + + private record Decoded( + String prefix, Size size, boolean compact, boolean deterministic, Long time) {} + + private static Long timeOf(OptionalLong time) { + return time.isPresent() ? Long.valueOf(time.getAsLong()) : null; + } + + /** A null or empty prefix is no prefix, which is the builder's default. */ + private static void prefix(UxidOptions.Builder builder, String prefix) { + builder.prefix(Optional.ofNullable(prefix).orElse("")); + } + + private static LetterCase letterCase(String name) { + return switch (name) { + case "lower" -> LetterCase.LOWER; + case "upper" -> LetterCase.UPPER; + default -> throw new AssertionError("not a case: " + name); + }; + } +} diff --git a/src/test/java/systems/riddler/uxid/CorpusHashTest.java b/src/test/java/systems/riddler/uxid/CorpusHashTest.java new file mode 100644 index 0000000..39a789f --- /dev/null +++ b/src/test/java/systems/riddler/uxid/CorpusHashTest.java @@ -0,0 +1,71 @@ +package systems.riddler.uxid; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * The vendored corpus is the one its manifest and {@code conformance/SOURCE.json} describe. The + * hash is recomputed by the reference's rule (the corpus README, "The format" and "Running the + * cases"): {@code sha256:} and the lowercase hex SHA-256 of the case files' bytes concatenated in + * the order of the manifest's files; each {@code case_count} is the number of lines of its file. A + * mismatch means the vendored copy is not the one recorded; it is never repaired by rewriting a + * hash. + */ +class CorpusHashTest { + private static final List CASE_FILES = + List.of("encode.json", "deterministic.json", "decode.json", "invalid.json", "monotonic.json"); + private static final List KINDS = + List.of("encode", "deterministic", "decode", "invalid", "monotonic"); + + @Test // (sabotage: each file hashed twice -> the manifest hash assertion failed) + void theCaseFilesHashToTheManifestsCorpusHash() { + Corpus.Manifest manifest = Corpus.manifest(); + + assertEquals( + manifest.corpusHash(), + Corpus.hash(manifest.files().stream().map(Corpus.Entry::file).toList())); + } + + @Test // (sabotage: trailing empty line kept -> a case count assertion failed) + void eachCaseCountIsTheNumberOfLinesOfItsFile() { + for (Corpus.Entry entry : Corpus.manifest().files()) { + assertEquals(entry.caseCount(), Corpus.lines(entry.file()).size(), entry.file()); + } + } + + @Test // (sabotage: expected file order swapped -> the files list assertion failed) + void theManifestListsExactlyTheFilesTheRunnerRuns() { + Corpus.Manifest manifest = Corpus.manifest(); + + assertEquals(1, manifest.version()); + assertEquals(CASE_FILES, manifest.files().stream().map(Corpus.Entry::file).toList()); + assertEquals(KINDS, manifest.files().stream().map(Corpus.Entry::kind).toList()); + } + + @Test // (sabotage: SOURCE.json hash edited -> the recorded hash assertion failed) + void sourceJsonRecordsTheSameHashAndTheTagItCameFrom() { + Fields source = Fields.parse(Corpus.text("SOURCE.json")); + String repo = source.string("repo"); + String tag = source.string("tag"); + String sha = source.string("sha"); + String recorded = source.string("corpus_hash"); + source.done(); + + assertEquals("riddler/uxid-ex", repo); + assertTrue(tag.startsWith("v"), tag); + assertTrue(sha.matches("[0-9a-f]{40}"), sha); + assertEquals(Corpus.manifest().corpusHash(), recorded); + assertEquals(recorded, Corpus.hash(CASE_FILES)); + } + + @Test // (sabotage: a carriage return added to a case file -> the line ending assertion failed) + void theCaseFilesAreCheckedInWithNewlineLineEndings() { + for (String file : CASE_FILES) { + assertFalse(Corpus.text(file).contains("\r"), file); + } + } +} diff --git a/src/test/java/systems/riddler/uxid/CorpusRunnerTest.java b/src/test/java/systems/riddler/uxid/CorpusRunnerTest.java new file mode 100644 index 0000000..e4bb2ad --- /dev/null +++ b/src/test/java/systems/riddler/uxid/CorpusRunnerTest.java @@ -0,0 +1,67 @@ +package systems.riddler.uxid; + +import java.util.stream.Stream; +import org.junit.jupiter.api.Named; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Runs every case of every vendored case file: one parameterised test per file, one invocation per + * line, named by the case id. There is no skip list. + */ +class CorpusRunnerTest { + + @ParameterizedTest // (sabotage: case forced to lower -> upper encode cases failed) + @MethodSource("encodeCases") + void everyEncodeCaseGivesItsId(String line) { + CorpusCases.encode(line); + } + + @ParameterizedTest // (sabotage: from given an extra x -> every deterministic case failed) + @MethodSource("deterministicCases") + void everyDeterministicCaseGivesItsId(String line) { + CorpusCases.deterministic(line); + } + + @ParameterizedTest // (sabotage: compact flag negated -> decode cases failed) + @MethodSource("decodeCases") + void everyDecodeCaseReadsItsAnswer(String line) { + CorpusCases.decode(line); + } + + @ParameterizedTest // (sabotage: reason always empty -> other invalid cases failed) + @MethodSource("invalidCases") + void everyInvalidCaseIsRefusedWithItsError(String line) { + CorpusCases.invalid(line); + } + + @ParameterizedTest // (sabotage: call time plus one -> the monotonic cases failed) + @MethodSource("monotonicCases") + void everyMonotonicCaseGivesItsIds(String line) { + CorpusCases.monotonic(line); + } + + static Stream> encodeCases() { + return cases("encode.json"); + } + + static Stream> deterministicCases() { + return cases("deterministic.json"); + } + + static Stream> decodeCases() { + return cases("decode.json"); + } + + static Stream> invalidCases() { + return cases("invalid.json"); + } + + static Stream> monotonicCases() { + return cases("monotonic.json"); + } + + private static Stream> cases(String file) { + return Corpus.lines(file).stream().map(line -> Named.of(CorpusCases.id(line), line)); + } +} diff --git a/src/test/java/systems/riddler/uxid/CorpusStrictnessTest.java b/src/test/java/systems/riddler/uxid/CorpusStrictnessTest.java new file mode 100644 index 0000000..66a89ca --- /dev/null +++ b/src/test/java/systems/riddler/uxid/CorpusStrictnessTest.java @@ -0,0 +1,89 @@ +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.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.opentest4j.AssertionFailedError; + +/** The runner reads a case strictly, takes a null prefix as none and skips nothing. */ +class CorpusStrictnessTest { + private static final String ENCODE = + "{\"expected\":\"0000000000\",\"id\":\"encode/x\",\"input\":{\"case\":\"lower\"," + + "\"compact\":false,\"prefix\":null,\"rand\":\"\",\"size\":\"xs\",\"time\":0}}"; + + @Test // (sabotage: done() never fails -> the extra key was accepted) + void aKeyTheRunnerDoesNotKnowAtTheTopOfACaseFails() { + String line = ENCODE.replace("{\"expected\"", "{\"extra\":1,\"expected\""); + + AssertionError failure = assertThrows(AssertionError.class, () -> CorpusCases.encode(line)); + assertTrue(failure.getMessage().contains("extra"), failure.getMessage()); + } + + @Test // (sabotage: done() never fails -> the extra key was accepted) + void aKeyTheRunnerDoesNotKnowInACasesInputFails() { + String line = ENCODE.replace("\"case\":\"lower\"", "\"case\":\"lower\",\"zone\":\"utc\""); + + AssertionError failure = assertThrows(AssertionError.class, () -> CorpusCases.encode(line)); + assertTrue(failure.getMessage().contains("zone"), failure.getMessage()); + } + + @Test // (sabotage: done() never fails -> the extra option was accepted) + void aKeyTheRunnerDoesNotKnowInARefusedRequestFails() { + String line = + "{\"expected_error\":\"rand_size_too_large\",\"id\":\"invalid/x\",\"input\":{\"generate\":" + + "{\"prefix\":\"usr\",\"rand_size\":11,\"retries\":3,\"size\":\"xl\",\"time\":0}}}"; + + AssertionError failure = assertThrows(AssertionError.class, () -> CorpusCases.invalid(line)); + assertTrue(failure.getMessage().contains("retries"), failure.getMessage()); + } + + @Test // (sabotage: a missing key read as null -> the missing key assertion failed) + void aKeyMissingFromACaseFails() { + String line = ENCODE.replace("\"prefix\":null,", ""); + + AssertionError failure = assertThrows(AssertionError.class, () -> CorpusCases.encode(line)); + assertTrue(failure.getMessage().contains("has no prefix"), failure.getMessage()); + } + + @Test // (sabotage: null prefix read as the text null -> the no prefix assertion failed) + void aNullPrefixAndAnEmptyOneReadAsNoPrefix() { + assertDoesNotThrow(() -> CorpusCases.encode(ENCODE)); + assertDoesNotThrow(() -> CorpusCases.encode(ENCODE.replace("null", "\"\""))); + String line = ENCODE.replace("\"prefix\":null", "\"prefix\":\"usr\""); + + assertThrows(AssertionFailedError.class, () -> CorpusCases.encode(line)); + } + + @Test // (sabotage: answer not compared -> the wrong answer passed) + void aCaseWhoseAnswerIsWrongFailsInsteadOfBeingSkipped() { + String line = ENCODE.replace("\"expected\":\"0000000000\"", "\"expected\":\"0000000001\""); + + assertThrows(AssertionFailedError.class, () -> CorpusCases.encode(line)); + } + + @Test // (sabotage: duplicate key check disabled -> the duplicate assertion failed) + void theJsonReaderRefusesWhatTheCorpusNeverHolds() { + assertThrows(IllegalArgumentException.class, () -> Json.parse("{\"a\":1,\"a\":2}")); + assertThrows(IllegalArgumentException.class, () -> Json.parse("{\"a\":1.5}")); + assertThrows(IllegalArgumentException.class, () -> Json.parse("[1,]")); + assertThrows(IllegalArgumentException.class, () -> Json.parse("{\"a\":1} x")); + assertThrows(IllegalArgumentException.class, () -> Json.parse("\"open")); + } + + @Test // (sabotage: unicode escape read as ? -> the value assertion failed) + void theJsonReaderReadsEveryValueShapeTheCorpusUses() { + Object value = + Json.parse("{\"s\":\"caf\\u00e9\\n\",\"n\":-7,\"b\":[true,false,null],\"o\":{}}"); + + assertEquals( + Map.of("s", "café\n", "n", -7L, "b", Json.parse("[true,false,null]"), "o", Map.of()), + value); + assertInstanceOf(List.class, Json.parse("[]")); + } +} diff --git a/src/test/java/systems/riddler/uxid/DecodeCorpusTest.java b/src/test/java/systems/riddler/uxid/DecodeCorpusTest.java deleted file mode 100644 index 4659499..0000000 --- a/src/test/java/systems/riddler/uxid/DecodeCorpusTest.java +++ /dev/null @@ -1,98 +0,0 @@ -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/DeterministicCorpusTest.java b/src/test/java/systems/riddler/uxid/DeterministicCorpusTest.java deleted file mode 100644 index 28e6bcf..0000000 --- a/src/test/java/systems/riddler/uxid/DeterministicCorpusTest.java +++ /dev/null @@ -1,67 +0,0 @@ -package systems.riddler.uxid; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.util.HexFormat; -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/deterministic.json} and the deterministic refusal of {@code - * conformance/invalid.json}), each named by its case id. The full corpus runner replaces this - * hand-picked subset. - */ -class DeterministicCorpusTest { - - @ParameterizedTest(name = "{0}") // (sabotage: count byte left out -> every case failed) - @CsvSource( - nullValues = "null", - value = { - "deterministic/golden-usr-xl, usr, alice@example.com, xl, lower," - + " usr_zcvt7epac0t1ebcsjfyf7cwz25", - "deterministic/golden-cus-s, cus, acme-corp, s, lower, cus_zt9vnyfd85bff0", - "deterministic/golden-no-prefix-m, null, alice@example.com, m, lower, zqz4cwzgyw8ks97869", - "deterministic/usr-xs, usr, alice@example.com, xs, lower, usr_z1yhw20vag", - "deterministic/usr-s, usr, alice@example.com, s, lower, usr_z5rtsrkhx2h4dr", - "deterministic/usr-m, usr, alice@example.com, m, lower, usr_zt8naccefxhwnrmscd", - "deterministic/usr-l, usr, alice@example.com, l, lower, usr_zwbz6myvgz7ehe3nceap5n", - "deterministic/usr-xl, usr, alice@example.com, xl, lower, usr_zcvt7epac0t1ebcsjfyf7cwz25", - "deterministic/empty-prefix-xl, '', alice@example.com, xl, lower," - + " z4krgegf6drn563pvqhya4tsx9", - "deterministic/no-prefix-xl, null, alice@example.com, xl, lower," - + " z4krgegf6drn563pvqhya4tsx9", - "deterministic/usr-xl-upper, usr, alice@example.com, xl, upper," - + " usr_ZCVT7EPAC0T1EBCSJFYF7CWZ25", - "deterministic/non-ascii-from-m, cus, café, m, lower, cus_zfsx9se75671f9kdjv", - }) - void aDeterministicCaseGivesTheCorpusId( - String id, String prefix, String from, String size, String letterCase, String expected) { - UxidOptions.Builder builder = - UxidOptions.builder() - .from(from) - .size(Size.parse(size)) - .letterCase(letterCase.equals("upper") ? LetterCase.UPPER : LetterCase.LOWER) - .byteSource(TestSources.refusing()); - if (prefix != null) { - builder.prefix(prefix); - } - assertEquals(expected, Uxid.generate(builder.build()), id); - } - - @ParameterizedTest(name = "{0}") // (sabotage: rand_with_from check removed -> nothing thrown) - @CsvSource("invalid/generate-rand-with-from, usr, alice@example.com, 00010203040506070809") - void fixedBytesWithADeterministicBodyAreRefused( - String id, String prefix, String from, String rand) { - UxidOptions options = - UxidOptions.builder() - .prefix(prefix) - .from(from) - .rand(HexFormat.of().parseHex(rand)) - .byteSource(TestSources.refusing()) - .build(); - UxidException refusal = assertThrows(UxidException.class, () -> Uxid.generate(options), id); - assertEquals(ErrorCode.RAND_WITH_FROM, refusal.code(), id); - } -} diff --git a/src/test/java/systems/riddler/uxid/Fields.java b/src/test/java/systems/riddler/uxid/Fields.java new file mode 100644 index 0000000..4540c1a --- /dev/null +++ b/src/test/java/systems/riddler/uxid/Fields.java @@ -0,0 +1,167 @@ +package systems.riddler.uxid; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * A JSON object read strictly: every member the reader takes is marked, a member of the wrong type + * fails, and {@link #done()} fails when the object still holds a member nobody took, so a case + * carrying a key the runner does not know is a failure and a future corpus cannot be half-read. + */ +final class Fields { + private final String where; + private final Map members; + private final Set taken = new HashSet<>(); + + private Fields(String where, Map members) { + this.where = where; + this.members = members; + } + + /** Reads {@code text} as one JSON object. */ + static Fields parse(String text) { + return of("the case", Json.parse(text)); + } + + @SuppressWarnings("unchecked") + private static Fields of(String where, Object value) { + if (!(value instanceof Map)) { + throw new AssertionError(where + " is not an object"); + } + return new Fields(where, (Map) value); + } + + boolean has(String key) { + return members.containsKey(key); + } + + String string(String key) { + Object value = required(key); + if (!(value instanceof String text)) { + throw wrongType(key, "a string"); + } + return text; + } + + /** A required member that is a string or {@code null}; {@code null} comes back as null. */ + String nullableString(String key) { + Object value = required(key); + if (value != null && !(value instanceof String)) { + throw wrongType(key, "a string or null"); + } + return (String) value; + } + + /** An optional member that is a string or {@code null}; absent and {@code null} are null. */ + String optionalNullableString(String key) { + return has(key) ? nullableString(key) : null; + } + + /** An optional string member; absent is null. */ + String optionalString(String key) { + return has(key) ? string(key) : null; + } + + boolean bool(String key) { + Object value = required(key); + if (!(value instanceof Boolean flag)) { + throw wrongType(key, "a boolean"); + } + return flag; + } + + /** An optional boolean member; absent is null. */ + Boolean optionalBool(String key) { + return has(key) ? Boolean.valueOf(bool(key)) : null; + } + + long integer(String key) { + Object value = required(key); + if (!(value instanceof Long number)) { + throw wrongType(key, "an integer"); + } + return number; + } + + /** A required member that is an integer or {@code null}; {@code null} comes back as null. */ + Long nullableInteger(String key) { + Object value = required(key); + if (value != null && !(value instanceof Long)) { + throw wrongType(key, "an integer or null"); + } + return (Long) value; + } + + /** An optional integer member; absent is null. */ + Long optionalInteger(String key) { + return has(key) ? Long.valueOf(integer(key)) : null; + } + + Fields object(String key) { + return of(where + "." + key, required(key)); + } + + List integers(String key) { + List numbers = new ArrayList<>(); + for (Object item : list(key)) { + if (!(item instanceof Long number)) { + throw wrongType(key, "a list of integers"); + } + numbers.add(number); + } + return numbers; + } + + List strings(String key) { + List texts = new ArrayList<>(); + for (Object item : list(key)) { + if (!(item instanceof String text)) { + throw wrongType(key, "a list of strings"); + } + texts.add(text); + } + return texts; + } + + /** The members of a list of objects, each read strictly. */ + List objects(String key) { + List items = new ArrayList<>(); + for (Object item : list(key)) { + items.add(of(where + "." + key + "[]", item)); + } + return items; + } + + /** Fails when the object holds a member no reader took. */ + void done() { + Set unknown = new HashSet<>(members.keySet()); + unknown.removeAll(taken); + if (!unknown.isEmpty()) { + throw new AssertionError(where + " carries a key the runner does not know: " + unknown); + } + } + + @SuppressWarnings("unchecked") + private List list(String key) { + Object value = required(key); + if (!(value instanceof List)) { + throw wrongType(key, "a list"); + } + return (List) value; + } + + private Object required(String key) { + if (!members.containsKey(key)) { + throw new AssertionError(where + " has no " + key); + } + taken.add(key); + return members.get(key); + } + + private AssertionError wrongType(String key, String expected) { + return new AssertionError(where + "." + key + " is not " + expected); + } +} diff --git a/src/test/java/systems/riddler/uxid/GenerateCorpusTest.java b/src/test/java/systems/riddler/uxid/GenerateCorpusTest.java deleted file mode 100644 index 23ac859..0000000 --- a/src/test/java/systems/riddler/uxid/GenerateCorpusTest.java +++ /dev/null @@ -1,128 +0,0 @@ -package systems.riddler.uxid; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.util.HexFormat; -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/encode.json} and - * the generate refusals of {@code conformance/invalid.json}), each named by its case id. The full - * corpus runner replaces this hand-picked subset. - */ -class GenerateCorpusTest { - - @ParameterizedTest(name = "{0}") // (sabotage: short group padded -> xs, s, m and l cases failed) - @CsvSource( - nullValues = "null", - value = { - "encode/xs-standard-lower, usr, xs, false, lower, '', 1700000000000, usr_01hf7yat00", - "encode/xs-compact-upper, usr, xs, true, upper, 01, 1700000000000, usr_HF7YAT0001", - "encode/s-standard-lower, usr, s, false, lower, 0123, 1700000000000, usr_01hf7yat0004h1", - "encode/s-compact-lower, usr, s, true, lower, 012345, 1700000000000, usr_hf7yat0004hm5", - "encode/m-standard-upper, usr, m, false, upper, 0123456789, 1700000000000," - + " usr_01HF7YAT0004HMASW9", - "encode/m-compact-lower, usr, m, true, lower, 0123456789ab, 1700000000000," - + " usr_hf7yat0004hmasw9n3", - "encode/l-standard-lower, usr, l, false, lower, 0123456789abcd, 1700000000000," - + " usr_01hf7yat0004hmasw9nf61", - "encode/l-compact-upper, usr, l, true, upper, 0123456789abcdef, 1700000000000," - + " usr_HF7YAT0004HMASW9NF6YF", - "encode/xl-standard-lower, usr, xl, false, lower, 0123456789abcdef0123, 1700000000000," - + " usr_01hf7yat0004hmasw9nf6yy093", - "encode/xl-compact-lower, usr, xl, true, lower, 0123456789abcdef0123, 1700000000000," - + " usr_hf7yat0004hmasw9nf6yy093", - "encode/xs-compact-rand-all-ones, cus, xs, true, lower, ff, 1700000000000, cus_hf7yat00z7", - "encode/s-standard-rand-all-ones, cus, s, false, lower, ffff, 1700000000000," - + " cus_01hf7yat00zzz1", - "encode/l-compact-rand-all-ones, cus, l, true, lower, ffffffffffffffff, 1700000000000," - + " cus_hf7yat00zzzzzzzzzzzzf", - "encode/xl-standard-rand-all-ones, cus, xl, false, lower, ffffffffffffffffffff," - + " 1700000000000, cus_01hf7yat00zzzzzzzzzzzzzzzz", - "encode/xs-standard-time-zero, null, xs, false, lower, '', 0, 0000000000", - "encode/xs-standard-time-max, null, xs, false, lower, '', 281474976710655, 7zzzzzzzzz", - "encode/s-compact-guard-last-valid, usr, s, true, lower, 012345, 1065151889407," - + " usr_yzzzzzzz04hm5", - "encode/xl-standard-no-prefix, null, xl, false, lower, 0123456789abcdef0123," - + " 1700000000000, 01hf7yat0004hmasw9nf6yy093", - "encode/xl-standard-empty-prefix, '', xl, false, lower, 0123456789abcdef0123," - + " 1700000000000, 01hf7yat0004hmasw9nf6yy093", - "encode/s-standard-prefix-with-delimiter, a_b, s, false, lower, 0123, 1700000000000," - + " a_b_01hf7yat0004h1", - }) - void anEncodeCaseGivesTheCorpusId( - String id, - String prefix, - String size, - boolean compact, - String letterCase, - String rand, - long time, - String expected) { - UxidOptions.Builder builder = - UxidOptions.builder() - .size(Size.parse(size)) - .compactTime(compact) - .letterCase(letterCase.equals("upper") ? LetterCase.UPPER : LetterCase.LOWER) - .rand(HexFormat.of().parseHex(rand)) - .time(time) - .byteSource(TestSources.refusing()); - if (prefix != null) { - builder.prefix(prefix); - } - assertEquals(expected, Uxid.generate(builder.build()), id); - } - - @ParameterizedTest(name = "{0}") // (sabotage: guard compares to 32 -> both cases threw nothing) - @CsvSource({ - "invalid/generate-compact-time-at-guard, 2164663517184", - "invalid/generate-compact-time-at-guard-previous-window, 1065151889408", - }) - void aCompactTimeAtTheMarkerIsRefused(String id, long time) { - UxidOptions options = - UxidOptions.builder() - .prefix("usr") - .size(Size.S) - .compactTime(true) - .rand(HexFormat.of().parseHex("010203")) - .time(time) - .build(); - UxidException refusal = assertThrows(UxidException.class, () -> Uxid.generate(options), id); - assertEquals(ErrorCode.COMPACT_TIME_OUT_OF_RANGE, refusal.code(), id); - } - - @ParameterizedTest(name = "{0}") // (sabotage: width check never fires -> all three threw nothing) - @CsvSource({ - "invalid/generate-rand-too-wide, s, 010203", - "invalid/generate-rand-too-narrow, xl, 010203040506070809", - "invalid/generate-rand-on-xs-standard, xs, 01", - }) - void fixedBytesOfTheWrongWidthAreRefused(String id, String size, String rand) { - UxidOptions options = - UxidOptions.builder() - .prefix("usr") - .size(Size.parse(size)) - .rand(HexFormat.of().parseHex(rand)) - .time(1_700_000_000_000L) - .build(); - UxidException refusal = assertThrows(UxidException.class, () -> Uxid.generate(options), id); - assertEquals(ErrorCode.RAND_WIDTH_MISMATCH, refusal.code(), id); - } - - @ParameterizedTest(name = "{0}") // (sabotage: cap raised to 11 -> the refusing source was called) - @CsvSource("invalid/generate-rand-size-11, 11") - void aRandomWidthAboveTenIsRefused(String id, int randSize) { - UxidOptions options = - UxidOptions.builder() - .prefix("usr") - .size(Size.XL) - .randSize(randSize) - .time(1_700_000_000_000L) - .byteSource(TestSources.refusing()) - .build(); - UxidException refusal = assertThrows(UxidException.class, () -> Uxid.generate(options), id); - assertEquals(ErrorCode.RAND_SIZE_TOO_LARGE, refusal.code(), id); - } -} diff --git a/src/test/java/systems/riddler/uxid/Json.java b/src/test/java/systems/riddler/uxid/Json.java new file mode 100644 index 0000000..c5cd12c --- /dev/null +++ b/src/test/java/systems/riddler/uxid/Json.java @@ -0,0 +1,182 @@ +package systems.riddler.uxid; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * A small strict JSON reader for the conformance corpus, so the tests need no dependency. It reads + * objects as {@link Map}, arrays as {@link List}, strings, booleans, {@code null} and integers (as + * {@link Long}). It refuses what the corpus never holds: a number with a fraction or an exponent, a + * duplicate key, a trailing comma and anything after the value. + */ +final class Json { + private final String text; + private int pos; + + private Json(String text) { + this.text = text; + } + + /** Reads one JSON value that fills {@code text}, or throws {@link IllegalArgumentException}. */ + static Object parse(String text) { + Json reader = new Json(text); + Object value = reader.value(); + reader.whitespace(); + if (reader.pos != text.length()) { + throw reader.fail("text after the value"); + } + return value; + } + + private Object value() { + whitespace(); + if (pos >= text.length()) { + throw fail("no value"); + } + char c = text.charAt(pos); + return switch (c) { + case '{' -> object(); + case '[' -> array(); + case '"' -> string(); + case 't' -> word("true", true); + case 'f' -> word("false", false); + case 'n' -> word("null", null); + default -> number(); + }; + } + + private Map object() { + Map members = new LinkedHashMap<>(); + pos++; + if (peek('}')) { + pos++; + return members; + } + while (true) { + if (!peek('"')) { + throw fail("an object key"); + } + String key = string(); + expect(':'); + if (members.containsKey(key)) { + throw fail("duplicate key " + key); + } + members.put(key, value()); + if (peek(',')) { + pos++; + } else { + expect('}'); + return members; + } + } + } + + private List array() { + List items = new ArrayList<>(); + pos++; + if (peek(']')) { + pos++; + return items; + } + while (true) { + items.add(value()); + if (peek(',')) { + pos++; + } else { + expect(']'); + return items; + } + } + } + + private String string() { + StringBuilder out = new StringBuilder(); + pos++; + while (true) { + if (pos >= text.length()) { + throw fail("an unterminated string"); + } + char c = text.charAt(pos++); + if (c == '"') { + return out.toString(); + } + if (c < 0x20) { + throw fail("a control character in a string"); + } + if (c != '\\') { + out.append(c); + continue; + } + if (pos >= text.length()) { + throw fail("an unterminated escape"); + } + char escaped = text.charAt(pos++); + switch (escaped) { + case '"', '\\', '/' -> out.append(escaped); + case 'b' -> out.append('\b'); + case 'f' -> out.append('\f'); + case 'n' -> out.append('\n'); + case 'r' -> out.append('\r'); + case 't' -> out.append('\t'); + case 'u' -> { + if (pos + 4 > text.length()) { + throw fail("a short unicode escape"); + } + out.append((char) Integer.parseInt(text.substring(pos, pos + 4), 16)); + pos += 4; + } + default -> throw fail("an unknown escape"); + } + } + } + + private Long number() { + int start = pos; + if (peek('-')) { + pos++; + } + int digits = pos; + while (pos < text.length() && text.charAt(pos) >= '0' && text.charAt(pos) <= '9') { + pos++; + } + if (pos == digits) { + throw fail("a value"); + } + if (pos < text.length() && ".eE".indexOf(text.charAt(pos)) >= 0) { + throw fail("a number that is not an integer"); + } + return Long.valueOf(text.substring(start, pos)); + } + + private Object word(String word, Object value) { + if (!text.startsWith(word, pos)) { + throw fail("a value"); + } + pos += word.length(); + return value; + } + + private void whitespace() { + while (pos < text.length() && " \t\r\n".indexOf(text.charAt(pos)) >= 0) { + pos++; + } + } + + private boolean peek(char c) { + whitespace(); + return pos < text.length() && text.charAt(pos) == c; + } + + private void expect(char c) { + if (!peek(c)) { + throw fail("'" + c + "'"); + } + pos++; + } + + private IllegalArgumentException fail(String what) { + return new IllegalArgumentException("bad JSON at offset " + pos + ": expected " + what); + } +} diff --git a/src/test/java/systems/riddler/uxid/MonotonicCorpusTest.java b/src/test/java/systems/riddler/uxid/MonotonicCorpusTest.java deleted file mode 100644 index 78aaeac..0000000 --- a/src/test/java/systems/riddler/uxid/MonotonicCorpusTest.java +++ /dev/null @@ -1,117 +0,0 @@ -package systems.riddler.uxid; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HexFormat; -import java.util.List; -import org.junit.jupiter.api.Test; -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/monotonic.json} - * and the monotonic refusals of {@code conformance/invalid.json}), each named by its case id. A - * list is written space-separated. The full corpus runner replaces this hand-picked copy. - */ -class MonotonicCorpusTest { - - @ParameterizedTest(name = "{0}") // (sabotage: overflow keeps T -> the two overflow cases failed) - @CsvSource( - nullValues = "null", - value = { - "monotonic/xl-same-millisecond, usr, xl, false, 123456789, null, 1 1099511627776 42," - + " 1700000000000 1700000000000 1700000000000 1700000000000," - + " usr_01hf7yat0000000000003nqk8n usr_01hf7yat0000000000003nqk8p" - + " usr_01hf7yat0000000001003nqk8p usr_01hf7yat0000000001003nqka0", - "monotonic/m-clock-moves-back, usr, m, false, 1000, null, 5 1048576 7," - + " 1700000000000 1699999999999 1699999999990 1700000000000," - + " usr_01hf7yat00000000z8 usr_01hf7yat00000000zd usr_01hf7yat00000100zd" - + " usr_01hf7yat00000100zm", - "monotonic/s-overflow-bumps-time, usr, s, false, 65000, 1234, 200 250 100 7 3," - + " 1700000000000 1700000000000 1700000000000 1700000000000 1700000000000" - + " 1700000000001," - + " usr_01hf7yat00zqm0 usr_01hf7yat00ztr0 usr_01hf7yat00zyn0 usr_01hf7yat010k90" - + " usr_01hf7yat010kc1 usr_01hf7yat010ke0", - "monotonic/xs-compact-overflow-clock-back, usr, xs, true, 250, 9, 5 16," - + " 1700000000000 1700000000000 1699999999999," - + " usr_hf7yat00z2 usr_hf7yat00z7 usr_hf7yat0111", - "monotonic/l-compact-single, usr, l, true, 0, null, '', 1700000000000," - + " usr_hf7yat000000000000000", - "monotonic/s-compact-no-prefix-largest-step, null, s, true, 0, null, 4096," - + " 1700000000000 1700000000000, hf7yat0000000 hf7yat0000800", - }) - void aMonotonicCaseGivesTheCorpusIds( - String id, - String prefix, - String size, - boolean compact, - long initialSeed, - Long overflowSeed, - String steps, - String times, - String expected) { - List seeds = new ArrayList<>(List.of(initialSeed)); - if (overflowSeed != null) { - seeds.add(overflowSeed); - } - TestSources.Replay draws = new TestSources.Replay(seeds, longs(steps)); - Monotonic generator = new Monotonic(draws); - - List ids = new ArrayList<>(); - for (long time : longs(times)) { - UxidOptions.Builder builder = - UxidOptions.builder() - .size(Size.parse(size)) - .compactTime(compact) - .time(time) - .byteSource(TestSources.refusing()); - if (prefix != null) { - builder.prefix(prefix); - } - ids.add(generator.generate(builder.build())); - } - - assertEquals(Arrays.asList(expected.split(" ", -1)), ids, id); - assertTrue(draws.drained(), id + ": every recorded draw is drawn"); - } - - @Test // (sabotage: from check skipped with a prefix -> assertThrows saw draws) - void fromWithAMonotonicGeneratorIsRefused() { - UxidOptions options = UxidOptions.builder().prefix("usr").from("alice@example.com").build(); - Monotonic generator = new Monotonic(TestSources.refusingDraws()); - - UxidException refusal = assertThrows(UxidException.class, () -> generator.generate(options)); - assertEquals( - ErrorCode.FROM_WITH_MONOTONIC, refusal.code(), "invalid/generate-from-with-monotonic"); - } - - @Test // (sabotage: rand check skipped with a prefix -> assertThrows saw draws) - void fixedRandomBytesWithAMonotonicGeneratorAreRefused() { - UxidOptions options = - UxidOptions.builder() - .prefix("usr") - .rand(HexFormat.of().parseHex("0102")) - .size(Size.S) - .time(1_700_000_000_000L) - .build(); - Monotonic generator = new Monotonic(TestSources.refusingDraws()); - - UxidException refusal = assertThrows(UxidException.class, () -> generator.generate(options)); - assertEquals( - ErrorCode.RAND_WITH_MONOTONIC, refusal.code(), "invalid/generate-rand-with-monotonic"); - } - - private static List longs(String list) { - List values = new ArrayList<>(); - for (String value : list.trim().split(" ", -1)) { - if (!value.isEmpty()) { - values.add(Long.parseLong(value)); - } - } - return values; - } -}