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; - } -}