Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4aa85a503 |
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.1 - 2026-09-01
|
||||||
|
|
||||||
|
- Removed the external randomness-service client and its network permission; Random Tools is now entirely local-only.
|
||||||
|
- Replaced the remote workspace with bounded local coin, card, sequence, calendar-date, decimal-fraction and spherical-coordinate draws.
|
||||||
|
- Added weekday and no-replacement date controls, recipe metadata and expanded cross-browser coverage.
|
||||||
|
- Hardened identifier and inclusive-integer bounds, added normalized custom-list identity, and limited screen-reader announcements to concise result status.
|
||||||
|
|
||||||
## 0.1.0 - 2026-09-01
|
## 0.1.0 - 2026-09-01
|
||||||
|
|
||||||
- Added the initial local-first Random Tools workbench.
|
- Added the initial local-first Random Tools workbench.
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ Random Tools is a standalone local-first application in the [add·ideas Toolbox]
|
|||||||
- Explicitly non-cryptographic, seeded deterministic generation with reproduction metadata
|
- Explicitly non-cryptographic, seeded deterministic generation with reproduction metadata
|
||||||
- Dice expressions, sampling without replacement, Fisher–Yates shuffling and passphrases
|
- Dice expressions, sampling without replacement, Fisher–Yates shuffling and passphrases
|
||||||
- UUIDv4, UUIDv7 and random ULID generation
|
- UUIDv4, UUIDv7 and random ULID generation
|
||||||
- Optional RANDOM.ORG integer requests only after per-session consent
|
- Local draws for coin flips, shuffled card deals and unique integer sequences
|
||||||
|
- Random calendar dates with weekday and uniqueness controls, exact decimal fractions, and equal-area spherical coordinates
|
||||||
|
|
||||||
Secure local generation never falls back to the seeded or remote source. Deterministic results are reproducible but not suitable for secrets. The RANDOM.ORG workspace is the sole network-capable feature: it is not the default, requires an explicit consent checkbox, omits credentials/referrer and may still be unavailable because of browser CORS policy. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
|
All functionality runs locally. Secure generation never falls back to the seeded source, and deterministic results are reproducible but not suitable for secrets. Recipes for custom passphrases identify the normalized list by count and SHA-256 but do not embed it, so reproduction requires the same custom input. The browser CSPRNG is not physical entropy, and no operation is presented as certified for regulated drawings or gambling. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ npm run test:browser
|
|||||||
|
|
||||||
## Release
|
## Release
|
||||||
|
|
||||||
`npm run release:artifact` creates a deterministic `release/rand-tools-0.1.0.zip` and checksum sidecar.
|
`npm run release:artifact` creates a deterministic `release/rand-tools-0.1.1.zip` and checksum sidecar.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Corresponding source
|
# Corresponding source
|
||||||
|
|
||||||
The corresponding source for Random Tools 0.1.0 is available at:
|
The corresponding source for Random Tools 0.1.1 is available at:
|
||||||
|
|
||||||
https://git.add-ideas.de/lotobo/rand-tools/src/tag/v0.1.0
|
https://git.add-ideas.de/lotobo/rand-tools/src/tag/v0.1.1
|
||||||
|
|
||||||
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
|
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Third-party notices
|
# Third-party notices
|
||||||
|
|
||||||
Random Tools 0.1.0 directly depends on these runtime packages:
|
Random Tools 0.1.1 directly depends on these runtime packages:
|
||||||
|
|
||||||
| Package | Pinned version | Declared licence |
|
| Package | Pinned version | Declared licence |
|
||||||
| -------------------------------- | -------------: | ---------------- |
|
| -------------------------------- | -------------: | ---------------- |
|
||||||
@@ -10,6 +10,4 @@ Random Tools 0.1.0 directly depends on these runtime packages:
|
|||||||
| `react` | 19.2.8 | MIT |
|
| `react` | 19.2.8 | MIT |
|
||||||
| `react-dom` | 19.2.8 | MIT |
|
| `react-dom` | 19.2.8 | MIT |
|
||||||
|
|
||||||
The optional RANDOM.ORG feature calls a public HTTP API after explicit user consent; it does not embed or redistribute RANDOM.ORG code or data. Use of that service remains subject to its operator's terms and availability.
|
|
||||||
|
|
||||||
This table covers direct production dependencies, not transitive packages or development tooling. During a release build, the exact non-development dependency tree and discovered licence texts are generated from the lockfile into `LICENSES/npm-runtime-licenses.txt` and included in the release ZIP. Copyright and licence terms remain with their respective authors.
|
This table covers direct production dependencies, not transitive packages or development tooling. During a release build, the exact non-development dependency tree and discovered licence texts are generated from the lockfile into `LICENSES/npm-runtime-licenses.txt` and included in the release ZIP. Copyright and licence terms remain with their respective authors.
|
||||||
|
|||||||
@@ -2,6 +2,6 @@
|
|||||||
|
|
||||||
Random Tools is a static React/Vite application wrapped in the shared Toolbox shell. `random/source.ts` exposes one common interface over two deliberately distinct local sources: browser WebCrypto with rejection-sampled integers, and the versioned `toolbox-helpers` xoshiro128** seeded generator. Secure mode never falls back to deterministic mode.
|
Random Tools is a static React/Vite application wrapped in the shared Toolbox shell. `random/source.ts` exposes one common interface over two deliberately distinct local sources: browser WebCrypto with rejection-sampled integers, and the versioned `toolbox-helpers` xoshiro128** seeded generator. Secure mode never falls back to deterministic mode.
|
||||||
|
|
||||||
`random/generators.ts` builds bounded numbers, strings, identifiers, dice, samples, shuffles, passphrases and normal samples on that interface. Each successful local result includes source identity, parameters and deterministic state where applicable. Operations are synchronous and bounded, so version 0.1 creates no worker.
|
`random/generators.ts` builds bounded numbers, strings, identifiers, dice, samples, shuffles, passphrases and normal samples on that interface. `random/draws.ts` adds local binary coin flips, standard playing-card deals, shuffled inclusive integer sequences, Gregorian calendar-date sampling, exact decimal-digit fractions and equal-area points on a spherical surface model. Date sampling can filter weekdays and use a partial Fisher–Yates mapping for selection without replacement; it does not allocate every date in a large range.
|
||||||
|
|
||||||
`random/remote.ts` is a separate, explicitly selected RANDOM.ORG integer client. Requests are validated, serialised, credentialless, no-referrer, no-store and subject to a 120-second abort timeout; responses are checked against the requested count/range. This is the only runtime network path and the production CSP permits only that origin in addition to self.
|
Each successful result includes source identity, parameters and deterministic state where applicable. Most operations are synchronous and bounded; custom word-list identity uses the asynchronous browser Web Crypto digest API without a worker. Custom lists are trimmed, emptied lines are dropped and uniqueness is checked once before both selection and metadata are derived. The application contains no third-party network client; its CSP limits connections to its own origin for loading the local application shell.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Privacy and security
|
# Privacy and security
|
||||||
|
|
||||||
Local WebCrypto is the default. It stays in the browser, has no remote fallback, and is the only mode intended for secrets. Seeded xoshiro128** output is reproducible and explicitly non-cryptographic; anyone with the seed/state/recipe can reproduce or predict it. The entropy estimate for a passphrase is a simple uniform-choice model, not a password-strength audit.
|
Local Web Crypto is the default. It stays in the browser, has no network or seeded fallback, and is the only mode intended for secrets. Seeded xoshiro128** output is reproducible and explicitly non-cryptographic; anyone with the seed/state/recipe can reproduce or predict it. The entropy estimate for a passphrase is a simple uniform-choice model, not a password-strength audit.
|
||||||
|
|
||||||
The RANDOM.ORG workspace is an opt-in exception to local processing. Only after checking consent and pressing the request button does the browser send the requested count and range to `https://www.random.org`; RANDOM.ORG and network intermediaries can observe the request and the user's IP address. Credentials and referrer are omitted. Browser CORS policy or service limits may prevent the request. No other operation substitutes this source automatically.
|
There is no third-party request path, telemetry, analytics, account, geolocation lookup or persistence. Coordinates are generated as mathematical samples and are not derived from the device location. Results and seeds remain in page memory unless copied or downloaded. A custom passphrase recipe contains the normalized list count and a versioned SHA-256 identity, not the list contents; the same normalized input is therefore still required for reproduction.
|
||||||
|
|
||||||
Counts, ranges, alphabets, list sizes and generated byte counts have explicit limits before large allocations. Results and seeds remain in page memory unless copied or downloaded. The app has no telemetry, analytics, account or persistence and is not certified for regulated drawings or gambling.
|
Counts, ranges, alphabets, list sizes and generated byte counts have explicit limits before large allocations. Web Crypto is a browser-provided CSPRNG, not a physical randomness source. The app is not certified for regulated drawings or gambling.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "rand-tools",
|
"name": "rand-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rand-tools",
|
"name": "rand-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3",
|
"@add-ideas/toolbox-contract": "0.2.3",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rand-tools",
|
"name": "rand-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"description": "Generate secure or reproducible random values locally in the browser.",
|
"description": "Generate secure or reproducible random values locally in the browser.",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"author": "Albrecht Degering",
|
"author": "Albrecht Degering",
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.1 - 2026-09-01
|
||||||
|
|
||||||
|
- Removed the external randomness-service client and its network permission; Random Tools is now entirely local-only.
|
||||||
|
- Replaced the remote workspace with bounded local coin, card, sequence, calendar-date, decimal-fraction and spherical-coordinate draws.
|
||||||
|
- Added weekday and no-replacement date controls, recipe metadata and expanded cross-browser coverage.
|
||||||
|
- Hardened identifier and inclusive-integer bounds, added normalized custom-list identity, and limited screen-reader announcements to concise result status.
|
||||||
|
|
||||||
## 0.1.0 - 2026-09-01
|
## 0.1.0 - 2026-09-01
|
||||||
|
|
||||||
- Added the initial local-first Random Tools workbench.
|
- Added the initial local-first Random Tools workbench.
|
||||||
|
|||||||
+4
-3
@@ -10,9 +10,10 @@ Random Tools is a standalone local-first application in the [add·ideas Toolbox]
|
|||||||
- Explicitly non-cryptographic, seeded deterministic generation with reproduction metadata
|
- Explicitly non-cryptographic, seeded deterministic generation with reproduction metadata
|
||||||
- Dice expressions, sampling without replacement, Fisher–Yates shuffling and passphrases
|
- Dice expressions, sampling without replacement, Fisher–Yates shuffling and passphrases
|
||||||
- UUIDv4, UUIDv7 and random ULID generation
|
- UUIDv4, UUIDv7 and random ULID generation
|
||||||
- Optional RANDOM.ORG integer requests only after per-session consent
|
- Local draws for coin flips, shuffled card deals and unique integer sequences
|
||||||
|
- Random calendar dates with weekday and uniqueness controls, exact decimal fractions, and equal-area spherical coordinates
|
||||||
|
|
||||||
Secure local generation never falls back to the seeded or remote source. Deterministic results are reproducible but not suitable for secrets. The RANDOM.ORG workspace is the sole network-capable feature: it is not the default, requires an explicit consent checkbox, omits credentials/referrer and may still be unavailable because of browser CORS policy. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
|
All functionality runs locally. Secure generation never falls back to the seeded source, and deterministic results are reproducible but not suitable for secrets. Recipes for custom passphrases identify the normalized list by count and SHA-256 but do not embed it, so reproduction requires the same custom input. The browser CSPRNG is not physical entropy, and no operation is presented as certified for regulated drawings or gambling. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ npm run test:browser
|
|||||||
|
|
||||||
## Release
|
## Release
|
||||||
|
|
||||||
`npm run release:artifact` creates a deterministic `release/rand-tools-0.1.0.zip` and checksum sidecar.
|
`npm run release:artifact` creates a deterministic `release/rand-tools-0.1.1.zip` and checksum sidecar.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
# Corresponding source
|
# Corresponding source
|
||||||
|
|
||||||
The corresponding source for Random Tools 0.1.0 is available at:
|
The corresponding source for Random Tools 0.1.1 is available at:
|
||||||
|
|
||||||
https://git.add-ideas.de/lotobo/rand-tools/src/tag/v0.1.0
|
https://git.add-ideas.de/lotobo/rand-tools/src/tag/v0.1.1
|
||||||
|
|
||||||
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
|
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Third-party notices
|
# Third-party notices
|
||||||
|
|
||||||
Random Tools 0.1.0 directly depends on these runtime packages:
|
Random Tools 0.1.1 directly depends on these runtime packages:
|
||||||
|
|
||||||
| Package | Pinned version | Declared licence |
|
| Package | Pinned version | Declared licence |
|
||||||
| -------------------------------- | -------------: | ---------------- |
|
| -------------------------------- | -------------: | ---------------- |
|
||||||
@@ -10,6 +10,4 @@ Random Tools 0.1.0 directly depends on these runtime packages:
|
|||||||
| `react` | 19.2.8 | MIT |
|
| `react` | 19.2.8 | MIT |
|
||||||
| `react-dom` | 19.2.8 | MIT |
|
| `react-dom` | 19.2.8 | MIT |
|
||||||
|
|
||||||
The optional RANDOM.ORG feature calls a public HTTP API after explicit user consent; it does not embed or redistribute RANDOM.ORG code or data. Use of that service remains subject to its operator's terms and availability.
|
|
||||||
|
|
||||||
This table covers direct production dependencies, not transitive packages or development tooling. During a release build, the exact non-development dependency tree and discovered licence texts are generated from the lockfile into `LICENSES/npm-runtime-licenses.txt` and included in the release ZIP. Copyright and licence terms remain with their respective authors.
|
This table covers direct production dependencies, not transitive packages or development tooling. During a release build, the exact non-development dependency tree and discovered licence texts are generated from the lockfile into `LICENSES/npm-runtime-licenses.txt` and included in the release ZIP. Copyright and licence terms remain with their respective authors.
|
||||||
|
|||||||
@@ -2,6 +2,6 @@
|
|||||||
|
|
||||||
Random Tools is a static React/Vite application wrapped in the shared Toolbox shell. `random/source.ts` exposes one common interface over two deliberately distinct local sources: browser WebCrypto with rejection-sampled integers, and the versioned `toolbox-helpers` xoshiro128** seeded generator. Secure mode never falls back to deterministic mode.
|
Random Tools is a static React/Vite application wrapped in the shared Toolbox shell. `random/source.ts` exposes one common interface over two deliberately distinct local sources: browser WebCrypto with rejection-sampled integers, and the versioned `toolbox-helpers` xoshiro128** seeded generator. Secure mode never falls back to deterministic mode.
|
||||||
|
|
||||||
`random/generators.ts` builds bounded numbers, strings, identifiers, dice, samples, shuffles, passphrases and normal samples on that interface. Each successful local result includes source identity, parameters and deterministic state where applicable. Operations are synchronous and bounded, so version 0.1 creates no worker.
|
`random/generators.ts` builds bounded numbers, strings, identifiers, dice, samples, shuffles, passphrases and normal samples on that interface. `random/draws.ts` adds local binary coin flips, standard playing-card deals, shuffled inclusive integer sequences, Gregorian calendar-date sampling, exact decimal-digit fractions and equal-area points on a spherical surface model. Date sampling can filter weekdays and use a partial Fisher–Yates mapping for selection without replacement; it does not allocate every date in a large range.
|
||||||
|
|
||||||
`random/remote.ts` is a separate, explicitly selected RANDOM.ORG integer client. Requests are validated, serialised, credentialless, no-referrer, no-store and subject to a 120-second abort timeout; responses are checked against the requested count/range. This is the only runtime network path and the production CSP permits only that origin in addition to self.
|
Each successful result includes source identity, parameters and deterministic state where applicable. Most operations are synchronous and bounded; custom word-list identity uses the asynchronous browser Web Crypto digest API without a worker. Custom lists are trimmed, emptied lines are dropped and uniqueness is checked once before both selection and metadata are derived. The application contains no third-party network client; its CSP limits connections to its own origin for loading the local application shell.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Privacy and security
|
# Privacy and security
|
||||||
|
|
||||||
Local WebCrypto is the default. It stays in the browser, has no remote fallback, and is the only mode intended for secrets. Seeded xoshiro128** output is reproducible and explicitly non-cryptographic; anyone with the seed/state/recipe can reproduce or predict it. The entropy estimate for a passphrase is a simple uniform-choice model, not a password-strength audit.
|
Local Web Crypto is the default. It stays in the browser, has no network or seeded fallback, and is the only mode intended for secrets. Seeded xoshiro128** output is reproducible and explicitly non-cryptographic; anyone with the seed/state/recipe can reproduce or predict it. The entropy estimate for a passphrase is a simple uniform-choice model, not a password-strength audit.
|
||||||
|
|
||||||
The RANDOM.ORG workspace is an opt-in exception to local processing. Only after checking consent and pressing the request button does the browser send the requested count and range to `https://www.random.org`; RANDOM.ORG and network intermediaries can observe the request and the user's IP address. Credentials and referrer are omitted. Browser CORS policy or service limits may prevent the request. No other operation substitutes this source automatically.
|
There is no third-party request path, telemetry, analytics, account, geolocation lookup or persistence. Coordinates are generated as mathematical samples and are not derived from the device location. Results and seeds remain in page memory unless copied or downloaded. A custom passphrase recipe contains the normalized list count and a versioned SHA-256 identity, not the list contents; the same normalized input is therefore still required for reproduction.
|
||||||
|
|
||||||
Counts, ranges, alphabets, list sizes and generated byte counts have explicit limits before large allocations. Results and seeds remain in page memory unless copied or downloaded. The app has no telemetry, analytics, account or persistence and is not certified for regulated drawings or gambling.
|
Counts, ranges, alphabets, list sizes and generated byte counts have explicit limits before large allocations. Web Crypto is a browser-provided CSPRNG, not a physical randomness source. The app is not certified for regulated drawings or gambling.
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
const CACHE_PREFIX = "rand-tools-shell-";
|
const CACHE_PREFIX = "rand-tools-shell-";
|
||||||
const CACHE_NAME = CACHE_PREFIX + "0.1.0";
|
const CACHE_NAME = CACHE_PREFIX + "0.1.1";
|
||||||
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
|
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
|
|||||||
+14
-4
@@ -3,12 +3,22 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.rand-tools",
|
"id": "de.add-ideas.rand-tools",
|
||||||
"name": "Random Tools",
|
"name": "Random Tools",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"description": "Generate secure or reproducible random values locally in the browser.",
|
"description": "Generate secure or reproducible random values locally in the browser.",
|
||||||
"entry": "./",
|
"entry": "./",
|
||||||
"icon": "./favicon.svg",
|
"icon": "./favicon.svg",
|
||||||
"categories": ["random", "developer", "productivity"],
|
"categories": ["random", "developer", "productivity"],
|
||||||
"tags": ["random", "uuid", "ulid", "dice", "shuffle", "sample"],
|
"tags": [
|
||||||
|
"random",
|
||||||
|
"uuid",
|
||||||
|
"ulid",
|
||||||
|
"dice",
|
||||||
|
"shuffle",
|
||||||
|
"sample",
|
||||||
|
"cards",
|
||||||
|
"dates",
|
||||||
|
"coordinates"
|
||||||
|
],
|
||||||
"integration": {
|
"integration": {
|
||||||
"contextVersion": 1,
|
"contextVersion": 1,
|
||||||
"launchModes": ["navigate", "new-tab"],
|
"launchModes": ["navigate", "new-tab"],
|
||||||
@@ -22,10 +32,10 @@
|
|||||||
"topLevelContext": false
|
"topLevelContext": false
|
||||||
},
|
},
|
||||||
"privacy": {
|
"privacy": {
|
||||||
"processing": "mixed",
|
"processing": "local",
|
||||||
"fileUploads": false,
|
"fileUploads": false,
|
||||||
"telemetry": false,
|
"telemetry": false,
|
||||||
"label": "Local generation is the default; RANDOM.ORG is contacted only after explicit opt-in."
|
"label": "All generation runs locally; the application makes no third-party requests."
|
||||||
},
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"repository": "https://git.add-ideas.de/lotobo/rand-tools",
|
"repository": "https://git.add-ideas.de/lotobo/rand-tools",
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const types = new Map([
|
|||||||
]);
|
]);
|
||||||
const headers = {
|
const headers = {
|
||||||
"Content-Security-Policy":
|
"Content-Security-Policy":
|
||||||
"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self' https://www.random.org; worker-src 'self' blob:; manifest-src 'self'",
|
"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'",
|
||||||
"Cross-Origin-Opener-Policy": "same-origin",
|
"Cross-Origin-Opener-Policy": "same-origin",
|
||||||
"Cross-Origin-Resource-Policy": "same-origin",
|
"Cross-Origin-Resource-Policy": "same-origin",
|
||||||
"Permissions-Policy":
|
"Permissions-Policy":
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ export function HelpDialog({
|
|||||||
Generate secure or reproducible random values locally in the browser.
|
Generate secure or reproducible random values locally in the browser.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Local WebCrypto and seeded generation stay in this browser. RANDOM.ORG
|
Web Crypto and seeded generation stay in this browser. The application
|
||||||
is contacted only from its separate workspace after explicit consent;
|
has no third-party network path, and local sources never fall back to
|
||||||
local sources never fall back to it. Inputs and responses are bounded.
|
one another. Inputs and outputs are bounded.
|
||||||
</p>
|
</p>
|
||||||
</dialog>
|
</dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
+389
-136
@@ -1,19 +1,30 @@
|
|||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { bytesToHex, triggerBlobDownload } from "@add-ideas/toolbox-helpers";
|
import {
|
||||||
|
bytesToHex,
|
||||||
|
digestHex,
|
||||||
|
triggerBlobDownload,
|
||||||
|
} from "@add-ideas/toolbox-helpers";
|
||||||
import {
|
import {
|
||||||
DEFAULT_WORDS,
|
DEFAULT_WORDS,
|
||||||
|
normalizeWordList,
|
||||||
normalValues,
|
normalValues,
|
||||||
passphrase,
|
passphraseWithWordList,
|
||||||
|
randomIdentifiers,
|
||||||
randomIntegers,
|
randomIntegers,
|
||||||
randomString,
|
randomString,
|
||||||
rollDice,
|
rollDice,
|
||||||
sampleValues,
|
sampleValues,
|
||||||
ulid,
|
|
||||||
uuidV4,
|
|
||||||
uuidV7,
|
|
||||||
} from "../random/generators";
|
} from "../random/generators";
|
||||||
import { randomOrgIntegers } from "../random/remote";
|
import {
|
||||||
|
coinFlips,
|
||||||
|
dealCards,
|
||||||
|
decimalFractions,
|
||||||
|
integerSequence,
|
||||||
|
randomCoordinates,
|
||||||
|
randomDates,
|
||||||
|
} from "../random/draws";
|
||||||
import { randomSource, type SourceMode } from "../random/source";
|
import { randomSource, type SourceMode } from "../random/source";
|
||||||
|
import { APP_VERSION } from "../version";
|
||||||
|
|
||||||
type Tab =
|
type Tab =
|
||||||
| "numbers"
|
| "numbers"
|
||||||
@@ -22,7 +33,18 @@ type Tab =
|
|||||||
| "dice"
|
| "dice"
|
||||||
| "lists"
|
| "lists"
|
||||||
| "passphrases"
|
| "passphrases"
|
||||||
| "remote";
|
| "draws";
|
||||||
|
type DrawKind =
|
||||||
|
"coins" | "cards" | "sequence" | "dates" | "decimals" | "coordinates";
|
||||||
|
const WEEKDAYS = [
|
||||||
|
"Sunday",
|
||||||
|
"Monday",
|
||||||
|
"Tuesday",
|
||||||
|
"Wednesday",
|
||||||
|
"Thursday",
|
||||||
|
"Friday",
|
||||||
|
"Saturday",
|
||||||
|
] as const;
|
||||||
interface Output {
|
interface Output {
|
||||||
title: string;
|
title: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -34,12 +56,24 @@ function Result({ output }: { output: Output | undefined }) {
|
|||||||
if (!output)
|
if (!output)
|
||||||
return <p className="empty">Generate a result to see it here.</p>;
|
return <p className="empty">Generate a result to see it here.</p>;
|
||||||
const report = JSON.stringify(
|
const report = JSON.stringify(
|
||||||
{ schemaVersion: 1, generatedBy: "add-ideas Rand Tools 0.1.0", ...output },
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
generatedBy: `add-ideas Rand Tools ${APP_VERSION}`,
|
||||||
|
...output,
|
||||||
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<section className="result" aria-live="polite">
|
<section className="result">
|
||||||
|
<p
|
||||||
|
className="visually-hidden"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
>
|
||||||
|
Result ready: {output.title}.
|
||||||
|
</p>
|
||||||
<div className="panel-heading">
|
<div className="panel-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Last successful result</p>
|
<p className="eyebrow">Last successful result</p>
|
||||||
@@ -65,7 +99,7 @@ function Result({ output }: { output: Output | undefined }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<pre>{output.text}</pre>
|
<pre aria-label="Generated output">{output.text}</pre>
|
||||||
{output.note && <p className="warning">{output.note}</p>}
|
{output.note && <p className="warning">{output.note}</p>}
|
||||||
<details>
|
<details>
|
||||||
<summary>Reproduction metadata</summary>
|
<summary>Reproduction metadata</summary>
|
||||||
@@ -109,7 +143,7 @@ function SourceControls({
|
|||||||
)}
|
)}
|
||||||
<p className={mode === "secure" ? "success" : "warning"}>
|
<p className={mode === "secure" ? "success" : "warning"}>
|
||||||
{mode === "secure"
|
{mode === "secure"
|
||||||
? "Uses crypto.getRandomValues. No fallback to seeded or remote randomness."
|
? "Uses crypto.getRandomValues locally. There is no network or non-cryptographic fallback."
|
||||||
: "Reproducible pseudorandom output is not cryptographic and must not be used for keys, passwords, or security tokens."}
|
: "Reproducible pseudorandom output is not cryptographic and must not be used for keys, passwords, or security tokens."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,38 +179,64 @@ export function Workbench() {
|
|||||||
const [shuffle, setShuffle] = useState(false);
|
const [shuffle, setShuffle] = useState(false);
|
||||||
const [wordCount, setWordCount] = useState(6);
|
const [wordCount, setWordCount] = useState(6);
|
||||||
const [customWords, setCustomWords] = useState("");
|
const [customWords, setCustomWords] = useState("");
|
||||||
const [remoteConsent, setRemoteConsent] = useState(false);
|
const [drawKind, setDrawKind] = useState<DrawKind>("coins");
|
||||||
const [remoteBusy, setRemoteBusy] = useState(false);
|
const [deckCount, setDeckCount] = useState(1);
|
||||||
|
const [dealCount, setDealCount] = useState(5);
|
||||||
|
const [dateStart, setDateStart] = useState("2026-01-01");
|
||||||
|
const [dateEnd, setDateEnd] = useState("2026-12-31");
|
||||||
|
const [dateWeekdays, setDateWeekdays] = useState<number[]>([
|
||||||
|
0, 1, 2, 3, 4, 5, 6,
|
||||||
|
]);
|
||||||
|
const [uniqueDates, setUniqueDates] = useState(false);
|
||||||
|
const [decimalPlaces, setDecimalPlaces] = useState(8);
|
||||||
|
const [coordinatePlaces, setCoordinatePlaces] = useState(6);
|
||||||
|
const activeOperation = useRef(0);
|
||||||
|
|
||||||
|
type LocalResult = {
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
parameters: Record<string, unknown>;
|
||||||
|
note?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeLocal = (
|
||||||
|
source: ReturnType<typeof randomSource>,
|
||||||
|
result: LocalResult,
|
||||||
|
sourceSeed: string,
|
||||||
|
operation: number,
|
||||||
|
) => {
|
||||||
|
if (operation !== activeOperation.current) return;
|
||||||
|
setOutput({
|
||||||
|
title: result.title,
|
||||||
|
text: result.text,
|
||||||
|
note: result.note,
|
||||||
|
recipe: {
|
||||||
|
source: source.identity,
|
||||||
|
sourceClass:
|
||||||
|
source.mode === "secure"
|
||||||
|
? "cryptographic"
|
||||||
|
: "deterministic-non-cryptographic",
|
||||||
|
seed: source.mode === "deterministic" ? sourceSeed : undefined,
|
||||||
|
stateAfter: source.state?.(),
|
||||||
|
parameters: result.parameters,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setError("");
|
||||||
|
};
|
||||||
|
|
||||||
const local = (
|
const local = (
|
||||||
operation: (source: ReturnType<typeof randomSource>) => {
|
operation: (source: ReturnType<typeof randomSource>) => LocalResult,
|
||||||
title: string;
|
|
||||||
text: string;
|
|
||||||
parameters: Record<string, unknown>;
|
|
||||||
note?: string;
|
|
||||||
},
|
|
||||||
) => {
|
) => {
|
||||||
|
const operationId = ++activeOperation.current;
|
||||||
try {
|
try {
|
||||||
const source = randomSource(mode, seed);
|
const sourceSeed = seed;
|
||||||
const result = operation(source);
|
const source = randomSource(mode, sourceSeed);
|
||||||
setOutput({
|
completeLocal(source, operation(source), sourceSeed, operationId);
|
||||||
title: result.title,
|
|
||||||
text: result.text,
|
|
||||||
note: result.note,
|
|
||||||
recipe: {
|
|
||||||
source: source.identity,
|
|
||||||
sourceClass:
|
|
||||||
mode === "secure"
|
|
||||||
? "cryptographic"
|
|
||||||
: "deterministic-non-cryptographic",
|
|
||||||
seed: mode === "deterministic" ? seed : undefined,
|
|
||||||
stateAfter: source.state?.(),
|
|
||||||
parameters: result.parameters,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setError("");
|
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setError(reason instanceof Error ? reason.message : "Generation failed.");
|
if (operationId === activeOperation.current)
|
||||||
|
setError(
|
||||||
|
reason instanceof Error ? reason.message : "Generation failed.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -207,13 +267,7 @@ export function Workbench() {
|
|||||||
const generateIdentifiers = () =>
|
const generateIdentifiers = () =>
|
||||||
local((source) => {
|
local((source) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const values = Array.from({ length: count }, () =>
|
const values = randomIdentifiers(source, count, identifier, now);
|
||||||
identifier === "uuid4"
|
|
||||||
? uuidV4(source)
|
|
||||||
: identifier === "uuid7"
|
|
||||||
? uuidV7(source, now)
|
|
||||||
: ulid(source, now),
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
title:
|
title:
|
||||||
identifier === "uuid4"
|
identifier === "uuid4"
|
||||||
@@ -268,62 +322,149 @@ export function Workbench() {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const generatePassphrase = () =>
|
const generatePassphrase = async () => {
|
||||||
local((source) => {
|
const operationId = ++activeOperation.current;
|
||||||
if (customWords.length > 4_000_000)
|
try {
|
||||||
throw new Error(
|
const sourceSeed = seed;
|
||||||
"Custom word-list input exceeds 4,000,000 UTF-16 units.",
|
const source = randomSource(mode, sourceSeed);
|
||||||
);
|
const custom = customWords.trim().length > 0;
|
||||||
const words = customWords.trim()
|
const wordList = normalizeWordList(custom ? customWords : DEFAULT_WORDS);
|
||||||
? customWords.replaceAll("\r\n", "\n").split("\n")
|
const result = passphraseWithWordList(source, wordCount, wordList);
|
||||||
: DEFAULT_WORDS;
|
const wordListSha256 = await digestHex(
|
||||||
const result = passphrase(source, wordCount, words);
|
new TextEncoder().encode(
|
||||||
return {
|
`${wordList.normalization}\0${wordList.canonical}`,
|
||||||
title: "Passphrase",
|
),
|
||||||
text: result.value,
|
"SHA-256",
|
||||||
parameters: {
|
16 * 1024 * 1024,
|
||||||
wordCount,
|
);
|
||||||
listSize: new Set(words).size,
|
completeLocal(
|
||||||
separator: "-",
|
source,
|
||||||
entropyModelBits: result.entropy,
|
{
|
||||||
|
title: "Passphrase",
|
||||||
|
text: result.value,
|
||||||
|
parameters: {
|
||||||
|
wordCount,
|
||||||
|
wordList: {
|
||||||
|
identity: custom
|
||||||
|
? "custom-normalized-word-list-v1"
|
||||||
|
: "rand-tools-bundled-word-list-v1",
|
||||||
|
normalization: wordList.normalization,
|
||||||
|
normalizedCount: result.listSize,
|
||||||
|
sha256: wordListSha256,
|
||||||
|
customInputRequiredForReproduction: custom,
|
||||||
|
},
|
||||||
|
separator: "-",
|
||||||
|
entropyModelBits: result.entropy,
|
||||||
|
},
|
||||||
|
note: `The ${result.entropy.toFixed(1)}-bit figure is only count × log₂(list size), assuming independent uniform choices. It is not a password-strength audit. ${custom ? "The recipe identifies but does not embed the normalized custom list; the same custom input is required to reproduce this result." : "The bundled list is project-authored; use a larger reviewed list for real passphrases."}`,
|
||||||
},
|
},
|
||||||
note: `The ${result.entropy.toFixed(1)}-bit figure is only count × log₂(list size), assuming independent uniform choices. It is not a password-strength audit. The bundled list is project-authored; use a larger reviewed list for real passphrases.`,
|
sourceSeed,
|
||||||
};
|
operationId,
|
||||||
});
|
);
|
||||||
|
} catch (reason) {
|
||||||
|
if (operationId === activeOperation.current)
|
||||||
|
setError(
|
||||||
|
reason instanceof Error ? reason.message : "Generation failed.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
const generateBytes = () =>
|
const generateBytes = () =>
|
||||||
local((source) => ({
|
local((source) => ({
|
||||||
title: "Random bytes (hex)",
|
title: "Random bytes (hex)",
|
||||||
text: bytesToHex(source.bytes(length)),
|
text: bytesToHex(source.bytes(length)),
|
||||||
parameters: { bytes: length, encoding: "lowercase hexadecimal" },
|
parameters: { bytes: length, encoding: "lowercase hexadecimal" },
|
||||||
}));
|
}));
|
||||||
const requestRemote = async () => {
|
const generateDraw = () =>
|
||||||
if (!remoteConsent) {
|
local((source) => {
|
||||||
setError("Confirm the external-service notice first.");
|
switch (drawKind) {
|
||||||
return;
|
case "coins": {
|
||||||
}
|
const values = coinFlips(source, count);
|
||||||
setRemoteBusy(true);
|
const heads = values.filter((value) => value === "Heads").length;
|
||||||
setError("");
|
return {
|
||||||
try {
|
title: `${count} coin flip${count === 1 ? "" : "s"}`,
|
||||||
const values = await randomOrgIntegers({ count, minimum, maximum });
|
text: `${values.join("\n")}\n\nHeads: ${heads}\nTails: ${values.length - heads}`,
|
||||||
setOutput({
|
parameters: { draw: "independent binary coin flips", count },
|
||||||
title: "RANDOM.ORG integers",
|
};
|
||||||
text: values.join("\n"),
|
}
|
||||||
note: "Remote values were returned by RANDOM.ORG over HTTPS. They are not used for Toolbox passwords, keys, or secure local workflows.",
|
case "cards": {
|
||||||
recipe: {
|
const values = dealCards(source, deckCount, dealCount);
|
||||||
source: "RANDOM.ORG HTTP integer generator",
|
return {
|
||||||
sourceClass: "remote-unverified",
|
title: `Deal of ${dealCount} card${dealCount === 1 ? "" : "s"}`,
|
||||||
requestedAt: new Date().toISOString(),
|
text: values.join("\n"),
|
||||||
parameters: { count, minimum, maximum },
|
parameters: {
|
||||||
},
|
draw: "Fisher–Yates shuffled standard deck",
|
||||||
});
|
deckCount,
|
||||||
} catch (reason) {
|
dealtCards: dealCount,
|
||||||
setError(
|
withoutReplacement: true,
|
||||||
reason instanceof Error ? reason.message : "Remote request failed.",
|
},
|
||||||
);
|
};
|
||||||
} finally {
|
}
|
||||||
setRemoteBusy(false);
|
case "sequence": {
|
||||||
}
|
const values = integerSequence(source, minimum, maximum);
|
||||||
};
|
return {
|
||||||
|
title: "Shuffled integer sequence",
|
||||||
|
text: values.join("\n"),
|
||||||
|
parameters: {
|
||||||
|
draw: "Fisher–Yates shuffled inclusive integer range",
|
||||||
|
minimum,
|
||||||
|
maximumInclusive: maximum,
|
||||||
|
count: values.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "dates": {
|
||||||
|
const values = randomDates(source, count, dateStart, dateEnd, {
|
||||||
|
weekdays: dateWeekdays,
|
||||||
|
withoutReplacement: uniqueDates,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
title: "Random calendar dates",
|
||||||
|
text: values.join("\n"),
|
||||||
|
parameters: {
|
||||||
|
draw: `uniform Gregorian calendar day ${uniqueDates ? "without" : "with"} replacement`,
|
||||||
|
count,
|
||||||
|
startInclusive: dateStart,
|
||||||
|
endInclusive: dateEnd,
|
||||||
|
weekdays: dateWeekdays.map((day) => WEEKDAYS[day]),
|
||||||
|
withoutReplacement: uniqueDates,
|
||||||
|
timezone: "none (calendar dates)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "decimals": {
|
||||||
|
const values = decimalFractions(source, count, decimalPlaces);
|
||||||
|
return {
|
||||||
|
title: "Decimal fractions",
|
||||||
|
text: values.join("\n"),
|
||||||
|
parameters: {
|
||||||
|
draw: "independent uniform decimal digits",
|
||||||
|
interval: "[0, 1)",
|
||||||
|
count,
|
||||||
|
decimalPlaces,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "coordinates": {
|
||||||
|
const values = randomCoordinates(source, count, coordinatePlaces);
|
||||||
|
return {
|
||||||
|
title: "Random coordinates",
|
||||||
|
text: values
|
||||||
|
.map(
|
||||||
|
({ latitude, longitude }) =>
|
||||||
|
`${latitude.toFixed(coordinatePlaces)}, ${longitude.toFixed(coordinatePlaces)}`,
|
||||||
|
)
|
||||||
|
.join("\n"),
|
||||||
|
parameters: {
|
||||||
|
draw: "uniform point on a spherical surface model",
|
||||||
|
count,
|
||||||
|
decimalPlaces: coordinatePlaces,
|
||||||
|
latitudeMethod: "asin(2u - 1)",
|
||||||
|
},
|
||||||
|
note: "Coordinates are mathematical samples on a spherical model, not surveyed locations; no geolocation API or map service is used.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
["numbers", "Numbers"],
|
["numbers", "Numbers"],
|
||||||
@@ -332,20 +473,21 @@ export function Workbench() {
|
|||||||
["dice", "Dice"],
|
["dice", "Dice"],
|
||||||
["lists", "Lists"],
|
["lists", "Lists"],
|
||||||
["passphrases", "Passphrases"],
|
["passphrases", "Passphrases"],
|
||||||
["remote", "RANDOM.ORG"],
|
["draws", "Draws"],
|
||||||
] as const;
|
] as const;
|
||||||
return (
|
return (
|
||||||
<main className="workbench">
|
<main className="workbench">
|
||||||
<header className="hero">
|
<header className="hero">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Secure local default</p>
|
<p className="eyebrow">Entirely local</p>
|
||||||
<h1>Rand Tools</h1>
|
<h1>Rand Tools</h1>
|
||||||
<p>
|
<p>
|
||||||
Generate numbers, strings, identifiers, dice, samples, shuffles, and
|
Generate numbers, strings, identifiers, dice, samples, shuffles,
|
||||||
passphrases with explicit randomness sources.
|
passphrases, and practical draws with explicit local randomness
|
||||||
|
sources.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="privacy-pill">WebCrypto by default</span>
|
<span className="privacy-pill">No network requests</span>
|
||||||
</header>
|
</header>
|
||||||
<SourceControls
|
<SourceControls
|
||||||
mode={mode}
|
mode={mode}
|
||||||
@@ -613,64 +755,176 @@ export function Workbench() {
|
|||||||
<button
|
<button
|
||||||
className="primary"
|
className="primary"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={generatePassphrase}
|
onClick={() => void generatePassphrase()}
|
||||||
>
|
>
|
||||||
Generate passphrase
|
Generate passphrase
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{tab === "remote" && (
|
{tab === "draws" && (
|
||||||
<>
|
<>
|
||||||
<p className="warning">
|
<label className="field">
|
||||||
This optional action contacts <strong>www.random.org</strong>,
|
<span>Draw type</span>
|
||||||
revealing your IP address and request parameters to that
|
<select
|
||||||
service. Browser JavaScript cannot set the contact-email
|
value={drawKind}
|
||||||
User-Agent requested by its automated-client guidance, so this
|
onChange={(event) =>
|
||||||
integration cannot claim complete guideline adherence.
|
setDrawKind(event.target.value as DrawKind)
|
||||||
</p>
|
}
|
||||||
<label className="check">
|
>
|
||||||
<input
|
<option value="coins">Coin flips</option>
|
||||||
type="checkbox"
|
<option value="cards">Playing cards</option>
|
||||||
checked={remoteConsent}
|
<option value="sequence">Integer sequence</option>
|
||||||
onChange={(event) => setRemoteConsent(event.target.checked)}
|
<option value="dates">Calendar dates</option>
|
||||||
/>{" "}
|
<option value="decimals">Decimal fractions</option>
|
||||||
I understand and want to make this one external request.
|
<option value="coordinates">Coordinates</option>
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div className="form-grid">
|
{(drawKind === "coins" ||
|
||||||
|
drawKind === "dates" ||
|
||||||
|
drawKind === "decimals" ||
|
||||||
|
drawKind === "coordinates") && (
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Count (max 1,000)</span>
|
<span>Result count</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="1000"
|
max="100000"
|
||||||
value={count}
|
value={count}
|
||||||
onChange={(event) => setCount(event.target.valueAsNumber)}
|
onChange={(event) => setCount(event.target.valueAsNumber)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
)}
|
||||||
|
{drawKind === "cards" && (
|
||||||
|
<div className="form-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span>Decks</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="8"
|
||||||
|
value={deckCount}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDeckCount(event.target.valueAsNumber)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Cards to deal</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max={deckCount * 52}
|
||||||
|
value={dealCount}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDealCount(event.target.valueAsNumber)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{drawKind === "sequence" && (
|
||||||
|
<div className="form-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span>Sequence minimum</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={minimum}
|
||||||
|
onChange={(event) =>
|
||||||
|
setMinimum(event.target.valueAsNumber)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Sequence maximum (inclusive)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={maximum}
|
||||||
|
onChange={(event) =>
|
||||||
|
setMaximum(event.target.valueAsNumber)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{drawKind === "dates" && (
|
||||||
|
<>
|
||||||
|
<div className="form-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span>Start date (inclusive)</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateStart}
|
||||||
|
onChange={(event) => setDateStart(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>End date (inclusive)</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateEnd}
|
||||||
|
onChange={(event) => setDateEnd(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<fieldset className="weekday-options">
|
||||||
|
<legend>Eligible weekdays</legend>
|
||||||
|
{WEEKDAYS.map((weekday, value) => (
|
||||||
|
<label className="check" key={weekday}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={dateWeekdays.includes(value)}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDateWeekdays((current) =>
|
||||||
|
event.target.checked
|
||||||
|
? [...current, value].sort()
|
||||||
|
: current.filter((day) => day !== value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{weekday.slice(0, 3)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
<label className="check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={uniqueDates}
|
||||||
|
onChange={(event) => setUniqueDates(event.target.checked)}
|
||||||
|
/>
|
||||||
|
Do not repeat dates
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{drawKind === "decimals" && (
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Minimum</span>
|
<span>Decimal places</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={minimum}
|
min="1"
|
||||||
onChange={(event) => setMinimum(event.target.valueAsNumber)}
|
max="64"
|
||||||
|
value={decimalPlaces}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDecimalPlaces(event.target.valueAsNumber)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
)}
|
||||||
|
{drawKind === "coordinates" && (
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Maximum</span>
|
<span>Coordinate decimal places</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={maximum}
|
min="0"
|
||||||
onChange={(event) => setMaximum(event.target.valueAsNumber)}
|
max="10"
|
||||||
|
value={coordinatePlaces}
|
||||||
|
onChange={(event) =>
|
||||||
|
setCoordinatePlaces(event.target.valueAsNumber)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
)}
|
||||||
<button
|
<button className="primary" type="button" onClick={generateDraw}>
|
||||||
className="primary"
|
Generate draw
|
||||||
type="button"
|
|
||||||
disabled={!remoteConsent || remoteBusy}
|
|
||||||
onClick={() => void requestRemote()}
|
|
||||||
>
|
|
||||||
{remoteBusy ? "Requesting…" : "Request remote integers"}
|
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -685,9 +939,8 @@ export function Workbench() {
|
|||||||
<section className="panel workspace">
|
<section className="panel workspace">
|
||||||
<p className="notice">
|
<p className="notice">
|
||||||
No generator is presented as certified for lotteries, gambling,
|
No generator is presented as certified for lotteries, gambling,
|
||||||
regulated drawings, or password-strength evaluation. Secure,
|
regulated drawings, or password-strength evaluation. Secure and
|
||||||
deterministic, and remote sources never silently substitute for one
|
deterministic sources never silently substitute for one another.
|
||||||
another.
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import type { RandomSource } from "./source";
|
||||||
|
|
||||||
|
const MAX_RESULTS = 100_000;
|
||||||
|
const DAY_MILLISECONDS = 86_400_000;
|
||||||
|
const RANKS = [
|
||||||
|
"A",
|
||||||
|
"2",
|
||||||
|
"3",
|
||||||
|
"4",
|
||||||
|
"5",
|
||||||
|
"6",
|
||||||
|
"7",
|
||||||
|
"8",
|
||||||
|
"9",
|
||||||
|
"10",
|
||||||
|
"J",
|
||||||
|
"Q",
|
||||||
|
"K",
|
||||||
|
] as const;
|
||||||
|
const SUITS = ["spades", "hearts", "diamonds", "clubs"] as const;
|
||||||
|
const SUIT_SYMBOLS: Record<(typeof SUITS)[number], string> = {
|
||||||
|
spades: "♠",
|
||||||
|
hearts: "♥",
|
||||||
|
diamonds: "♦",
|
||||||
|
clubs: "♣",
|
||||||
|
};
|
||||||
|
|
||||||
|
function validateCount(count: number, label = "Count"): void {
|
||||||
|
if (!Number.isSafeInteger(count) || count < 1 || count > MAX_RESULTS)
|
||||||
|
throw new Error(
|
||||||
|
`${label} must be 1–${MAX_RESULTS.toLocaleString("en-US")}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coinFlips(
|
||||||
|
source: RandomSource,
|
||||||
|
count: number,
|
||||||
|
): Array<"Heads" | "Tails"> {
|
||||||
|
validateCount(count, "Flip count");
|
||||||
|
return Array.from({ length: count }, () =>
|
||||||
|
source.integer(0, 2) === 0 ? "Heads" : "Tails",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dealCards(
|
||||||
|
source: RandomSource,
|
||||||
|
deckCount: number,
|
||||||
|
cardCount: number,
|
||||||
|
): string[] {
|
||||||
|
if (!Number.isSafeInteger(deckCount) || deckCount < 1 || deckCount > 8)
|
||||||
|
throw new Error("Deck count must be 1–8.");
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(cardCount) ||
|
||||||
|
cardCount < 1 ||
|
||||||
|
cardCount > deckCount * 52
|
||||||
|
)
|
||||||
|
throw new Error(`Cards to deal must be 1–${deckCount * 52}.`);
|
||||||
|
|
||||||
|
const deck = Array.from({ length: deckCount }, (_, deckIndex) =>
|
||||||
|
SUITS.flatMap((suit) =>
|
||||||
|
RANKS.map(
|
||||||
|
(rank) =>
|
||||||
|
`${rank}${SUIT_SYMBOLS[suit]}${deckCount > 1 ? ` (deck ${deckIndex + 1})` : ""}`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).flat();
|
||||||
|
return source.shuffle(deck).slice(0, cardCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function integerSequence(
|
||||||
|
source: RandomSource,
|
||||||
|
minimum: number,
|
||||||
|
maximumInclusive: number,
|
||||||
|
): number[] {
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(minimum) ||
|
||||||
|
!Number.isSafeInteger(maximumInclusive) ||
|
||||||
|
maximumInclusive < minimum
|
||||||
|
)
|
||||||
|
throw new Error("Sequence bounds must be ordered safe integers.");
|
||||||
|
const length = maximumInclusive - minimum + 1;
|
||||||
|
if (!Number.isSafeInteger(length) || length < 1 || length > MAX_RESULTS)
|
||||||
|
throw new Error("A sequence may contain 1–100,000 integers.");
|
||||||
|
return source.shuffle(Array.from({ length }, (_, index) => minimum + index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateToDay(value: string): number {
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/u.exec(value);
|
||||||
|
if (!match) throw new Error("Dates must use the YYYY-MM-DD format.");
|
||||||
|
const year = Number(match[1]);
|
||||||
|
const month = Number(match[2]);
|
||||||
|
const day = Number(match[3]);
|
||||||
|
if (year < 1 || month < 1 || month > 12 || day < 1 || day > 31)
|
||||||
|
throw new Error("Date is outside the supported calendar range.");
|
||||||
|
const date = new Date(0);
|
||||||
|
date.setUTCHours(0, 0, 0, 0);
|
||||||
|
date.setUTCFullYear(year, month - 1, day);
|
||||||
|
if (
|
||||||
|
date.getUTCFullYear() !== year ||
|
||||||
|
date.getUTCMonth() !== month - 1 ||
|
||||||
|
date.getUTCDate() !== day
|
||||||
|
)
|
||||||
|
throw new Error("Date is not a valid Gregorian calendar date.");
|
||||||
|
return Math.floor(date.getTime() / DAY_MILLISECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayToDate(dayIndex: number): string {
|
||||||
|
const date = new Date(dayIndex * DAY_MILLISECONDS);
|
||||||
|
return [
|
||||||
|
String(date.getUTCFullYear()).padStart(4, "0"),
|
||||||
|
String(date.getUTCMonth() + 1).padStart(2, "0"),
|
||||||
|
String(date.getUTCDate()).padStart(2, "0"),
|
||||||
|
].join("-");
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekdayForDay(dayIndex: number): number {
|
||||||
|
// 1970-01-01 (day zero) was Thursday (4 in the Sunday-first convention).
|
||||||
|
return (((dayIndex + 4) % 7) + 7) % 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
function allowedDayCount(
|
||||||
|
first: number,
|
||||||
|
last: number,
|
||||||
|
weekdays: ReadonlySet<number>,
|
||||||
|
): number {
|
||||||
|
const days = last - first + 1;
|
||||||
|
const fullWeeks = Math.floor(days / 7);
|
||||||
|
let count = fullWeeks * weekdays.size;
|
||||||
|
for (let offset = fullWeeks * 7; offset < days; offset += 1)
|
||||||
|
if (weekdays.has(weekdayForDay(first + offset))) count += 1;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayAtAllowedIndex(
|
||||||
|
first: number,
|
||||||
|
index: number,
|
||||||
|
weekdays: ReadonlySet<number>,
|
||||||
|
): number {
|
||||||
|
const fullWeeks = Math.floor(index / weekdays.size);
|
||||||
|
let remainder = index % weekdays.size;
|
||||||
|
const weekStart = first + fullWeeks * 7;
|
||||||
|
for (let offset = 0; offset < 7; offset += 1) {
|
||||||
|
if (!weekdays.has(weekdayForDay(weekStart + offset))) continue;
|
||||||
|
if (remainder === 0) return weekStart + offset;
|
||||||
|
remainder -= 1;
|
||||||
|
}
|
||||||
|
throw new Error("Could not map the selected calendar day.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleIndices(
|
||||||
|
source: RandomSource,
|
||||||
|
population: number,
|
||||||
|
count: number,
|
||||||
|
): number[] {
|
||||||
|
const swaps = new Map<number, number>();
|
||||||
|
const values: number[] = [];
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const selected = source.integer(index, population);
|
||||||
|
values.push(swaps.get(selected) ?? selected);
|
||||||
|
swaps.set(selected, swaps.get(index) ?? index);
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RandomDateOptions {
|
||||||
|
weekdays?: readonly number[];
|
||||||
|
withoutReplacement?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function randomDates(
|
||||||
|
source: RandomSource,
|
||||||
|
count: number,
|
||||||
|
start: string,
|
||||||
|
end: string,
|
||||||
|
options: RandomDateOptions = {},
|
||||||
|
): string[] {
|
||||||
|
validateCount(count, "Date count");
|
||||||
|
const first = dateToDay(start);
|
||||||
|
const last = dateToDay(end);
|
||||||
|
if (last < first) throw new Error("End date must not precede start date.");
|
||||||
|
const weekdays = new Set(options.weekdays ?? [0, 1, 2, 3, 4, 5, 6]);
|
||||||
|
if (
|
||||||
|
weekdays.size < 1 ||
|
||||||
|
[...weekdays].some(
|
||||||
|
(weekday) => !Number.isSafeInteger(weekday) || weekday < 0 || weekday > 6,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error("Select at least one valid weekday.");
|
||||||
|
const available = allowedDayCount(first, last, weekdays);
|
||||||
|
if (available < 1)
|
||||||
|
throw new Error("The range contains no dates on the selected weekdays.");
|
||||||
|
if (options.withoutReplacement && count > available)
|
||||||
|
throw new Error(
|
||||||
|
`Unique date count cannot exceed the ${available.toLocaleString("en-US")} matching dates.`,
|
||||||
|
);
|
||||||
|
const indices = options.withoutReplacement
|
||||||
|
? sampleIndices(source, available, count)
|
||||||
|
: Array.from({ length: count }, () => source.integer(0, available));
|
||||||
|
return indices.map((index) =>
|
||||||
|
dayToDate(dayAtAllowedIndex(first, index, weekdays)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decimalFractions(
|
||||||
|
source: RandomSource,
|
||||||
|
count: number,
|
||||||
|
decimalPlaces: number,
|
||||||
|
): string[] {
|
||||||
|
validateCount(count, "Fraction count");
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(decimalPlaces) ||
|
||||||
|
decimalPlaces < 1 ||
|
||||||
|
decimalPlaces > 64
|
||||||
|
)
|
||||||
|
throw new Error("Decimal places must be 1–64.");
|
||||||
|
if (count * decimalPlaces > 4_000_000)
|
||||||
|
throw new Error("Requested decimal output exceeds 4,000,000 digits.");
|
||||||
|
return Array.from({ length: count }, () => {
|
||||||
|
const digits = Array.from({ length: decimalPlaces }, () =>
|
||||||
|
String(source.integer(0, 10)),
|
||||||
|
).join("");
|
||||||
|
return `0.${digits}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Coordinate {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function randomCoordinates(
|
||||||
|
source: RandomSource,
|
||||||
|
count: number,
|
||||||
|
decimalPlaces: number,
|
||||||
|
): Coordinate[] {
|
||||||
|
validateCount(count, "Coordinate count");
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(decimalPlaces) ||
|
||||||
|
decimalPlaces < 0 ||
|
||||||
|
decimalPlaces > 10
|
||||||
|
)
|
||||||
|
throw new Error("Coordinate decimal places must be 0–10.");
|
||||||
|
const round = (value: number) => {
|
||||||
|
const rounded = Number(value.toFixed(decimalPlaces));
|
||||||
|
return Object.is(rounded, -0) ? 0 : rounded;
|
||||||
|
};
|
||||||
|
return Array.from({ length: count }, () => ({
|
||||||
|
// asin(2u - 1) makes equal-area latitude bands on a spherical model.
|
||||||
|
latitude: round((Math.asin(2 * source.float() - 1) * 180) / Math.PI),
|
||||||
|
longitude: round(source.float() * 360 - 180),
|
||||||
|
}));
|
||||||
|
}
|
||||||
+81
-22
@@ -133,11 +133,16 @@ export function randomIntegers(
|
|||||||
if (
|
if (
|
||||||
!Number.isSafeInteger(minimum) ||
|
!Number.isSafeInteger(minimum) ||
|
||||||
!Number.isSafeInteger(maximumInclusive) ||
|
!Number.isSafeInteger(maximumInclusive) ||
|
||||||
maximumInclusive < minimum ||
|
maximumInclusive < minimum
|
||||||
maximumInclusive - minimum >= 2 ** 32
|
|
||||||
)
|
)
|
||||||
|
throw new Error("Integer bounds must be safe, ordered integers.");
|
||||||
|
if (maximumInclusive === Number.MAX_SAFE_INTEGER)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Integer bounds must be safe, ordered, and span fewer than 2³² values.",
|
"Maximum inclusive must be less than Number.MAX_SAFE_INTEGER so its exclusive bound is exactly representable.",
|
||||||
|
);
|
||||||
|
if (maximumInclusive - minimum >= 2 ** 32)
|
||||||
|
throw new Error(
|
||||||
|
"The inclusive integer range must contain at most 2³² values.",
|
||||||
);
|
);
|
||||||
return Array.from({ length: count }, () =>
|
return Array.from({ length: count }, () =>
|
||||||
source.integer(minimum, maximumInclusive + 1),
|
source.integer(minimum, maximumInclusive + 1),
|
||||||
@@ -211,6 +216,25 @@ export function ulid(source: RandomSource, now = Date.now()): string {
|
|||||||
return left + right;
|
return left + right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type IdentifierKind = "uuid4" | "uuid7" | "ulid";
|
||||||
|
|
||||||
|
export function randomIdentifiers(
|
||||||
|
source: RandomSource,
|
||||||
|
count: number,
|
||||||
|
kind: IdentifierKind,
|
||||||
|
now = Date.now(),
|
||||||
|
): string[] {
|
||||||
|
if (!Number.isSafeInteger(count) || count < 1 || count > 100_000)
|
||||||
|
throw new Error("Identifier count must be a safe integer from 1–100,000.");
|
||||||
|
return Array.from({ length: count }, () =>
|
||||||
|
kind === "uuid4"
|
||||||
|
? uuidV4(source)
|
||||||
|
: kind === "uuid7"
|
||||||
|
? uuidV7(source, now)
|
||||||
|
: ulid(source, now),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface DiceResult {
|
export interface DiceResult {
|
||||||
expression: string;
|
expression: string;
|
||||||
rolls: number[];
|
rolls: number[];
|
||||||
@@ -249,30 +273,65 @@ export function rollDice(source: RandomSource, expression: string): DiceResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NormalizedWordList {
|
||||||
|
readonly words: readonly string[];
|
||||||
|
readonly canonical: string;
|
||||||
|
readonly normalization: "trim-lines-drop-empty-preserve-order-v1";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWordList(
|
||||||
|
input: string | readonly string[],
|
||||||
|
): NormalizedWordList {
|
||||||
|
if (typeof input === "string" && input.length > 4_000_000)
|
||||||
|
throw new Error("Custom word-list input exceeds 4,000,000 UTF-16 units.");
|
||||||
|
const words = typeof input === "string" ? input.split(/\r\n?|\n/u) : input;
|
||||||
|
if (words.length > 100_000 || words.some((word) => word.length > 10_000))
|
||||||
|
throw new Error(
|
||||||
|
"Word lists are limited to 100,000 entries and 10,000 UTF-16 units per entry.",
|
||||||
|
);
|
||||||
|
const normalized = words.map((word) => word.trim()).filter(Boolean);
|
||||||
|
const uniqueCount = new Set(normalized).size;
|
||||||
|
if (uniqueCount < 2 || uniqueCount !== normalized.length)
|
||||||
|
throw new Error(
|
||||||
|
"Word list must contain at least two unique non-empty entries after trimming.",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
words: normalized,
|
||||||
|
canonical: normalized.join("\n"),
|
||||||
|
normalization: "trim-lines-drop-empty-preserve-order-v1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function passphraseWithWordList(
|
||||||
|
source: RandomSource,
|
||||||
|
count: number,
|
||||||
|
wordList: NormalizedWordList,
|
||||||
|
separator = "-",
|
||||||
|
): { value: string; entropy: number; listSize: number } {
|
||||||
|
if (!Number.isSafeInteger(count) || count < 1 || count > 100)
|
||||||
|
throw new Error("Passphrase word count must be 1–100.");
|
||||||
|
return {
|
||||||
|
value: Array.from(
|
||||||
|
{ length: count },
|
||||||
|
() => wordList.words[source.integer(0, wordList.words.length)]!,
|
||||||
|
).join(separator),
|
||||||
|
entropy: count * Math.log2(wordList.words.length),
|
||||||
|
listSize: wordList.words.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function passphrase(
|
export function passphrase(
|
||||||
source: RandomSource,
|
source: RandomSource,
|
||||||
count: number,
|
count: number,
|
||||||
words: readonly string[] = DEFAULT_WORDS,
|
words: readonly string[] = DEFAULT_WORDS,
|
||||||
separator = "-",
|
separator = "-",
|
||||||
): { value: string; entropy: number } {
|
): { value: string; entropy: number; listSize: number } {
|
||||||
if (words.length > 100_000 || words.some((word) => word.length > 10_000))
|
return passphraseWithWordList(
|
||||||
throw new Error(
|
source,
|
||||||
"Word lists are limited to 100,000 entries and 10,000 UTF-16 units per entry.",
|
count,
|
||||||
);
|
normalizeWordList(words),
|
||||||
const clean = words.map((word) => word.trim()).filter(Boolean);
|
separator,
|
||||||
if (!Number.isSafeInteger(count) || count < 1 || count > 100)
|
);
|
||||||
throw new Error("Passphrase word count must be 1–100.");
|
|
||||||
if (new Set(clean).size < 2 || new Set(clean).size !== clean.length)
|
|
||||||
throw new Error(
|
|
||||||
"Word list must contain at least two unique non-empty entries.",
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
value: Array.from(
|
|
||||||
{ length: count },
|
|
||||||
() => clean[source.integer(0, clean.length)]!,
|
|
||||||
).join(separator),
|
|
||||||
entropy: count * Math.log2(clean.length),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalValues(
|
export function normalValues(
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
export interface RemoteIntegerRequest {
|
|
||||||
count: number;
|
|
||||||
minimum: number;
|
|
||||||
maximum: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ENDPOINT = "https://www.random.org/integers/";
|
|
||||||
let lastRequest: Promise<unknown> = Promise.resolve();
|
|
||||||
|
|
||||||
export async function randomOrgIntegers(
|
|
||||||
request: RemoteIntegerRequest,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<number[]> {
|
|
||||||
if (
|
|
||||||
!Number.isInteger(request.count) ||
|
|
||||||
request.count < 1 ||
|
|
||||||
request.count > 1_000
|
|
||||||
)
|
|
||||||
throw new Error("Remote count must be 1–1,000.");
|
|
||||||
if (
|
|
||||||
!Number.isSafeInteger(request.minimum) ||
|
|
||||||
!Number.isSafeInteger(request.maximum) ||
|
|
||||||
request.maximum < request.minimum ||
|
|
||||||
request.minimum < -1_000_000_000 ||
|
|
||||||
request.maximum > 1_000_000_000
|
|
||||||
)
|
|
||||||
throw new Error(
|
|
||||||
"RANDOM.ORG bounds must be ordered integers from −1,000,000,000 to 1,000,000,000.",
|
|
||||||
);
|
|
||||||
const execute = async () => {
|
|
||||||
const timeout = new AbortController();
|
|
||||||
const timer = window.setTimeout(() => timeout.abort(), 120_000);
|
|
||||||
const abort = () => timeout.abort();
|
|
||||||
signal?.addEventListener("abort", abort, { once: true });
|
|
||||||
try {
|
|
||||||
const url = new URL(ENDPOINT);
|
|
||||||
url.search = new URLSearchParams({
|
|
||||||
num: String(request.count),
|
|
||||||
min: String(request.minimum),
|
|
||||||
max: String(request.maximum),
|
|
||||||
col: "1",
|
|
||||||
base: "10",
|
|
||||||
format: "plain",
|
|
||||||
rnd: "new",
|
|
||||||
}).toString();
|
|
||||||
const response = await fetch(url, {
|
|
||||||
credentials: "omit",
|
|
||||||
referrerPolicy: "no-referrer",
|
|
||||||
cache: "no-store",
|
|
||||||
signal: timeout.signal,
|
|
||||||
});
|
|
||||||
if (!response.ok)
|
|
||||||
throw new Error(`RANDOM.ORG returned HTTP ${response.status}.`);
|
|
||||||
const text = await response.text();
|
|
||||||
const values = text.trim().split(/\s+/u).filter(Boolean).map(Number);
|
|
||||||
if (
|
|
||||||
values.length !== request.count ||
|
|
||||||
values.some(
|
|
||||||
(value) =>
|
|
||||||
!Number.isSafeInteger(value) ||
|
|
||||||
value < request.minimum ||
|
|
||||||
value > request.maximum,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
throw new Error(
|
|
||||||
"RANDOM.ORG response did not match the requested integer contract.",
|
|
||||||
);
|
|
||||||
return values;
|
|
||||||
} finally {
|
|
||||||
window.clearTimeout(timer);
|
|
||||||
signal?.removeEventListener("abort", abort);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const queued = lastRequest.then(execute, execute);
|
|
||||||
lastRequest = queued.catch(() => undefined);
|
|
||||||
return queued;
|
|
||||||
}
|
|
||||||
@@ -15,6 +15,16 @@
|
|||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
html {
|
html {
|
||||||
min-width: 20rem;
|
min-width: 20rem;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
@@ -259,6 +269,20 @@ textarea {
|
|||||||
width: auto;
|
width: auto;
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
}
|
}
|
||||||
|
.weekday-options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(5.3rem, 1fr));
|
||||||
|
gap: 0.55rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--toolbox-border);
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
}
|
||||||
|
.weekday-options legend {
|
||||||
|
padding: 0 0.35rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
.result {
|
.result {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
|
|||||||
@@ -3,12 +3,22 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.rand-tools",
|
"id": "de.add-ideas.rand-tools",
|
||||||
"name": "Random Tools",
|
"name": "Random Tools",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"description": "Generate secure or reproducible random values locally in the browser.",
|
"description": "Generate secure or reproducible random values locally in the browser.",
|
||||||
"entry": "./",
|
"entry": "./",
|
||||||
"icon": "./favicon.svg",
|
"icon": "./favicon.svg",
|
||||||
"categories": ["random", "developer", "productivity"],
|
"categories": ["random", "developer", "productivity"],
|
||||||
"tags": ["random", "uuid", "ulid", "dice", "shuffle", "sample"],
|
"tags": [
|
||||||
|
"random",
|
||||||
|
"uuid",
|
||||||
|
"ulid",
|
||||||
|
"dice",
|
||||||
|
"shuffle",
|
||||||
|
"sample",
|
||||||
|
"cards",
|
||||||
|
"dates",
|
||||||
|
"coordinates"
|
||||||
|
],
|
||||||
"integration": {
|
"integration": {
|
||||||
"contextVersion": 1,
|
"contextVersion": 1,
|
||||||
"launchModes": ["navigate", "new-tab"],
|
"launchModes": ["navigate", "new-tab"],
|
||||||
@@ -22,10 +32,10 @@
|
|||||||
"topLevelContext": false
|
"topLevelContext": false
|
||||||
},
|
},
|
||||||
"privacy": {
|
"privacy": {
|
||||||
"processing": "mixed",
|
"processing": "local",
|
||||||
"fileUploads": false,
|
"fileUploads": false,
|
||||||
"telemetry": false,
|
"telemetry": false,
|
||||||
"label": "Local generation is the default; RANDOM.ORG is contacted only after explicit opt-in."
|
"label": "All generation runs locally; the application makes no third-party requests."
|
||||||
},
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"repository": "https://git.add-ideas.de/lotobo/rand-tools",
|
"repository": "https://git.add-ideas.de/lotobo/rand-tools",
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = "0.1.0";
|
export const APP_VERSION = "0.1.1";
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ test("runs from a nested path without external requests", async ({ page }) => {
|
|||||||
expect(errors).toEqual([]);
|
expect(errors).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("repeats deterministic output without contacting the remote source", async ({
|
test("repeats deterministic output without external requests", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const external = await localOnly(page);
|
const external = await localOnly(page);
|
||||||
@@ -50,6 +50,95 @@ test("repeats deterministic output without contacting the remote source", async
|
|||||||
expect(external).toEqual([]);
|
expect(external).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("runs the local draw catalogue with recipe metadata", async ({ page }) => {
|
||||||
|
const external = await localOnly(page);
|
||||||
|
await page.goto("/deep/nested/rand/");
|
||||||
|
await page.getByLabel("Random source").selectOption("deterministic");
|
||||||
|
await page
|
||||||
|
.getByRole("textbox", { name: "Seed", exact: true })
|
||||||
|
.fill("draw-catalogue");
|
||||||
|
await page.getByRole("tab", { name: "Draws" }).click();
|
||||||
|
|
||||||
|
await page.getByLabel("Result count").fill("7");
|
||||||
|
await page.getByRole("button", { name: "Generate draw" }).click();
|
||||||
|
await expect(page.locator(".result > pre")).toContainText(/Heads|Tails/u);
|
||||||
|
|
||||||
|
await page.getByLabel("Draw type").selectOption("cards");
|
||||||
|
await page.getByLabel("Cards to deal").fill("5");
|
||||||
|
await page.getByRole("button", { name: "Generate draw" }).click();
|
||||||
|
await expect(page.locator(".result > pre")).toContainText(/[♠♥♦♣]/u);
|
||||||
|
|
||||||
|
await page.getByLabel("Draw type").selectOption("sequence");
|
||||||
|
await page.getByLabel("Sequence minimum").fill("5");
|
||||||
|
await page.getByLabel("Sequence maximum (inclusive)").fill("8");
|
||||||
|
await page.getByRole("button", { name: "Generate draw" }).click();
|
||||||
|
const sequence = (await page.locator(".result > pre").textContent())
|
||||||
|
?.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map(Number)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
expect(sequence).toEqual([5, 6, 7, 8]);
|
||||||
|
|
||||||
|
await page.getByLabel("Draw type").selectOption("dates");
|
||||||
|
await page.getByLabel("Result count").fill("3");
|
||||||
|
await page.getByLabel("Start date (inclusive)").fill("2026-01-01");
|
||||||
|
await page.getByLabel("End date (inclusive)").fill("2026-01-31");
|
||||||
|
await page.getByLabel("Do not repeat dates").check();
|
||||||
|
await page.getByRole("button", { name: "Generate draw" }).click();
|
||||||
|
await expect(page.locator(".result > pre")).toContainText(/2026-01-/u);
|
||||||
|
|
||||||
|
await page.getByLabel("Draw type").selectOption("decimals");
|
||||||
|
await page.getByLabel("Result count").fill("2");
|
||||||
|
await page.getByLabel("Decimal places").fill("4");
|
||||||
|
await page.getByRole("button", { name: "Generate draw" }).click();
|
||||||
|
await expect(page.locator(".result > pre")).toContainText(/0\.\d{4}/u);
|
||||||
|
|
||||||
|
await page.getByLabel("Draw type").selectOption("coordinates");
|
||||||
|
await page.getByLabel("Result count").fill("2");
|
||||||
|
await page.getByLabel("Coordinate decimal places").fill("4");
|
||||||
|
await page.getByRole("button", { name: "Generate draw" }).click();
|
||||||
|
await expect(page.locator(".result > pre")).toContainText(
|
||||||
|
/-?\d+\.\d{4}, -?\d+\.\d{4}/u,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
page.getByText(/mathematical samples on a spherical model/u),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(external).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("identifies normalized custom passphrase inputs without announcing output", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const external = await localOnly(page);
|
||||||
|
await page.goto("/deep/nested/rand/");
|
||||||
|
await page.getByLabel("Random source").selectOption("deterministic");
|
||||||
|
await page
|
||||||
|
.getByRole("textbox", { name: "Seed", exact: true })
|
||||||
|
.fill("word-list-identity");
|
||||||
|
await page.getByRole("tab", { name: "Passphrases" }).click();
|
||||||
|
await page
|
||||||
|
.getByLabel(/Optional custom word list/u)
|
||||||
|
.fill(" alpha \n\nbeta\n gamma ");
|
||||||
|
await page.getByRole("button", { name: "Generate passphrase" }).click();
|
||||||
|
|
||||||
|
const result = page.locator(".result");
|
||||||
|
await expect(result.getByRole("status")).toHaveText(
|
||||||
|
/Result ready: Passphrase/u,
|
||||||
|
);
|
||||||
|
await expect(result).not.toHaveAttribute("aria-live");
|
||||||
|
await result.getByText("Reproduction metadata").click();
|
||||||
|
await expect(result.locator("details pre")).toContainText(
|
||||||
|
/"normalizedCount": 3/u,
|
||||||
|
);
|
||||||
|
await expect(result.locator("details pre")).toContainText(
|
||||||
|
/"sha256": "[0-9a-f]{64}"/u,
|
||||||
|
);
|
||||||
|
await expect(result.locator("details pre")).toContainText(
|
||||||
|
/"customInputRequiredForReproduction": true/u,
|
||||||
|
);
|
||||||
|
expect(external).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test("serves the release identity and hardened headers", async ({
|
test("serves the release identity and hardened headers", async ({
|
||||||
request,
|
request,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -59,13 +148,16 @@ test("serves the release identity and hardened headers", async ({
|
|||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
);
|
);
|
||||||
expect(index.headers()["content-security-policy"]).toContain(
|
expect(index.headers()["content-security-policy"]).toContain(
|
||||||
"connect-src 'self' https://www.random.org",
|
"connect-src 'self'",
|
||||||
|
);
|
||||||
|
expect(index.headers()["content-security-policy"]).not.toMatch(
|
||||||
|
/connect-src[^;]*https?:/u,
|
||||||
);
|
);
|
||||||
expect(await index.text()).not.toMatch(/\b(?:src|href)=["']\//u);
|
expect(await index.text()).not.toMatch(/\b(?:src|href)=["']\//u);
|
||||||
const manifest = await request.get("/deep/nested/rand/toolbox-app.json");
|
const manifest = await request.get("/deep/nested/rand/toolbox-app.json");
|
||||||
await expect(manifest.json()).resolves.toMatchObject({
|
await expect(manifest.json()).resolves.toMatchObject({
|
||||||
id: "de.add-ideas.rand-tools",
|
id: "de.add-ideas.rand-tools",
|
||||||
version: "0.1.0",
|
version: "0.1.1",
|
||||||
entry: "./",
|
entry: "./",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ describe("Random Tools", () => {
|
|||||||
expect(
|
expect(
|
||||||
await screen.findByRole("heading", { name: "Random Tools" }),
|
await screen.findByRole("heading", { name: "Random Tools" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
expect(await screen.findByText("WebCrypto by default")).toBeVisible();
|
expect(await screen.findByText("No network requests")).toBeVisible();
|
||||||
|
expect(screen.getByRole("tab", { name: "Draws" })).toBeVisible();
|
||||||
|
expect(screen.queryByText(/RANDOM\.ORG/iu)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
normalizeWordList,
|
||||||
passphrase,
|
passphrase,
|
||||||
|
passphraseWithWordList,
|
||||||
|
randomIdentifiers,
|
||||||
randomIntegers,
|
randomIntegers,
|
||||||
randomString,
|
randomString,
|
||||||
rollDice,
|
rollDice,
|
||||||
@@ -8,6 +11,14 @@ import {
|
|||||||
uuidV4,
|
uuidV4,
|
||||||
uuidV7,
|
uuidV7,
|
||||||
} from "../../src/random/generators";
|
} from "../../src/random/generators";
|
||||||
|
import {
|
||||||
|
coinFlips,
|
||||||
|
dealCards,
|
||||||
|
decimalFractions,
|
||||||
|
integerSequence,
|
||||||
|
randomCoordinates,
|
||||||
|
randomDates,
|
||||||
|
} from "../../src/random/draws";
|
||||||
import { randomSource } from "../../src/random/source";
|
import { randomSource } from "../../src/random/source";
|
||||||
|
|
||||||
describe("random generators", () => {
|
describe("random generators", () => {
|
||||||
@@ -28,6 +39,38 @@ describe("random generators", () => {
|
|||||||
expect(ulid(randomSource("deterministic", "id"), 0)).toMatch(
|
expect(ulid(randomSource("deterministic", "id"), 0)).toMatch(
|
||||||
/^[0-9A-HJKMNP-TV-Z]{26}$/u,
|
/^[0-9A-HJKMNP-TV-Z]{26}$/u,
|
||||||
));
|
));
|
||||||
|
it("bounds identifier batches before allocating their output array", () => {
|
||||||
|
const source = randomSource("deterministic", "identifier-bounds");
|
||||||
|
expect(() => randomIdentifiers(source, 0, "uuid4", 0)).toThrow(
|
||||||
|
/safe integer from 1–100,000/u,
|
||||||
|
);
|
||||||
|
expect(() => randomIdentifiers(source, 100_001, "uuid7", 0)).toThrow(
|
||||||
|
/safe integer from 1–100,000/u,
|
||||||
|
);
|
||||||
|
expect(() => randomIdentifiers(source, Number.NaN, "ulid", 0)).toThrow(
|
||||||
|
/safe integer from 1–100,000/u,
|
||||||
|
);
|
||||||
|
expect(randomIdentifiers(source, 2, "uuid4", 0)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
it("rejects an inclusive MAX_SAFE_INTEGER bound before adding one", () => {
|
||||||
|
const source = randomSource("deterministic", "integer-boundary");
|
||||||
|
expect(() =>
|
||||||
|
randomIntegers(
|
||||||
|
source,
|
||||||
|
1,
|
||||||
|
Number.MAX_SAFE_INTEGER,
|
||||||
|
Number.MAX_SAFE_INTEGER,
|
||||||
|
),
|
||||||
|
).toThrow(/less than Number\.MAX_SAFE_INTEGER/u);
|
||||||
|
expect(
|
||||||
|
randomIntegers(
|
||||||
|
source,
|
||||||
|
1,
|
||||||
|
Number.MAX_SAFE_INTEGER - 1,
|
||||||
|
Number.MAX_SAFE_INTEGER - 1,
|
||||||
|
),
|
||||||
|
).toEqual([Number.MAX_SAFE_INTEGER - 1]);
|
||||||
|
});
|
||||||
it("rejects biased duplicate alphabets", () =>
|
it("rejects biased duplicate alphabets", () =>
|
||||||
expect(() =>
|
expect(() =>
|
||||||
randomString(randomSource("deterministic", "x"), 3, "aab"),
|
randomString(randomSource("deterministic", "x"), 3, "aab"),
|
||||||
@@ -45,6 +88,21 @@ describe("random generators", () => {
|
|||||||
"four",
|
"four",
|
||||||
]).entropy,
|
]).entropy,
|
||||||
).toBe(8));
|
).toBe(8));
|
||||||
|
it("normalizes a custom word list once and reports its eligible count", () => {
|
||||||
|
const words = normalizeWordList(" one \r\n\r\ntwo\n three ");
|
||||||
|
expect(words).toMatchObject({
|
||||||
|
words: ["one", "two", "three"],
|
||||||
|
canonical: "one\ntwo\nthree",
|
||||||
|
normalization: "trim-lines-drop-empty-preserve-order-v1",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
passphraseWithWordList(
|
||||||
|
randomSource("deterministic", "normalized-words"),
|
||||||
|
4,
|
||||||
|
words,
|
||||||
|
),
|
||||||
|
).toMatchObject({ listSize: 3, entropy: 4 * Math.log2(3) });
|
||||||
|
});
|
||||||
it("bounds alphabet and word-list allocation before generation", () => {
|
it("bounds alphabet and word-list allocation before generation", () => {
|
||||||
const source = randomSource("deterministic", "bounds");
|
const source = randomSource("deterministic", "bounds");
|
||||||
expect(() => randomString(source, 1, "ab".repeat(70_000))).toThrow(
|
expect(() => randomString(source, 1, "ab".repeat(70_000))).toThrow(
|
||||||
@@ -55,3 +113,98 @@ describe("random generators", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("local draws", () => {
|
||||||
|
it("reproduces coin flips and reports only binary outcomes", () => {
|
||||||
|
const first = coinFlips(randomSource("deterministic", "coins"), 50);
|
||||||
|
const second = coinFlips(randomSource("deterministic", "coins"), 50);
|
||||||
|
expect(first).toEqual(second);
|
||||||
|
expect(first.every((value) => value === "Heads" || value === "Tails")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deals every card from a standard deck without replacement", () => {
|
||||||
|
const cards = dealCards(randomSource("deterministic", "cards"), 1, 52);
|
||||||
|
expect(cards).toHaveLength(52);
|
||||||
|
expect(new Set(cards).size).toBe(52);
|
||||||
|
expect(
|
||||||
|
cards.every((card) => /^(?:A|[2-9]|10|J|Q|K)[♠♥♦♣]$/u.test(card)),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shuffles an inclusive integer range exactly once", () => {
|
||||||
|
const values = integerSequence(
|
||||||
|
randomSource("deterministic", "sequence"),
|
||||||
|
-2,
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
expect(values).toHaveLength(6);
|
||||||
|
expect([...values].sort((a, b) => a - b)).toEqual([-2, -1, 0, 1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters dates by weekday and can sample without replacement", () => {
|
||||||
|
const values = randomDates(
|
||||||
|
randomSource("deterministic", "dates"),
|
||||||
|
4,
|
||||||
|
"2024-02-01",
|
||||||
|
"2024-02-29",
|
||||||
|
{ weekdays: [1], withoutReplacement: true },
|
||||||
|
);
|
||||||
|
expect(values).toHaveLength(4);
|
||||||
|
expect(new Set(values).size).toBe(4);
|
||||||
|
expect(
|
||||||
|
values.every((value) => new Date(`${value}T00:00:00Z`).getUTCDay() === 1),
|
||||||
|
).toBe(true);
|
||||||
|
expect(() =>
|
||||||
|
randomDates(
|
||||||
|
randomSource("deterministic", "bad-date"),
|
||||||
|
1,
|
||||||
|
"2023-02-29",
|
||||||
|
"2023-03-01",
|
||||||
|
),
|
||||||
|
).toThrow(/valid Gregorian/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates exact-length decimal fractions with no binary formatting", () => {
|
||||||
|
const values = decimalFractions(
|
||||||
|
randomSource("deterministic", "fractions"),
|
||||||
|
4,
|
||||||
|
12,
|
||||||
|
);
|
||||||
|
expect(values).toHaveLength(4);
|
||||||
|
expect(values.every((value) => /^0\.\d{12}$/u.test(value))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates reproducible coordinates inside geographic bounds", () => {
|
||||||
|
const first = randomCoordinates(
|
||||||
|
randomSource("deterministic", "coordinates"),
|
||||||
|
100,
|
||||||
|
6,
|
||||||
|
);
|
||||||
|
const second = randomCoordinates(
|
||||||
|
randomSource("deterministic", "coordinates"),
|
||||||
|
100,
|
||||||
|
6,
|
||||||
|
);
|
||||||
|
expect(first).toEqual(second);
|
||||||
|
expect(
|
||||||
|
first.every(
|
||||||
|
({ latitude, longitude }) =>
|
||||||
|
latitude >= -90 &&
|
||||||
|
latitude <= 90 &&
|
||||||
|
longitude >= -180 &&
|
||||||
|
longitude <= 180,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects draw requests beyond their allocation bounds", () => {
|
||||||
|
const source = randomSource("deterministic", "bounds");
|
||||||
|
expect(() => coinFlips(source, 100_001)).toThrow(/100,000/u);
|
||||||
|
expect(() => dealCards(source, 1, 53)).toThrow(/1–52/u);
|
||||||
|
expect(() => integerSequence(source, 0, 100_000)).toThrow(/100,000/u);
|
||||||
|
expect(() => decimalFractions(source, 100_000, 64)).toThrow(/4,000,000/u);
|
||||||
|
expect(() => randomCoordinates(source, 1, 11)).toThrow(/0–10/u);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user